Foundations roadmap

Tests You Can Trust

Log in to save this

Saving keeps this in your list across devices. It's a free account — no card.

A test is a small program that runs your code and checks the result, so you find out a change broke something before your users do. But a test is only useful if you believe it: a test that fails at random gets ignored, and a test that can never fail gives false comfort. This lesson shows how to write tests worth trusting, using the test and expect style shared by Jest and Vitest.

Unit and integration tests

A unit test checks one small piece, usually a single function, on its own. It is fast and points straight at the broken line.

An integration test checks that several pieces work together: a route, its validation, and the database, for example. It is slower, but it catches the bugs that live between the pieces, such as a route that validates correctly and then saves the wrong field.

test('POST /tasks rejects a missing title', async () => {
  const res = await request(app).post('/tasks').send({});
  expect(res.status).toBe(400);
});

Most projects want many unit tests for logic and fewer integration tests for the important paths through the app.

Arrange, act, assert

Good tests have the same three steps, in order:

test('adds price times quantity for each item', () => {
  // Arrange: set up the input
  const items = [{ price: 500, qty: 2 }, { price: 250, qty: 1 }];
  // Act: run the code under test once
  const total = cartTotal(items);
  // Assert: check the result
  expect(total).toBe(1250);
});

One action per test keeps failures easy to read. The test name says what should happen, so a failure message like "adds price times quantity for each item" tells you what broke without opening the file.

Test the edges

Bugs cluster at edges, not in the middle. For cartTotal, the happy path with two items is the least interesting case. Ask instead:

  • What about an empty cart? It should be 0, not an error.
  • A quantity of zero, or a negative one?
  • A single item? A very large number?
  • Input that is missing or the wrong type?

When a bug is reported, first write a test that reproduces it, then fix the code. The test keeps that bug from quietly coming back.

Flaky tests and how to fix them

A flaky test sometimes passes and sometimes fails without any code change. The usual causes:

  • Time. A test that calls new Date() passes on weekdays and fails on Saturday, or breaks at midnight. Pass the date in as an argument, or use your test runner's fake timers, so the test controls the clock.
  • Order and shared state. Tests that share a list, a database row or a global variable pass alone and fail together, depending on which runs first. Reset the state before each test with beforeEach.
  • Network. A unit test that calls a real external API fails whenever that service is slow or down. Replace it with a fake that returns a fixed response.

Re-running a flaky test until it goes green is not a fix. It teaches the whole team to ignore red, and then real failures get ignored too.

A test that never fails is useless

This test passes, and it would pass even if loadUser were completely broken:

test('loads the user', () => {
  loadUser(1).then((user) => {
    expect(user.name).toBe('Ana');
  });
});

The promise is not returned or awaited, so the test finishes before the check runs. Write it with async and await instead. The general habit that catches this and similar mistakes: after writing a test, break the code on purpose and watch the test fail. If it stays green, it is not testing what you think.

Try it

Take one function from a project of yours, write a happy-path test, then three edge-case tests. For each one, change the function so it gives the wrong answer and confirm the right test goes red. A common mistake to avoid: judging tests by how many there are. Ten tests of the same happy path catch fewer bugs than three tests of different edges.

Resources

Curated resources for this node are on the way. Use what you already know how to search for, and check back soon.