ProductHow it worksPricingBlogDocsLoginFind Your First Bug
Testing pyramid diagram showing Jest at the unit and integration layers and Playwright at the E2E layer, with labeled cost and speed tradeoffs at each tier
TestingPlaywrightJest

Playwright vs Jest: The 70/20/10 Rule That Settles It

Tom Piaggio
Tom PiaggioCo-Founder at Autonoma

Playwright vs Jest is a false comparison. Jest is a JavaScript test runner for unit and integration tests. It runs in Node, never touches a browser, and is fast precisely because of that constraint. Playwright is a browser automation framework for end-to-end tests. It drives real Chromium, Firefox, or WebKit and is the right tool for testing what a user actually experiences. They belong at different layers of the testing pyramid. Most teams need both. The confusion comes from the "vs" framing, not from the tools themselves.

The testing pyramid has a canonical ratio: roughly 70% unit tests, 20% integration tests, 10% end-to-end tests. Teams that follow it ship faster, break less, and spend less time debugging flaky CI. Teams that ignore it tend to either over-invest in slow E2E suites that become maintenance burdens, or under-invest in browser-level coverage and discover checkout bugs in production on a Friday.

Jest owns the 70 and the 20. Playwright owns the 10. Those are not interchangeable numbers. Each tier has a different cost profile, a different failure mode, and a different answer to the question of what exactly is being tested. Conflating the tools means collapsing those distinctions, and you pay for it in CI minutes, debugging time, and missed coverage.

The ratio is not a hard rule. Some applications are entirely backend logic and run closer to 90/10/0. Others are complex multi-step user flows where 15% E2E is more appropriate. But the underlying principle holds: different layers of the pyramid need different tools, and Jest and Playwright were each designed for their respective layer. This article walks through exactly which layer each owns, what happens when teams get it wrong, and the one layer most teams skip entirely.

What Jest Is For

Jest is a test runner. It executes JavaScript or TypeScript in a Node.js process, gives you a describe/test/expect API, and ships with mocking, code coverage, and watch mode built in. It does not open a browser. It does not render HTML. It has no concept of a DOM unless you add one via jsdom, and even then it is a simulated DOM, not a real one.

That constraint is a feature, not a limitation. Because Jest never leaves Node, it is fast. A test suite with hundreds of unit tests runs in seconds. There are no browser startup times, no network round trips, no rendering pipelines. You can run Jest on every keystroke if you want. Most CI pipelines complete a full Jest suite in under two minutes.

Jest owns the unit layer. A unit test in Jest imports a function, calls it with specific inputs, and asserts on the outputs. The function under test might be a cart calculation, a date formatter, a discount rule, a data transformer. Whatever it is, it has no dependencies on the DOM or a running server. Jest verifies the logic in complete isolation.

Jest also owns integration tests when paired with MSW. Mock Service Worker intercepts HTTP requests at the network layer and returns fixture responses, which means you can test a component that fetches real data from a real API shape, without a running backend. This is the integration layer: modules working together, boundary inputs and outputs verified, but still in Node, still fast, still deterministic.

Here is a cart module tested with Jest and MSW. It covers three scenarios: adding items to the cart and checking the total, applying a discount code, and fetching product data from a mocked API endpoint. The entire suite runs in Node with no browser, no server, and no flakiness:

/**
 * Jest unit + integration tests for the cart module.
 *
 * - Unit tests exercise createCart() directly (no I/O, no network).
 * - Integration tests use MSW to intercept fetch() calls so we can
 *   verify the API client without a running backend.
 *
 * Run:  npm test
 */

const { createCart } = require("../src/cart");
const { fetchProducts, submitOrder } = require("../src/api");
const { http, HttpResponse } = require("msw");
const { setupServer } = require("msw/node");

/* ------------------------------------------------------------------ */
/*  Fixtures                                                          */
/* ------------------------------------------------------------------ */
const PRODUCTS_FIXTURE = [
  { id: "widget-a", name: "Widget Alpha", price: 25.0 },
  { id: "widget-b", name: "Widget Beta", price: 40.0 },
  { id: "widget-c", name: "Widget Gamma", price: 15.0 },
];

