Testing Best Practices

A good test suite catches bugs early, documents what your code does, and lets you refactor without worrying. A bad one breaks for the wrong reasons, runs slowly, and takes work to keep up to date.

Structuring Tests with Arrange-Act-Assert

The AAA pattern splits a test into three parts:

test("user can be created with valid data", () => {
  // Arrange — set up the inputs and conditions
  const userData = { name: "Alice", email: "alice@example.com" };

  // Act — perform the action you're testing
  const user = createUser(userData);

  // Assert — verify the result
  expect(user.id).toBeDefined();
  expect(user.name).toBe("Alice");
});

This makes a test easy to read. Someone reading it can see what was set up, what was done, and what was expected.

Testing One Thing at a Time

Each test should verify a single behavior. When a test fails, you should immediately know what broke.

// Bad — testing multiple behaviors in one test
test("user operations", () => {
  const user = createUser({ name: "Alice" });
  expect(user.name).toBe("Alice");
  updateUser(user, { name: "Bob" });
  expect(user.name).toBe("Bob");
});

// Good — separate, focused tests
test("creates user with given name", () => {
  const user = createUser({ name: "Alice" });
  expect(user.name).toBe("Alice");
});

test("updates user name", () => {
  const user = createUser({ name: "Alice" });
  updateUser(user, { name: "Bob" });
  expect(user.name).toBe("Bob");
});

Using Descriptive Test Names

A test name should read like a sentence that describes the expected behavior:

// Bad
test("test1", () => {});
test("divide", () => {});

// Good
test("throws ValidationError when email is invalid", () => {});
test("returns empty array when no users match filter", () => {});

When a test fails, the name appears in the output. A descriptive name tells you what went wrong without having to read the test body.

Testing Edge Cases

Most bugs are not in the happy path. Test the boundary values, the empty inputs, and the types you did not expect:

describe("divide", () => {
  test("divides positive numbers", () => {
    expect(divide(10, 2)).toBe(5);
  });

  test("handles negative divisor", () => {
    expect(divide(10, -2)).toBe(-5);
  });

  test("throws on division by zero", () => {
    expect(() => divide(10, 0)).toThrow();
  });
});

Avoiding Test Interdependence

Each test should be independent and able to run in any order. Never rely on state from a previous test:

// Bad — second test depends on the first
let user;
test("creates user", () => {
  user = createUser({ name: "Alice" });
});
test("updates user", () => {
  updateUser(user, { name: "Bob" }); // fails if first test fails
});

// Good — each test is self-contained
test("updates user name", () => {
  const user = createUser({ name: "Alice" });
  updateUser(user, { name: "Bob" });
  expect(user.name).toBe("Bob");
});

Measuring Code Coverage

Code coverage shows which parts of your code are executed during tests. Jest has it built in:

npx jest --coverage

This reports four metrics: statements (lines executed), branches (if/else paths taken), functions (functions called), and lines (source lines hit). Aim for high coverage, but do not chase 100%. Some code is hard to test and not worth the effort. And keep in mind what coverage actually tells you: it tells you which code was not run by your tests. It does not tell you that the code that was run is correct.

Modern Alternatives and E2E Tools

Vitest is a newer test runner built for Vite projects. It uses the same API as Jest (describe, test, expect) but replaces jest.fn() with vi.fn() and jest.mock() with vi.mock(). If your project already uses Vite, use Vitest. It is faster and needs less configuration.

For end-to-end testing, tools like Playwright and Cypress let you automate a real browser and test entire user flows (clicking buttons, filling forms, navigating pages). These sit at the top of the testing pyramid. Use them for the workflows that matter most, not for everything.

Comparing Testing Tools

Feature Jest Vitest Playwright
Test type Unit / Integration Unit / Integration End-to-End
Speed Good Fastest Slower
Config Low (higher for ESM/TS edge cases) Minimal (Vite) Minimal
Assertions Built-in Built-in Built-in
Mocking Built-in Built-in Not applicable
Best for General projects Vite projects Full app testing

For new projects, my advice is to use Vitest if you are already using Vite, and Jest for everything else. Add Playwright or Cypress when you need E2E coverage.