Playwright is great, but it also created a funny problem: now some teams call every JavaScript testing conversation “Playwright vs something”. That is the wrong framing.
Playwright is mostly about browser automation and end-to-end testing. But a real JavaScript test stack is bigger than that. You still need unit tests, component tests, API-level tests, mocks, coverage, fake timers, CI reporting, and some sane strategy for not turning your suite into a flaky swamp.
So the better question is:
What JavaScript testing tools should I use around Playwright, or instead of Playwright, depending on the test level?
This article is not a hype list. This is the practical map.
The quick answer
If I were starting a modern JavaScript/TypeScript project today, I would usually pick one of these stacks:
| Project type | Practical stack |
|---|---|
| Modern Vite/React/Vue/Svelte app | Vitest + Testing Library + MSW + Playwright or Cypress for E2E |
| Existing React/Next.js project with Jest already installed | Jest + React Testing Library + MSW |
| Node.js backend or CLI tool | Node test runner or Vitest |
| Browser-heavy component testing | Cypress Component Testing or Vitest browser mode |
| Selenium/Appium/cloud/mobile-style automation | WebdriverIO |
| Older flexible Node/browser test setup | Mocha + Chai/Sinon or Node assert |
Jest is still not dead. Vitest is not just “Jest but trendy”. Cypress is not only E2E. WebdriverIO is not only “Selenium in JavaScript”. Mocha is old, but still useful in some codebases. And Node now has its own built-in test runner, which is actually worth knowing.
The mistake is trying to use one tool for everything.
First: separate the test levels
Before choosing a framework, define what you are testing.
1. Unit tests
You test a function, class, utility, reducer, parser, mapper, validator, or business rule.
Good tools:
- Jest
- Vitest
- Node test runner
- Mocha
Example: test a pure function.
export function calculateDiscount(price, userType) {
if (userType === 'vip') return price * 0.8;
if (userType === 'employee') return price * 0.5;
return price;
}
import { calculateDiscount } from './discount.js';
it('applies VIP discount', () => {
expect(calculateDiscount(100, 'vip')).toBe(80);
});
This does not need Playwright. It does not need a browser. It does not need Docker, Kubernetes, or some test pyramid tattoo. It just needs a fast runner and clean assertions.
2. Component tests
You test a UI component in isolation, but you still care about user behavior: text, buttons, forms, validation, callbacks, loading states.
Good tools:
- Testing Library with Jest or Vitest
- Cypress Component Testing
- Storybook test tooling, depending on the team
Example: test the behavior, not the implementation.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
it('shows an error for invalid email', async () => {
render(<LoginForm />);
await userEvent.type(screen.getByLabelText(/email/i), 'wrong-email');
await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
expect(screen.getByText(/enter a valid email/i)).toBeInTheDocument();
});
Notice what is not in the test:
- no checking internal React state
- no testing private methods
- no CSS class obsession
- no snapshot wall of death
You test what the user can observe.
3. API and integration tests
You test a route, service, database interaction, queue consumer, or HTTP client.
Good tools:
- Vitest or Jest
- Node test runner
- Supertest for Express-style HTTP APIs
- MSW for mocking external network calls
- Pact if you need contract testing
Example with MSW-style mocked API behavior:
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { getUser } from './client.js';
const server = setupServer(
http.get('https://api.example.com/users/:id', ({ params }) => {
return HttpResponse.json({ id: params.id, name: 'Dmitry' });
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
it('loads a user by id', async () => {
await expect(getUser('42')).resolves.toEqual({ id: '42', name: 'Dmitry' });
});
This is cleaner than mocking fetch manually in every test. Your app still performs a request, but the network is controlled.
4. End-to-end tests
You test the real app flow in a browser: login, checkout, dashboard, uploads, permissions, exports.
Good tools:
- Playwright
- Cypress
- WebdriverIO
This is where browser automation belongs. But if you put all your testing here, you will eventually hate your test suite.
Jest: still the default in many serious projects
Jest became popular because it gave JavaScript teams a full testing experience out of the box:
- test runner
- assertions
- mocking
- spies
- fake timers
- snapshots
- coverage
- watch mode
- good React ecosystem support
For many existing React and Node.js projects, Jest is still the boring correct choice. Boring is not an insult. Boring means hiring is easier, documentation is everywhere, and half the team already knows what expect().toEqual() means.
Basic Jest test:
import { formatUsername } from './formatUsername';
jest.mock('./logger', () => ({
log: jest.fn()
}));
it('trims and lowercases username', () => {
expect(formatUsername(' Dmitry ')).toBe('dmitry');
});
Testing async code:
it('returns user profile', async () => {
const profile = await loadProfile('user-123');
expect(profile).toMatchObject({
id: 'user-123',
active: true
});
});
Fake timers:
jest.useFakeTimers();
it('calls callback after delay', () => {
const callback = jest.fn();
startTimer(callback);
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledTimes(1);
});
Where Jest is good:
- mature React projects
- projects with many existing Jest tests
- teams that need stable docs and predictable tooling
- codebases already configured around Babel/Jest/ts-jest
Where Jest can annoy you:
- native ESM setups
- Vite projects
- heavy TypeScript transform setups
- slow watch mode in bigger projects
- configuration drift after years of patches
My take: do not rewrite a working Jest suite just because Vitest exists. But for a new Vite-based app, I would seriously consider Vitest first.
Vitest: the modern default for Vite and ESM-heavy projects
Vitest feels familiar if you know Jest, but it fits modern frontend tooling better. It was built around Vite, so it can reuse Vite configuration, module resolution, transforms, aliases, and TypeScript handling.
Basic Vitest test:
import { describe, expect, it } from 'vitest';
import { parsePrice } from './parsePrice';
describe('parsePrice', () => {
it('parses a dollar string', () => {
expect(parsePrice('$19.99')).toBe(19.99);
});
});
Mocking with Vitest:
import { vi, expect, it } from 'vitest';
import { sendWelcomeEmail } from './emailService';
import { mailer } from './mailer';
vi.mock('./mailer', () => ({
mailer: {
send: vi.fn()
}
}));
it('sends welcome email', async () => {
await sendWelcomeEmail('test@example.com');
expect(mailer.send).toHaveBeenCalledWith(
expect.objectContaining({ to: 'test@example.com' })
);
});
Vitest is especially nice when your app already uses Vite. You avoid that classic nonsense where your app builds one way, but your tests resolve aliases and transforms another way.
Where Vitest is good:
- Vite projects
- Vue, React, Svelte, Solid, and modern frontend apps
- TypeScript-heavy projects
- fast local feedback
- Jest-like migration without a full mental reset
Where Vitest may not be perfect:
- older enterprise codebases already deeply invested in Jest
- projects with very custom Jest plugins
- teams where CI tooling/reporters are already built around Jest output
My take: Vitest is the tool I would teach first for a new modern frontend project. Jest is still the safe older default. Vitest is the better fit for the newer stack.
Testing Library: not a runner, but usually the right way to test UI
Testing Library is often misunderstood. It is not really a replacement for Jest or Vitest. It works with them.
Jest/Vitest runs the test. Testing Library helps you render components, find elements, and interact with the UI in a user-like way.
Bad style:
expect(wrapper.find('.submit-button').prop('disabled')).toBe(true);
Better style:
expect(screen.getByRole('button', { name: /submit/i })).toBeDisabled();
The second version is closer to how a real user experiences the page. A user does not know your component class name. They see a button.
This matters because frontend tests often become trash when they are tied to implementation details. Then someone refactors the component and twenty tests fail even though the app still works.
Testing Library pushes you toward better test design:
- find by role
- find by label
- find by text
- interact like a user
- avoid testing internals
For React, Vue, Angular, Svelte, and DOM testing, this style is usually a better long-term bet than testing component internals.
MSW: mocks that do not feel fake
Mocking is where many JavaScript test suites become ugly.
You can mock fetch. You can mock Axios. You can stub every service. You can create some magical test-only client. All of that works until your test environment stops resembling the app.
MSW takes a better approach: intercept requests at the network layer. Your app makes the request. MSW returns controlled responses.
Example: testing an error state.
server.use(
http.get('https://api.example.com/profile', () => {
return new HttpResponse(null, { status: 500 });
})
);
render(<ProfilePage />);
expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument();
This is useful for:
- loading states
- empty states
- error states
- retry logic
- API contract expectations
- frontend development before backend is ready
MSW is not a full testing framework. It is supporting infrastructure. But in a serious frontend test stack, it often matters more than people expect.
Node’s built-in test runner: underrated for backend and libraries
Node has its own test runner now. For small libraries, backend utilities, CLI tools, and dependency-light projects, this is very attractive.
Example:
import test from 'node:test';
import assert from 'node:assert/strict';
import { slugify } from './slugify.js';
test('slugify converts title to URL-safe string', () => {
assert.equal(slugify('Hello JavaScript Testing'), 'hello-javascript-testing');
});
Run it:
node --test
Why use it?
- no extra test runner dependency
- good enough for many Node projects
- clean for libraries and small services
- works well with native ESM
Why not use it?
- fewer ecosystem conveniences than Jest/Vitest
- less familiar for many frontend teams
- UI/component testing still needs other tools
- mocking ergonomics may not be as comfortable as Jest/Vitest
My take: for a frontend app, I would not start here. For a Node package or small backend tool, it is absolutely worth considering.
Mocha: old, flexible, still around
Mocha is one of those tools that feels old because it is old. But old does not mean useless.
Mocha gives you a test runner. You bring your own assertion library and mocking library if needed. That flexibility was one of its strengths before Jest became the batteries-included default.
Example with Node assert:
import assert from 'node:assert/strict';
import { describe, it } from 'mocha';
import { isValidEmail } from './isValidEmail.js';
describe('isValidEmail', () => {
it('rejects invalid email', () => {
assert.equal(isValidEmail('not-email'), false);
});
});
Where Mocha still makes sense:
- older Node projects
- teams that want flexible building blocks
- codebases already using Chai/Sinon
- browser + Node hybrid legacy setups
Where I would avoid it:
- new frontend projects where Vitest/Jest is easier
- teams that want one standard recommended path
- projects where setup time matters more than flexibility
Mocha is not my first recommendation for a new project, but I would not panic if I joined a mature codebase using it well.
Cypress: good for browser-visible testing, not just E2E
Cypress became famous as an end-to-end tool, but its component testing mode is also important.
The big difference from jsdom-style tests is that Cypress runs components in a real browser. That can catch issues that simulated DOM tests may miss: layout behavior, browser APIs, CSS-driven interaction, focus behavior, and visual debugging.
Example component test style:
import { SearchBox } from './SearchBox';
it('filters results by query', () => {
cy.mount(<SearchBox items={['MacBook', 'ThinkPad', 'iMac']} />);
cy.findByRole('textbox', { name: /search/i }).type('mac');
cy.findByText('MacBook').should('exist');
cy.findByText('iMac').should('exist');
cy.findByText('ThinkPad').should('not.exist');
});
Cypress is good when you want:
- visual debugging
- component behavior in a real browser
- frontend developer-friendly test authoring
- readable test flows
- a tool that product/dev/QA people can often understand together
Where Cypress can be less ideal:
- multi-tab scenarios
- some browser automation edge cases
- teams already heavily standardized on Playwright
- very large E2E suites where speed and isolation become a fight
My take: Cypress is still a strong tool, especially for component testing and developer-facing UI tests. But I would not use Cypress as a replacement for unit tests. That is how teams accidentally make everything slow.
WebdriverIO: useful when you need WebDriver, mobile, cloud, or enterprise-style automation
WebdriverIO is interesting because it sits closer to the Selenium/Appium world than Jest/Vitest/Cypress.
It can automate browsers through WebDriver-style protocols and can also work with Appium for mobile testing. This makes it useful when your testing world is not just one React app in one browser.
Example:
describe('login flow', () => {
it('logs in with valid credentials', async () => {
await browser.url('/login');
await $('#email').setValue('user@example.com');
await $('#password').setValue('correct-password');
await $('button[type="submit"]').click();
await expect($('h1')).toHaveTextContaining('Dashboard');
});
});
Where WebdriverIO makes sense:
- teams with Selenium/Grid infrastructure
- BrowserStack/Sauce Labs/cloud device farms
- Appium mobile automation
- enterprise QA teams
- projects that need protocol-level flexibility
- mixed web/mobile automation strategy
Where I would not start with WebdriverIO:
- simple modern frontend app
- small startup with no mobile automation need
- team that only wants fast web E2E tests
My take: WebdriverIO is not the trendy default, but it is very practical if your world includes mobile, real devices, Selenium infrastructure, and enterprise test architecture.
Puppeteer: still useful, but more narrow now
Puppeteer is excellent for browser control, scraping, PDF generation, automation scripts, Chrome-specific workflows, and some testing use cases.
But as a general test framework for modern E2E testing, Playwright usually feels more complete now. Puppeteer still has a place, but I would not build a fresh full QA automation strategy around it unless the project has a specific Chrome/DevTools automation need.
Use Puppeteer for:
- Chrome automation scripts
- PDF/screenshot generation
- scraping internal pages
- performance experiments
- small technical utilities
Use a fuller test framework for:
- cross-browser E2E
- team-wide QA automation
- trace/debug workflows
- test reporting at scale
The combinations that actually make sense
A good JavaScript test stack is layered.
Stack A: modern frontend app
vitest
@testing-library/react
@testing-library/user-event
msw
playwright or cypress
Use Vitest for unit/component logic. Use Testing Library for UI behavior. Use MSW for API states. Use Playwright or Cypress only for critical browser flows.
Stack B: mature React app
jest
@testing-library/react
@testing-library/user-event
msw
If Jest is already there and working, keep it. Improve test quality before changing tools.
Stack C: Node backend/API
node:test
node:assert
supertest
Or:
vitest
supertest
msw
Pick the heavier stack only if you need the ergonomics.
Stack D: enterprise QA / web + mobile
webdriverio
appium
allure or another reporter
browser/device cloud integration
This is not the stack I would pick for a tiny web app. But for cross-platform QA, it can make a lot of sense.
What not to do
Do not test everything through the browser
If a discount calculation can be tested as a function, test it as a function.
Bad:
await page.goto('/checkout');
await page.fill('#coupon', 'VIP20');
await page.click('#apply');
await expect(page.locator('.total')).toHaveText('$80.00');
Maybe you need one E2E test like that. But the discount rules should also have fast unit tests.
Good:
expect(applyCoupon(100, 'VIP20')).toBe(80);
Do not snapshot everything
Snapshots are useful for some stable output, but they are often abused.
A giant React snapshot usually tells you nothing except “some blob changed”. That is not a good test. Prefer assertions that describe behavior.
Do not mock the thing you are trying to test
This sounds obvious, but JavaScript tests often become a theater performance where everything is mocked and nothing real is verified.
If your test passes because every dependency is fake, ask yourself what confidence it actually gives you.
Do not ignore cleanup
A huge source of flaky JavaScript tests is shared state:
- mocks not reset
- fake timers left enabled
- localStorage/sessionStorage not cleared
- files reused across tests
- global objects patched and never restored
- server handlers not reset
Add boring cleanup:
afterEach(() => {
vi.restoreAllMocks();
localStorage.clear();
sessionStorage.clear();
});
For Jest:
afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
sessionStorage.clear();
});
Boring cleanup saves hours of detective work later.
My practical recommendation
If someone asks me “Jest or something else?” my answer is:
Use Vitest for new Vite/ESM-heavy projects.
Use Jest if the project already uses Jest, especially in mature React/Node codebases.
Use Testing Library for UI component behavior.
Use MSW for API mocking instead of hand-rolling ugly fetch mocks everywhere.
Use Node’s built-in test runner for small Node libraries, backend utilities, and projects where low dependency count matters.
Use Cypress when you want browser-visible component tests or a strong frontend debugging experience.
Use WebdriverIO when you need Selenium/Appium/cloud/mobile-style automation.
Use Mocha when the codebase already uses it or you specifically want a flexible older runner.
And use Playwright where it belongs: important browser flows, cross-browser E2E, and high-value user journeys.
The winning setup is usually not one magical framework. It is a clean testing strategy:
- fast unit tests
- behavior-focused component tests
- controlled API mocks
- a small number of valuable E2E tests
- stable CI
- aggressive cleanup
- no fake confidence
That is what separates a useful test suite from a pile of green checkmarks that nobody trusts.