const ORDER_FIXTURE = {
  orderId: "ORD-1001",
  items: [{ id: "widget-a", name: "Widget Alpha", price: 25, quantity: 2 }],
  shipping: { name: "Jane Doe", address: "123 Main St", city: "Springfield", zip: "62704" },
  total: 50,
  createdAt: "2025-01-15T12:00:00.000Z",
};

/* ------------------------------------------------------------------ */
/*  MSW mock server (intercepts fetch at the Node level)              */
/* ------------------------------------------------------------------ */
const server = setupServer(
  http.get("http://localhost:3000/api/products", () => {
    return HttpResponse.json(PRODUCTS_FIXTURE);
  }),
  http.post("http://localhost:3000/api/orders", async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json({ ...ORDER_FIXTURE, items: body.items, shipping: body.shipping }, { status: 201 });
  })
);

beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

/* ================================================================== */
/*  UNIT TESTS — pure logic, zero I/O                                 */
/* ================================================================== */
describe("Cart — unit tests", () => {
  let cart;

  beforeEach(() => {
    cart = createCart();
  });

  test("starts empty with a $0.00 subtotal", () => {
    expect(cart.getItems()).toEqual([]);
    expect(cart.subtotal()).toBe(0);
  });

  test("adds a single item and calculates the subtotal", () => {
    cart.addItem({ id: "widget-a", name: "Widget Alpha", price: 25 });
    expect(cart.getItems()).toHaveLength(1);
    expect(cart.subtotal()).toBe(25);
  });

  test("increments quantity when the same item is added twice", () => {
    cart.addItem({ id: "widget-a", name: "Widget Alpha", price: 25 });
    cart.addItem({ id: "widget-a", name: "Widget Alpha", price: 25 });
    expect(cart.getItems()).toHaveLength(1);
    expect(cart.getItems()[0].quantity).toBe(2);
    expect(cart.subtotal()).toBe(50);
  });

  test("adds multiple distinct items and totals correctly", () => {
    cart.addItem({ id: "widget-a", name: "Widget Alpha", price: 25 });
    cart.addItem({ id: "widget-b", name: "Widget Beta", price: 40, quantity: 2 });
    expect(cart.getItems()).toHaveLength(2);
    expect(cart.subtotal()).toBe(105); // 25 + 40*2
  });

  test("removes an item from the cart", () => {
    cart.addItem({ id: "widget-a", name: "Widget Alpha", price: 25 });
    cart.addItem({ id: "widget-b", name: "Widget Beta", price: 40 });
    cart.removeItem("widget-a");
    expect(cart.getItems()).toHaveLength(1);
    expect(cart.getItems()[0].id).toBe("widget-b");
  });

  test("throws when removing an item that is not in the cart", () => {
    expect(() => cart.removeItem("nonexistent")).toThrow("Item nonexistent not in cart");
  });

  test("applies a percentage discount code (SAVE10 → 10% off)", () => {
    cart.addItem({ id: "widget-b", name: "Widget Beta", price: 40, quantity: 3 });
    expect(cart.subtotal()).toBe(120);
    expect(cart.applyDiscount("SAVE10")).toBe(108); // 120 - 12
  });

  test("applies a flat discount code (FLAT5 → $5 off)", () => {
    cart.addItem({ id: "widget-c", name: "Widget Gamma", price: 15 });
    expect(cart.applyDiscount("FLAT5")).toBe(10); // 15 - 5
  });

  test("flat discount never goes below zero", () => {
    cart.addItem({ id: "widget-c", name: "Widget Gamma", price: 3 });
    expect(cart.applyDiscount("FLAT5")).toBe(0);
  });

  test("throws on an invalid discount code", () => {
    cart.addItem({ id: "widget-a", name: "Widget Alpha", price: 25 });
    expect(() => cart.applyDiscount("BOGUS")).toThrow("Invalid discount code: BOGUS");
  });

  test("clear() empties the cart", () => {
    cart.addItem({ id: "widget-a", name: "Widget Alpha", price: 25 });
    cart.clear();
    expect(cart.getItems()).toEqual([]);
    expect(cart.subtotal()).toBe(0);
  });
});

