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.