Skip to content
End To End Tester

Jest

The default JavaScript test runner — configuration, projects, snapshot testing, fake timers, and the choices that keep a large Jest suite fast.

1 min read · updated 19 September 2026

Jest is the default JavaScript and TypeScript test runner: assertions, mocking, coverage, snapshots and a watch mode in one package, with zero config for most projects.

#The configuration that matters

javascript
// jest.config.js
module.exports = {
  // SWC, not ts-jest: type checking belongs in `tsc --noEmit`, not in the
  // hot path of every test run. This alone often halves suite time.
  transform: { '^.+\\.(t|j)sx?$': '@swc/jest' },

  moduleNameMapper: { '^@/(.*)$': '<rootDir>/$1' },

  // Node by default; jsdom only where the DOM is genuinely needed.
  projects: [
    {
      displayName: 'node',
      testEnvironment: 'node',
      testMatch: ['<rootDir>/lib/**/*.test.ts']
    },
    {
      displayName: 'dom',
      testEnvironment: 'jsdom',
      testMatch: ['<rootDir>/components/**/*.test.tsx'],
      setupFilesAfterEnv: ['<rootDir>/jest.setup.ts']
    }
  ],

  collectCoverageFrom: ['lib/**/*.ts', 'components/**/*.tsx', '!**/*.d.ts'],
  coverageReporters: ['text-summary', 'lcov'],

  // Fail on an unexpected console.error — usually a React warning that
  // nobody would otherwise read.
  setupFilesAfterEach: []
};

Projects are the most underused feature. jsdom costs roughly 100ms of setup per test file; running pure logic tests in the node environment instead is free speed.

#Parameterised tests

typescript
// One case per row, each reported separately with its values in the name.
it.each([
  [12_000, 2_400],
  [10_000, 2_000],   // the boundary — the case most often missing
  [9_999, 0]
])('discounts an order of %i by %i', (subtotal, expected) => {
  expect(applyDiscount({ subtotalCents: subtotal }, policy).discountCents).toBe(expected);
});

// The tagged-template form, when the columns want names
it.each`
  tier          | shippingCents
  ${'standard'} | ${395}
  ${'gold'}     | ${0}
`('charges $shippingCents shipping for a $tier customer', ({ tier, shippingCents }) => {
  expect(shippingFor(anOrder({ tier })).cents).toBe(shippingCents);
});

#Fake timers

The cure for tests that wait, and for logic involving debounce, retry backoff or expiry.

typescript
describe('retry with backoff', () => {
  beforeEach(() => jest.useFakeTimers());
  afterEach(() => jest.useRealTimers());

  it('waits a second, then two, then gives up', async () => {
    const attempt = jest.fn()
      .mockRejectedValueOnce(new Error('503'))
      .mockRejectedValueOnce(new Error('503'))
      .mockResolvedValue('ok');

    const promise = retryWithBackoff(attempt, { retries: 2 });

    await jest.advanceTimersByTimeAsync(1_000);
    await jest.advanceTimersByTimeAsync(2_000);

    await expect(promise).resolves.toBe('ok');
    expect(attempt).toHaveBeenCalledTimes(3);
  });
});

advanceTimersByTimeAsync is the one to reach for when the code under test awaits between timers; the synchronous version leaves promise callbacks unflushed and produces confusing hangs.

Setting a fixed system time is the other half:

typescript
jest.useFakeTimers({ now: new Date('2026-01-01T12:00:00Z') });

#Matchers worth knowing

typescript
expect(order).toMatchObject({ status: 'paid' });          // partial — ignores extra keys
expect(items).toEqual(expect.arrayContaining([expect.objectContaining({ sku: 'a' })]));
expect(fn).toHaveBeenCalledWith(expect.stringMatching(/^ORD-/));
await expect(client.fetch()).rejects.toThrow(RateLimitError);
expect(total).toBeCloseTo(12.34, 2);                      // floats

toMatchObject is the workhorse. Asserting on a whole object with toEqual makes the test fail whenever an unrelated field is added, which is a slow way to make a suite hostile to change.

#Snapshots, carefully

typescript
// Inline snapshots keep the expected value next to the assertion, which
// makes a diff in review readable. Prefer them to external .snap files.
expect(formatInvoice(invoice)).toMatchInlineSnapshot(`
  "Invoice INV-1
   Field Notes x2    £24.00
   Shipping           £3.95
   Total             £27.95"
`);

The failure mode is well known — see snapshot testing — and the mitigation is to snapshot small, meaningful output rather than whole component trees.

#Keeping a large suite fast

  1. Replace ts-jest with @swc/jest. Frequently the single biggest win.
  2. Use the node environment where you can (via projects).
  3. --maxWorkers=50% in CI. The default over-subscribes a two-core runner and makes everything slower.
  4. --onlyChanged on pull requests, full suite on main.
  5. Find the slow files: jest --listTests plus the --verbose timings, or --detectOpenHandles when a suite hangs at the end (almost always an unclosed server or timer).

#Where Jest ends

Jest is a unit and component runner. Browser journeys belong in Playwright; integration tests against a real database belong in a suite with Testcontainers and a longer timeout. Mocking is covered separately in jest mocking, because it is the part most often overused.

Common questions

Should I use Jest or Vitest?
For a Vite-based project, Vitest — it shares the build pipeline, starts faster and needs almost no configuration. For everything else, Jest remains the safer default: it has the larger ecosystem, more mature Node support and far more answers on the internet.
Why are my Jest tests slow?
Usually the transform. TypeScript compiled through ts-jest on every file is the single biggest cost; switching to SWC or Babel typically cuts a suite's runtime in half. After that, look at jsdom — tests that do not touch the DOM should run in the node environment.
What is the difference between jest.mock and jest.spyOn?
jest.mock replaces a whole module before it is imported, which is hoisted to the top of the file. jest.spyOn wraps an existing method on an object you already have, and can be restored. Prefer spyOn when you have a reference; it is easier to reason about and does not leak across tests.

Runnable samples for this page

last test results ↗

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?