/* ================================================================== */
/*  INTEGRATION TESTS — real fetch calls intercepted by MSW           */
/* ================================================================== */
describe("API client — integration tests (MSW)", () => {
  test("fetchProducts() returns the product catalogue", async () => {
    const products = await fetchProducts();
    expect(products).toHaveLength(3);
    expect(products[0]).toMatchObject({ id: "widget-a", name: "Widget Alpha" });
  });

  test("fetchProducts() throws on a server error", async () => {
    server.use(
      http.get("http://localhost:3000/api/products", () => {
        return new HttpResponse(null, { status: 500 });
      })
    );
    await expect(fetchProducts()).rejects.toThrow("Failed to fetch products: 500");
  });

  test("submitOrder() posts the order and returns confirmation", async () => {
    const order = await submitOrder({
      items: [{ id: "widget-a", name: "Widget Alpha", price: 25, quantity: 2 }],
      shipping: { name: "Jane Doe", address: "123 Main St", city: "Springfield", zip: "62704" },
    });
    expect(order.orderId).toBe("ORD-1001");
    expect(order.shipping.name).toBe("Jane Doe");
  });

  test("submitOrder() throws on a 400 response", async () => {
    server.use(
      http.post("http://localhost:3000/api/orders", () => {
        return HttpResponse.json({ error: "Cart is empty" }, { status: 400 });
      })
    );
    await expect(
      submitOrder({ items: [], shipping: { name: "X", address: "Y" } })
    ).rejects.toThrow("Failed to submit order: 400");
  });
});

What Jest cannot test. Jest cannot verify that your checkout button actually submits the form. It cannot assert that a CSS transition completes before the modal is interactive. It cannot catch a race condition that only appears when two asynchronous requests resolve in a specific order under real network latency. It cannot test what a user with a screen reader experiences when navigating your page. For all of that, you need a browser.

What Playwright Is For

Playwright is a browser automation framework. It drives real browser instances, Chromium, Firefox, and WebKit, and exposes an API for navigating pages, clicking elements, filling forms, and asserting on page state. The key word is real. Playwright does not simulate a browser. It controls an actual one.

That matters because a real browser enforces everything your application depends on: CSS layout, JavaScript event bubbling, browser storage APIs, network request handling, service workers, accessibility trees. Jest with jsdom can approximate some of these. Playwright does not approximate. It exercises the same code path a real user would.

Playwright owns the E2E layer. An E2E test in Playwright opens a browser, navigates to a URL, interacts with the UI, and asserts on what the user sees. It tests the whole stack from the frontend down: the component renders correctly, the API call succeeds, the response is displayed accurately, the state persists across a page reload. No mocking. No shortcuts.

The cost of that fidelity is time. A Playwright suite is slower than a Jest suite. Browser launch, page navigation, and network requests all take real wall-clock time. A thorough Playwright suite might take five to fifteen minutes in CI where Jest takes thirty seconds. That is the correct tradeoff for the E2E layer, but it means you do not write Playwright tests for everything. You write them for the critical paths.

Below is a Playwright test against the same cart application. It opens the shop in a real browser, adds two items, navigates to checkout, fills the shipping form, and asserts the confirmation page renders the correct order summary. The same flow Jest verified at the unit and integration layers, now verified end-to-end in a real browser:

/**
 * Playwright E2E test — full checkout flow.
 *
 * Prerequisites:
 *   1. Start the cart app:  npm run dev
 *   2. Install browsers:    npx playwright install
 *   3. Run:                 npx playwright test
 *
 * The test opens a real Chromium browser, navigates to the shop, adds two
 * items to the cart, proceeds to checkout, fills the shipping form, and
 * asserts the order confirmation page renders the correct summary.
 */

const { test, expect } = require("@playwright/test");

const BASE_URL = process.env.BASE_URL || "http://localhost:3000";

