How we used Jest, Vitest, and Playwright on a medical e-commerce platform: what to unit test, what to leave to E2E, and how we reached 85%+ coverage without slowing the team down.
A storefront can have 80% coverage and still break checkout. Coverage counts lines. Users count whether they can log in, search, add to cart, and pay.
We needed tests that:
The result: 85%+ coverage on critical modules and about 40% fewer production frontend issues after the suite matured.
| Layer | Tool | What it owns |
|---|---|---|
| Unit | Jest / Vitest | Pure logic, hooks, price calculators |
| Component | Testing Library | Forms, errors, accessible names |
| E2E | Playwright | Login, search, checkout, account |
If a bug needs a browser, it is not a unit test. If a function has no UI, it is not an E2E test.
We kept Jest for legacy packages and moved new code to Vitest for speed. The API is close enough that tests port cleanly.
import { describe, expect, it } from 'vitest';
import { getCheckoutTotal } from './pricing';
describe('getCheckoutTotal', () => {
it('applies a single promo once', () => {
const total = getCheckoutTotal({
items: [{ sku: 'A', price: 100, qty: 2 }],
promo: { type: 'percent', value: 10 },
taxRate: 0.08,
});
expect(total).toBe(194.4);
});
it('rejects stacking exclusive promos', () => {
expect(() =>
getCheckoutTotal({
items: [{ sku: 'A', price: 100, qty: 1 }],
promo: { type: 'stack', codes: ['SAVE10', 'SAVE20'] },
taxRate: 0,
})
).toThrow(/exclusive/);
});
});
Test the rules product will argue about in a war room. Skip wrapping className strings.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
test('shows a field error when email is invalid', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={jest.fn()} />);
await user.type(screen.getByLabelText('Email'), 'not-an-email');
await user.click(screen.getByRole('button', { name: 'Sign in' }));
expect(screen.getByText(/valid email/i)).toBeInTheDocument();
});
Query by role and label. If the test cannot find the control, neither can a screen reader.
We automated four flows and treated them as release blockers:
import { expect, test } from '@playwright/test';
test('guest can complete checkout with a test card', async ({ page }) => {
await page.goto('/products/sample-kit');
await page.getByRole('button', { name: 'Add to cart' }).click();
await page.getByRole('link', { name: 'Cart' }).click();
await page.getByRole('button', { name: 'Checkout' }).click();
await page.getByLabel('Email').fill('qa@example.com');
await page.getByLabel('Card number').fill('4242424242424242');
await page.getByRole('button', { name: 'Place order' }).click();
await expect(page.getByRole('heading', { name: /order confirmed/i })).toBeVisible();
});
Use test cards and a sandbox payment provider. Never hit production processors from CI.
getByRole over CSS selectorsstorageState for authenticated suitesWe set 85% as a floor for checkout, cart, and auth, not for every marketing component. Global 85% encourages people to test getters.
{
"coverage": {
"include": ["src/features/checkout/**", "src/features/auth/**"],
"thresholds": {
"lines": 85,
"functions": 85,
"branches": 80
}
}
}
Uncovered branches in promo stacking got a test. Uncovered SVG icons did not.
Tests that the team trusts get run. Tests that flake get quarantined or deleted. A red build that people ignore is worse than a missing test.
The strategy is simple: unit-test money math, component-test forms, and E2E the paths that lose revenue when they break.