Jest and Playwright: Pick the Layer First, Then the Runner
By Sergey Nosov
10 November 2026
Someone asked me a good question recently: Why not mix Jest and Playwright? Two more questions sit behind it. If Playwright can run unit tests too, why keep Jest at all? And if both stay, how do two test runners share one repository?
You can mix them, and Playwright’s own error message tells you how. Playwright can also run a plain unit test without starting a browser, at a price worth knowing. But the tool is the second decision. The first is the layer a test belongs to, and once you know the layer, the runner is usually an easy choice.
I ran every example in November 2026, on Jest 30.5.2 and Playwright 1.65.0 with Node.js 26. The examples are in TypeScript. A short section covers Vitest, which meets Playwright the same way Jest does.
The Layer Comes First
In an earlier article on test layers, I argued for one rule: do not classify a test by what it asserts; classify it by what it touches.
- Unit. The test stays inside the test process and touches only what your code owns, however many real classes it uses. When it fails, it points at a line.
- Integration. The test reaches something on this machine, or in a container it started: a database, a file, a port on localhost, your framework’s in-memory host. It points at a boundary.
- End-to-end. The test goes through the public surface of a deployed system, or calls a system you do not run. It points at a journey.
As a rule, every step up a layer costs more: more time, more false alarms, a harder diagnosis, more upkeep. In return, each step up buys more confidence that the assembled system works. The test pyramid entry in my Software Development Principles series gives the resulting advice: place most tests where the costs are lowest, and use the higher layers for confidence in the critical user journeys.
The well-known shapes are ways to make that trade. Mike Cohn’s test pyramid is widest at unit tests. Kent C. Dodds’s testing trophy is widest at integration tests, under a 2016 line from Guillermo Rauch: “Write tests. Not too many. Mostly integration.” The shapes disagree about the middle, and Dodds himself calls the line between integration and unit tests “a little bit fuzzy.” Neither shape puts most of its tests at the top.
Dodds presented the trophy in a February 2018 tweet, which his article embeds. Each line starts with a layer and ends with a tool: Cypress, which drives a browser, for end-to-end tests; Jest for integration and unit tests; and static checkers below them. That is the right order.
A layer is a property of what a test touches when it runs. A runner is only the program that executes it, and a runner will execute a test from a layer it was not built for.
Jest: Fast Tests inside the Process
Jest runs your tests in Node.js, its default environment, in the same process as the code they call. For JavaScript and TypeScript, that makes Jest a natural fit for the lower layers: unit tests, and integration tests that stay on this machine. It has a toolbox for that work:
- Module mocks.
jest.mock()swaps a module for a stand-in. - Fake timers.
jest.useFakeTimers()putsDate,setTimeout, and the other timer functions under the test’s control. - A jsdom environment. With the separate
jest-environment-jsdompackage, component tests get what Jest calls “a browser-like environment,” with no browser. - Watch mode.
jest --watchreruns the tests related to the files you change.
Here is a small sales-tax function, src/salesTax.ts. It keeps money in a floating-point number to stay short; real money code should count integer cents or use a decimal library.
export function applyTax(
price: number,
rate: number,
): number {
const total = price * (1 + rate);
return Math.round(total * 100) / 100;
}
And its Jest test, unit/salesTax.test.ts:
import { applyTax } from '../src/salesTax';
test('adds tax and rounds to cents', () => {
expect(applyTax(19.99, 0.0825)).toBe(21.64);
});
The test passed, and Jest timed it at 2 milliseconds or less. The whole run took about a second, so the test itself was a tiny part of it.
The toolbox starts to matter when the code under test has a collaborator that does not belong in a unit test. Suppose the tax rate comes from a module that calls a rate service, and src/checkout.ts uses it:
import { rateFor } from './rates';
import { applyTax } from './salesTax';
export async function totalFor(
price: number,
zip: string,
): Promise<number> {
const rate = await rateFor(zip);
return applyTax(price, rate);
}
A unit test of totalFor must not call the service. In unit/checkout.test.ts, one line swaps the module:
import { totalFor } from '../src/checkout';
import { rateFor } from '../src/rates';
jest.mock('../src/rates');
test('uses the rate for the ZIP code', async () => {
const zip = '75201';
const rate = jest.mocked(rateFor);
rate.mockResolvedValue(0.0825);
const total = await totalFor(19.99, zip);
expect(total).toBe(21.64);
expect(rate)
.toHaveBeenCalledWith(zip);
});
With the module mocked, rateFor returns what the test tells it to return. The test never leaves the process, so by the rule above it is still a unit test. It passed, also within 2 milliseconds.
One condition applies. This project compiles to CommonJS, and its TypeScript transformer, ts-jest, hoists the jest.mock call above the imports. For native ES modules, Jest’s documentation says that “the hoisting of jest.mock calls that happens in CJS won't work for ESM,” and Jest offers a separate API there, jest.unstable_mockModule.
Playwright: Real Browsers, Real Journeys
Playwright Test sits at the other end. Its documentation introduces it as “an end-to-end test framework for modern web apps,” and it drives real browsers: Chromium, WebKit, and Firefox. Two features aim at the timing problems of browser tests. Before Playwright acts on an element, it “auto-waits for all the relevant checks to pass and only then performs the requested action.” And its web assertions “will retry until the assertion passes, or the assertion timeout is reached.”
It is more than a browser driver. Its request fixture sends HTTP calls “directly from Node.js without loading a page.” Since version 1.62, released on 24 July 2026, component tests are built in. A mount fixture renders a story, a small wrapper around one of your components, in a real browser. The component-testing guide is plain about what such a test is: “A component test is a regular Playwright end-to-end test that runs against a small story gallery page served by your own dev server.”
Hold those features against the rule from the first section. An API test against a local server reaches something on this machine, and so does a component test against your own dev server. By what they touch, both are integration tests, unless the server or the page calls a system you do not run. Playwright’s guide calls the component test end-to-end; the rule keeps that word for tests that reach a deployed system. What the runner calls a test does not settle its layer.
Can Playwright Run a Plain Unit Test?
It can. A fixture is something a test asks for by name, such as page. The fixtures guide calls fixtures “on-demand”: Playwright Test “will setup only the ones needed by your test and nothing else.”
A test whose fixtures and hooks need no browser should therefore never start one. I checked. Here is the sales-tax test again as e2e/salesTax.spec.ts, with one line added, Playwright’s import:
import { test, expect } from '@playwright/test';
import { applyTax } from '../src/salesTax';
test('adds tax and rounds to cents', () => {
expect(applyTax(19.99, 0.0825)).toBe(21.64);
});
I set PLAYWRIGHT_BROWSERS_PATH to an empty folder, so that Playwright had no browser to start, and ran it. The test passed in 2 or 3 milliseconds, and the whole run took about a quarter of a second. In the same run, a test that asked for a page failed at once: “browserType.launch: Executable doesn't exist.” A test that used only the request fixture passed without a browser, too.
So the move cost one import line, because Playwright’s expect has the generic matchers, toBe among them. What you give up is the mocking half of the toolbox. I gave the jest.mock test from the Jest section the same import line and ran it under Playwright. It stopped at the first line that needed Jest: “ReferenceError: jest is not defined.”
As of version 1.65, Playwright Test has no counterpart to jest.mock. Its mocking tools aim at the browser: page.route() intercepts the page’s network requests, and page.clock controls time inside the page. Neither reaches into the Node.js process where a plain unit test runs.
You can work around that by passing collaborators in as arguments, or by adding a fake-timer library, and for simple functions that may be all you need. But each workaround rebuilds a piece of the toolbox by hand.
One more difference hides in how each runner handles TypeScript. Playwright’s documentation says that it “does not check the types and will run tests even if there are non-critical TypeScript compilation errors,” and it recommends running the TypeScript compiler alongside.
I ran Jest with the ts-jest preset, which by default checks types as it compiles; its README says it supports “all features of TypeScript including type-checking.” Jest’s other route to TypeScript, through Babel, does not check types either. If your unit tests move from ts-jest to Playwright, the type check has to move to a separate tsc step.
Do not read the run times above as a race. The two setups do different work, and I did not measure where the time went.
Two Runners in One Repository
So why not mix the two? You can, with one trap to clear first: by default, the two runners claim the same files. Jest’s default pattern takes files inside __tests__ folders and files with a .test or .spec suffix. Playwright’s documented default matches the same two suffixes.
The examples already sit in two folders, unit for Jest and e2e for Playwright. I left discovery at the defaults and ran each runner, and the folder names kept neither one out.
Jest picked up the Playwright files. Its own two tests passed, but the run failed, because each Playwright file stopped with this message:
Playwright Test needs to be invoked via 'npx playwright test' and excluded from Jest test runs. Creating one directory for Playwright tests and one for Jest is the recommended way of doing it.
That is Playwright speaking from inside Jest. It looks for JEST_WORKER_ID, an environment variable that Jest sets when it runs tests, and it refuses to go on.
Playwright fared worse in the other direction. It picked up the Jest files and hit “ReferenceError: jest is not defined” in one and “ReferenceError: test is not defined” in the other, because Jest’s globals do not exist there. Then it stopped, without running any of its own tests.
The fix is the one the message recommends: one folder for each runner, and one setting for each. Jest ignores the end-to-end folder in jest.config.js:
module.exports = {
preset: 'ts-jest',
testPathIgnorePatterns: [
'/node_modules/',
'<rootDir>/e2e/',
],
};
The <rootDir> token anchors the pattern to the project. Jest recommends it, because these patterns “match against the full path.” A bare /e2e/ would also ignore every test in a project checked out under a folder of that name.
Playwright looks only in its own folder, in playwright.config.ts:
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
});
And each runner gets its own script in package.json:
"scripts": {
"test": "jest",
"test:e2e": "playwright test"
}
With that in place, npm test ran Jest’s two tests and nothing else, and npm run test:e2e looked only in the e2e folder. One file there is out of place on purpose: the copy of the sales-tax test sits in e2e only to show that Playwright can run it. In a repository with both runners, it belongs with the Jest tests.
The Same Trap with Vitest
If your lower layers run on Vitest instead of Jest, the picture is the same. Vitest’s default environment is Node.js, and it has a toolbox of its own, vi.mock and vi.useFakeTimers included. Its default include pattern also claims .test and .spec files.
I repeated the experiment with Vitest 5.1.0. Vitest picked up the Playwright file and failed it with “Playwright Test did not expect test() to be called here.” The list of reasons that followed never mentions another test runner.
In the other direction, Playwright picked up the Vitest file and stopped. In a CommonJS project, the message was “Vitest cannot be imported in a CommonJS module using require().” In an ES-module project, it was “Vitest failed to find the current suite.” That one comes closest: its first suggestion is that Vitest was imported without running the vitest command. Still, none of the three messages names the cause, which is the same as before: two runners and one default pattern.
The fix has the same shape. Playwright keeps its testDir, and Vitest excludes the folder in vitest.config.ts:
import {
configDefaults,
defineConfig,
} from 'vitest/config';
export default defineConfig({
test: {
exclude: [
...configDefaults.exclude,
'e2e/**',
],
},
});
Keep the configDefaults.exclude line. Vitest’s configuration reference warns that “manually setting exclude in your config will replace the default value.” In the CommonJS project, I named the file vitest.config.mts, because Vite warned about ES-module syntax in a .ts configuration file there.
Setup Notes as of November 2026
Two TypeScript details are easy to trip over:
- TypeScript 7 and ts-jest. TypeScript is at version 7.0.2, and ts-jest 29.4.15 accepts only versions below 7. With npm 11’s default settings, installing the two together gave me TypeScript 6.0.3, and adding ts-jest to a project already on version 7 stopped with an
ERESOLVEerror. With itslegacy-peer-depssetting on, npm installed version 7.0.2, and ts-jest refused it at run time: the compiler “does not expose the JavaScript compiler API required by ts-jest.” The message suggests installing the two versions side by side. I stayed on 6.0.3. - TypeScript 6 and global types. The TypeScript 6.0 release notes have a section headed “types now defaults to [].” Until my
tsconfig.jsonlisted"types": ["jest"], ts-jest stopped the Jest files with errors such as “Cannot find name 'test'.”
One Runner Is a Legitimate Choice
Some projects run everything on one runner, and that buys something real: one configuration, one report.
Everything on Playwright can work when your unit tests are plain functions: values in, values out. You give up module mocks and fake timers for code that runs in Node.js, and you add a separate type-check step.
Everything on Jest or Vitest can work when no journey needs a real browser, as in a library or a service with no pages. A jsdom environment is, in Jest’s own phrase, browser-like, not a browser.
Vitest’s browser mode does use a real browser, and Vitest offers it for “integration or unit tests.” Playwright can drive that browser, but as a library; Vitest is still the only runner. For a journey through a deployed site, I would still reach for a tool built for journeys.
Either way, pick the layer first. A unit test does not become an end-to-end test because Playwright ran it. A slow test that calls a deployed service does not become a unit test because Jest did.
Two Layers, Two Questions
The sales-tax test from the Jest section asks whether the arithmetic is right, and it answers in milliseconds. Here is the kind of test that sits at the other end of the same feature, e2e/checkout.spec.ts:
import { test, expect } from '@playwright/test';
test('checkout shows a total', async ({ page }) => {
await page.goto('/cart');
await page.getByRole('button', { name: 'Check out' }).click();
const total = page.getByTestId('total');
await expect(total).toBeVisible();
});
It asks a different question: Can a customer click Check out and see a total? It does not check the arithmetic again; that is already pinned one layer down.
I ran it in Chromium against a small stand-in page on my own machine, with a hard-coded total behind the button. A second configuration file, selected with --config, gave Playwright a baseURL and a webServer entry, which started the page’s server before the tests.
The test passed in 587 milliseconds on the first run after I installed the browser, and in about 125 on the runs that followed. The arithmetic test took 2 or 3 milliseconds in the same runs. One trivial page is not a benchmark, but the direction is the one the layers predict.
By the rule from the first section, that run was an integration check, because the page lived on my machine and called nothing beyond it. Pointed at a deployed system, the same file is an end-to-end test. The layer moved, and the runner did not.
When a higher layer catches what a lower one missed, Ham Vocke’s rule from The Practical Test Pyramid says what to do. “If a higher-level test spots an error and there's no lower-level test failing, you need to write a lower-level test.” Suppose the checkout test fails one day: no total appears, because the tax function threw on an input nobody had tried. Fix the bug, and then add that input to the arithmetic test, so that the cheap layer catches it next time.
That leaves a question worth asking about your own project: Which layer caught your last bug, and which layer could have caught it sooner?
Takeaways
- Pick the layer first. Classify a test by what it touches, and then choose the runner that serves that layer well.
- Use Jest or Vitest for the lower layers. Module mocks, fake timers, jsdom, and watch mode are built for unit tests and local integration tests.
- Use Playwright for real browsers and journeys. It waits before it acts, its web assertions retry, and it also covers API and component tests.
- Playwright can run plain unit tests, and it starts no browser when no fixture or hook needs one. You give up module mocks, fake timers for Node.js code, and the type check that ts-jest does.
- Give two runners two folders. The default file patterns overlap. Ignore the end-to-end folder in Jest or Vitest, and set
testDirin Playwright. - The runner does not define the layer. A browser test that stays on your machine is an integration test. The same test against a deployed system is end-to-end.
- Check each thing once. Let the unit test pin the arithmetic and the end-to-end test pin the journey. When the top layer finds a bug, add a test below it.
Further Reading
- Unit, Integration, and End-to-End Tests: Where a Test Belongs and Why the Pyramid Holds (on this site, 3 October 2026)
- Fixtures (Playwright documentation)
- Component Testing (Playwright documentation)
- Configuring Jest (Jest documentation)
- exclude (Vitest configuration reference)
- The Practical Test Pyramid (Ham Vocke, martinfowler.com, 26 February 2018)
- Write Tests. Not Too Many. Mostly Integration. (Kent C. Dodds, 13 July 2019)