test.describe("Checkout flow", () => {
  test("adds two items, fills shipping, and confirms the order", async ({ page }) => {
    /* ----- Step 1: Visit the shop ---------------------------------- */
    await page.goto(BASE_URL);
    await expect(page.locator("h1")).toHaveText("Shop");

    /* ----- Step 2: Add Widget Alpha to the cart -------------------- */
    const alphaRow = page.locator('tr[data-product-id="widget-a"]');
    await alphaRow.locator("button.add-to-cart").click();

    /* ----- Step 3: Add Widget Beta to the cart --------------------- */
    const betaRow = page.locator('tr[data-product-id="widget-b"]');
    await betaRow.locator("button.add-to-cart").click();

    /* ----- Step 4: Verify the cart summary is visible -------------- */
    const cartSummary = page.locator("#cart-summary");
    await expect(cartSummary).toBeVisible();
    await expect(page.locator("#cart-total")).toHaveText("65.00"); // 25 + 40

    /* ----- Step 5: Proceed to checkout ----------------------------- */
    await page.locator("#checkout-btn").click();
    await expect(page.locator("h1")).toHaveText("Checkout");

    /* ----- Step 6: Fill the shipping form -------------------------- */
    await page.fill("#shipping-name", "Ada Lovelace");
    await page.fill("#shipping-address", "42 Analytical Engine Ln");
    await page.fill("#shipping-city", "London");
    await page.fill("#shipping-zip", "SW1A 1AA");

    /* ----- Step 7: Place the order --------------------------------- */
    await page.locator("#place-order-btn").click();

    /* ----- Step 8: Assert the confirmation page -------------------- */
    await expect(page.locator("h1")).toHaveText("Order Confirmed!");
    await expect(page.locator("#order-id")).toContainText("ORD-");
    await expect(page.locator("body")).toContainText("Ada Lovelace");
    await expect(page.locator("body")).toContainText("Widget Alpha");
    await expect(page.locator("body")).toContainText("Widget Beta");
    await expect(page.locator("body")).toContainText("$65.00");
  });

  test("shows the correct item count after adding the same item twice", async ({ page }) => {
    await page.goto(BASE_URL);

    const gammaRow = page.locator('tr[data-product-id="widget-c"]');
    await gammaRow.locator("button.add-to-cart").click();
    await gammaRow.locator("button.add-to-cart").click();

    await expect(page.locator("#cart-summary")).toBeVisible();
    await expect(page.locator("#cart-items")).toContainText("x2");
    await expect(page.locator("#cart-total")).toHaveText("30.00"); // 15 * 2
  });
});

What Playwright cannot replace. Playwright is not the right tool for testing a pure JavaScript function. Running a browser to assert that calculateTotal([item1, item2]) returns the correct number is wasteful. It is also fragile. If your layout changes, the test breaks even if the logic is correct. Playwright tests are also slower to write. Setting up browser contexts, dealing with selector stability, and handling async timing adds authorship overhead that is not justified for logic that Jest can verify in milliseconds.

That same authorship overhead is what stops most teams from having a real E2E layer at all. Writing Playwright tests is slow. Maintaining them as selectors rot and the DOM shifts is slower. A large portion of the teams we talk to have a Jest suite that is healthy and a Playwright suite that quietly atrophied. That is the exact gap we built Autonoma to close.

The Testing Pyramid: Where Both Fit

The testing pyramid is the principle (originally attributed to Mike Cohn, later popularized by Martin Fowler) that your test suite should have more unit tests than integration tests, and more integration tests than E2E tests. The canonical ratio is 70% unit, 20% integration, 10% E2E. Those are not hard rules. They are a heuristic for where you get the most signal per dollar of CI time. For a deeper breakdown of what distinguishes each layer, see unit vs integration vs E2E testing.

Testing pyramid showing the 70/20/10 ratio with unit tests as the wide base, integration tests in the middle, and E2E tests at the narrow top

LayerToolWhat It TestsSpeedIdeal Share
UnitJestPure functions, isolated modules, business logicVery fast (milliseconds per test)~70%
IntegrationJest + MSWModules working together, API contracts, component data flowFast (seconds per suite)~20%
E2EPlaywrightFull user flows in a real browser, cross-layer behaviorSlow (minutes per suite)~10%

