Testing Frameworks Compared: Jest, Vitest, Bun Test, and Playwright
Testing Frameworks Compared: Jest, Vitest, Bun Test, and Playwright
Choosing a test framework used to be simple: you picked Jest. Now there are genuinely good alternatives, each with different strengths. This guide compares the main options, covers different testing levels, and gives you practical advice on what to use where.
Framework Comparison
| Feature | Jest | Vitest | Bun test | Playwright Test |
|---|---|---|---|---|
| TypeScript | Via transform | Native | Native | Native |
| Speed | Moderate | Fast | Fastest | N/A (e2e) |
| ESM support | Painful | Native | Native | Native |
| Watch mode | Yes | Yes (fast) | Yes | No |
| Snapshot testing | Yes | Yes | Yes | Yes (visual) |
| Browser testing | Via jsdom | Via happy-dom/jsdom | Via happy-dom | Real browsers |
| API style | Jest globals | Jest-compatible | Jest-compatible | Playwright |
| Config | jest.config.ts | vitest.config.ts | bunfig.toml | playwright.config.ts |
Jest
Jest defined modern JavaScript testing. It popularized snapshot testing, zero-config setup, and parallel test execution. It's still the most widely used framework and has the largest ecosystem of plugins and integrations.
The problems: ESM support is still awkward (experimental and requiring flags), TypeScript needs a transformer (ts-jest or @swc/jest), and startup time is slow for large projects. The module mocking system (jest.mock) is powerful but creates tightly coupled tests.
// jest.config.ts
export default {
transform: { "^.+\\.tsx?$": "@swc/jest" },
testEnvironment: "node",
};
Vitest
Vitest is the testing framework built for the Vite ecosystem. It uses the same configuration as your Vite project, supports ESM natively, runs TypeScript without transformation, and is significantly faster than Jest.
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "happy-dom", // for DOM tests
coverage: {
provider: "v8",
},
},
});
Vitest's watch mode is excellent -- it uses Vite's module graph to only re-run tests affected by your changes. For projects already using Vite, Vitest is the obvious choice.
Bun Test
Bun's built-in test runner is the fastest option. It runs TypeScript natively, uses a Jest-compatible API, and has near-instant startup time.
import { test, expect, describe } from "bun:test";
describe("math", () => {
test("adds numbers", () => {
expect(1 + 1).toBe(2);
});
});
The trade-offs: Bun's test runner has fewer features than Jest or Vitest. Module mocking is supported but less mature. Coverage reporting works but has fewer output format options. For straightforward unit and integration tests, it's excellent. For complex testing needs (custom reporters, extensive mocking), you may hit limits.
Playwright Test
Playwright Test is purpose-built for end-to-end testing in real browsers. It's not a unit testing framework and shouldn't be compared directly with the others.
// playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
use: {
baseURL: "http://localhost:3000",
},
projects: [
{ name: "chromium", use: { browserName: "chromium" } },
{ name: "firefox", use: { browserName: "firefox" } },
],
});
Playwright's killer features: auto-waiting (no more sleep calls), trace viewer for debugging failed tests, and the ability to test across Chromium, Firefox, and WebKit with the same test code.
Unit vs Integration vs End-to-End
Unit Tests
Test individual functions, classes, or modules in isolation. Mock external dependencies.
import { test, expect } from "vitest";
import { calculateDiscount } from "./pricing";
test("applies 10% discount for orders over $100", () => {
expect(calculateDiscount(150)).toBe(15);
});
Use Bun test or Vitest for unit tests. Both are fast and have low overhead.
Integration Tests
Test multiple modules working together, often including a real database or HTTP layer.
import { test, expect } from "vitest";
import { createApp } from "./app";
import { setupTestDatabase } from "./test-utils";
test("POST /users creates a user", async () => {
const db = await setupTestDatabase();
const app = createApp(db);
const response = await app.request("/users", {
method: "POST",
body: JSON.stringify({ name: "Alice" }),
});
expect(response.status).toBe(201);
const user = await db.query("SELECT * FROM users WHERE name = 'Alice'");
expect(user).toHaveLength(1);
});
Use Vitest or Bun test for integration tests. The key is testing real interactions without mocking everything away.
End-to-End Tests
Test complete user flows through a real browser.
import { test, expect } from "@playwright/test";
test("user can sign up and see dashboard", async ({ page }) => {
await page.goto("/signup");
await page.fill('[name="email"]', "[email protected]");
await page.fill('[name="password"]', "securepassword");
await page.click('button[type="submit"]');
await expect(page.getByText("Welcome to your dashboard")).toBeVisible();
});
Use Playwright Test for e2e tests. It handles browser lifecycle, screenshots, and cross-browser testing.
Mocking Approaches
Mocking is necessary but overused. Here's a hierarchy of approaches from best to worst:
- Dependency injection: Pass dependencies as parameters. No mocking framework needed.
- Test doubles: Create simple implementations of interfaces for testing.
- Module mocking: Use
vi.mock()orjest.mock()to replace imports. - Global mocking: Override global objects like
fetchorDate.
// Prefer dependency injection
function createOrderService(db: Database, emailer: Emailer) {
return {
async placeOrder(order: Order) {
await db.insert(order);
await emailer.send(order.email, "Order confirmed");
},
};
}
// Test with simple test doubles
test("sends confirmation email", async () => {
const sentEmails: string[] = [];
const fakeEmailer = { send: async (to: string) => { sentEmails.push(to); } };
const fakeDb = { insert: async () => {} };
const service = createOrderService(fakeDb, fakeEmailer);
await service.placeOrder({ email: "[email protected]", items: [] });
expect(sentEmails).toContain("[email protected]");
});
Module mocking (vi.mock, jest.mock) should be a last resort, not the default. It creates brittle tests that break when you refactor internal module boundaries.
Coverage Tools
All three frameworks support coverage reporting:
# Vitest
vitest --coverage
# Bun
bun test --coverage
# Jest
jest --coverage
Vitest supports both V8 and Istanbul coverage providers. V8 is faster and more accurate for modern code. Bun uses its own coverage implementation.
Coverage targets to aim for:
- 80%+ line coverage for application code is a reasonable baseline
- Don't chase 100%. The last 20% often requires testing trivial code or writing brittle tests
- Focus on branch coverage, not just line coverage. Uncovered branches are where bugs hide
When Snapshot Testing Helps vs Hurts
Snapshot testing captures the output of a function and compares it against a saved reference:
test("renders user profile", () => {
const html = renderProfile({ name: "Alice", role: "admin" });
expect(html).toMatchSnapshot();
});
Helps when:
- Testing serialized output (HTML, JSON responses, CLI output)
- Catching unintended changes in complex output
- Rapid prototyping when the exact output shape is still evolving
Hurts when:
- Developers blindly update snapshots without reviewing changes (
uto update all) - Snapshots are huge (thousands of lines) and nobody reads them
- The output changes frequently, making every PR include snapshot updates
- Used instead of specific assertions that document expected behavior
The rule of thumb: if you wouldn't manually review the snapshot diff in a PR, the snapshot isn't adding value.
Recommendations
- New project with Vite: Use Vitest. Shares config, fast, excellent DX.
- New project with Bun: Use Bun test. Fastest startup, zero config, Jest-compatible API.
- Existing Jest project: Stay with Jest unless ESM/TypeScript pain is significant. Migration to Vitest is straightforward when you're ready.
- End-to-end testing: Use Playwright Test. It's the best browser testing tool available.
- Testing pyramid: Many unit tests (fast, cheap), fewer integration tests (slower, more confidence), minimal e2e tests (slowest, highest confidence). Don't invert this pyramid.