The pyramid shape exists because bugs are cheaper to catch at lower layers. A unit test that catches a discount calculation error costs nothing to run and pinpoints the exact function. A Playwright test that catches the same bug costs minutes of CI time and tells you the checkout page is broken, but not why. Both are valuable. The pyramid just tells you to lean toward the cheaper signal first.

The common failure mode. Teams either skip Jest entirely (writing Playwright tests for everything, which is slow and expensive) or skip Playwright entirely (trusting unit tests to catch integration failures, which they cannot). The teams with the best coverage write both and place them correctly.

Jest tells you the engine works. Playwright tells you the car drives. You need both.

For a deeper look at the pyramid itself and how to balance investment at each layer, the testing pyramid guide is the canonical reference.

A Real Project Using Both

The companion repo at github.com/Autonoma-Tools/playwright-vs-jest-when-to-use-each combines both tools in a single project. It is a minimal cart application with a Node backend, and the test suite has three layers:

The unit layer uses Jest to test the cart module directly. Adding an item, removing an item, applying a discount, calculating a total with tax. These tests run against the module in isolation, no HTTP, no DOM. They run in under two seconds and catch regressions in the business logic the moment they are introduced.

The integration layer adds MSW to test the React components that consume the cart API. The component renders, makes a fetch call, and MSW intercepts it with a fixture response. The test asserts the correct items render, the total is displayed, and the "Add to cart" button updates state. This layer catches issues at the component boundary without a running backend.

The E2E layer uses Playwright to run the full checkout flow in a real browser. The test navigates to the shop, adds two items, fills the checkout form, and asserts the confirmation page. This layer catches things neither lower layer can: layout regressions, form submission failures, navigation bugs, and anything that depends on the browser environment.

The three layers answer three different questions. Together, they give you high confidence without redundant coverage.

When You Might Use Only One

There are legitimate cases where you reach for one tool and skip the other.

Jest only makes sense for backend services with no meaningful UI. A Node API that transforms data, validates inputs, and writes to a database has no browser behavior to test. Jest covers the logic completely. Playwright would add no signal.

It also makes sense for pure utility libraries. A date formatting library, a validation schema, a calculation module. These are all Jest territory. There is no browser layer to exercise. If you are evaluating Jest against newer runners, the Jest vs Vitest comparison covers the performance and API differences.

Playwright only is rare but not wrong for very small teams building simple frontline apps where the UI is the entire product. If your application is essentially a form over an API and the business logic lives server-side, a focused set of Playwright E2E tests might be all you need in the frontend repo. The tradeoff is slower feedback on logic errors. Acceptable for some teams at early stage. Teams migrating from older browser drivers should also read the Playwright vs Selenium comparison for the key differences.

The case for skipping E2E entirely is much weaker. Every application has critical user flows. Checkout, authentication, onboarding, core feature workflows. Those flows involve the full stack. Jest cannot verify them. Skipping Playwright means you find out about checkout failures in production. Teams that reach for Autonoma are typically the ones who tried to skip the E2E layer because authoring Playwright suites was too expensive, then discovered the cost of not having one was higher.

The most expensive test is the one your production users run for you.

The E2E Maintenance Problem

Playwright E2E tests are the right tool for critical flows. They are also the most expensive tests to maintain. Selectors break when the DOM changes. Timing assumptions fail under CI load. API responses change shape. A test suite that was green on Monday is red on Thursday because a designer renamed a button's class.

This maintenance burden is why many teams let their E2E suite atrophy. They write the initial tests, the selectors rot, the tests start flaking, and eventually someone comments out the failing tests to unblock a deploy. The suite is technically present but no longer providing signal. For a deeper look at why E2E suites go flaky and what to do about it, the flaky tests guide covers root causes and remediation strategies.

This is exactly the problem we built Autonoma to solve. Rather than maintaining a hand-authored Playwright suite, our agents read your codebase, plan test scenarios from your routes and components, and execute them against your running application in a real browser. When your code changes, the Maintainer agent updates the tests to match. The selectors don't rot because no one wrote selectors by hand in the first place. If you are comparing Playwright vs Cypress for your E2E layer, the Playwright vs Cypress comparison covers the tradeoffs in detail.

Side-by-side comparison showing Jest as a lightweight isolated testing flow versus Playwright as a full-stack browser testing flow with page, server, and database connections

Jest vs Playwright: Side-by-Side Feature Matrix

DimensionJestPlaywright
RuntimeNode.jsReal browser (Chromium, Firefox, WebKit)
SpeedVery fast (ms per test)Slower (browser launch + navigation time)
What it testsFunctions, modules, components in isolationFull user flows, real browser behavior
Cannot testCSS, browser APIs, real network, actual DOM renderingPure logic efficiently; not worth it for isolated functions
MockingBuilt-in; deep module and function mockingNetwork interception (page.route), storage state
CI costLowHigher (browser binaries, longer run time)
DebuggingConsole output, stack tracesTrace viewer, video, screenshots on failure
Pyramid layerUnit + IntegrationE2E
Language supportJavaScript / TypeScriptJavaScript, TypeScript, Python, Java, C#
Learning curveLow (standard test runner APIs)Medium (selector strategies, async patterns, browser contexts)
Typical suite time30–120 seconds5–20 minutes

FAQ

For most web applications, yes. Jest covers unit and integration tests, which are the bulk of your suite. Playwright covers end-to-end tests for your critical user flows. Skipping Jest means slow, fragile tests for simple logic that should run in milliseconds. Skipping Playwright means your checkout, authentication, and onboarding flows are untested in a real browser. The two tools are complementary, not redundant. The exception is backend-only services with no UI, where Jest alone is sufficient.

Technically, yes. You can pair Jest with Puppeteer or Playwright's Node API and drive a browser from a Jest test. But this is uncommon and not recommended. Playwright's own test runner (built on the same engine) is better suited for E2E: it handles parallelism, retries, trace collection, and browser lifecycle out of the box. Use Jest for unit and integration tests. Use Playwright's test runner for E2E.

No. Playwright cannot efficiently replace Jest for unit and integration testing. Opening a real browser to test a discount calculation function is wasteful. It's 100x slower and makes failures harder to diagnose. Jest's strength is fast, isolated, logic-level verification. Playwright's strength is full-stack, real-browser, user-flow verification. They are not substitutes.

Mock Service Worker (MSW) is a library that intercepts HTTP requests at the service worker level (in the browser) or via Node.js interceptors (in Jest). When used with Jest, it lets you test components or modules that make API calls without running a real backend. Your Jest test fires a fetch, MSW intercepts it and returns a fixture, and your test asserts on what the component does with that response. This makes integration tests fast, deterministic, and backend-independent.

Focus on critical user flows: the paths that, if broken, directly affect revenue or core functionality. Checkout, authentication, onboarding, key feature workflows. Cover each happy path and the most impactful error states. Avoid writing Playwright tests for every UI variation. That coverage belongs at the unit or integration layer with Jest. A good heuristic: if it involves a real user completing a meaningful task, it belongs in Playwright. If it's verifying a function's output, it belongs in Jest.

Autonoma replaces the authoring and maintenance burden of a Playwright suite, not Playwright itself. Under the hood, our agents use real browser automation to execute test scenarios. What you eliminate is writing selectors by hand, updating tests when the DOM changes, and debugging flaky timing issues. If you need fine-grained control over browser primitives (network interception rules, storage state setup, Playwright traces for debugging specific failures), a hand-authored Playwright suite gives you that control. If you want comprehensive E2E coverage without maintaining it yourself, Autonoma is the right layer.

It's a heuristic, not a law. Backend-heavy projects with minimal UI might run 85% unit tests and skip E2E almost entirely. UI-heavy consumer apps might push more weight toward integration and E2E. The principle underneath the ratio is sound: slower, broader tests should be fewer in number because they are more expensive to run and harder to diagnose. Start with the heuristic and adjust based on where your bugs actually surface.