← All posts

Most of our tests were not testing anything

· 3 min read

The audit started as a complaint. Our test suite took nine minutes, and the payoff for those nine minutes was a regression roughly every sprint. Somebody asked, in a retro, whether the tests were doing anything at all.

So we checked. We took the 1,847 tests in the repository and, for each one, asked a single question: if the code it covers were deleted, would this test fail?

Four hundred and twelve of them would not.

The four shapes of a useless test#

They clustered into four patterns, and once you can name them you start seeing them everywhere.

The tautology. The test asserts on a mock it configured itself.

tsit('fetches the user', async () => {
  const api = { getUser: vi.fn().mockResolvedValue({ id: 1, name: 'Ada' }) };
  const service = new UserService(api);

  const result = await service.load(1);

  expect(result.name).toBe('Ada'); // proves the mock works
});

If load were (id) => this.api.getUser(id) or a hundred lines of transformation, this passes either way. It tests vi.fn().

The snapshot nobody reads. Snapshot tests are fine when someone reviews the diff. Ours had 340 snapshot files and the review habit was npm test -- -u followed by committing whatever came out. A snapshot that is regenerated on failure is a very slow way of writing nothing down.

The render smoke test. it('should create', () => expect(component).toBeTruthy()). Generated by the CLI, never extended. It catches a constructor that throws, which is real but narrow, and there were 290 of them.

The test that asserts implementation. These are the dangerous ones, because they fail constantly and teach the team that failures are noise:

tsexpect(component.privateHelper).toHaveBeenCalledWith('a', 'b');

Rename the helper, the test fails, the behaviour is unchanged. After enough of these, a red build stops meaning anything.

What we replaced them with#

We deleted 412 tests and wrote 60. Coverage fell to 71%. Escaped regressions went from roughly one a sprint to two in the following quarter.

The replacements followed one rule: a test asserts on something a user or a caller can observe. For a component that means rendered output and emitted events. For a service it means the return value and the calls it makes to its collaborators — but only the calls that are part of its contract, not every internal hop.

tsit('shows the retry affordance when loading fails', async () => {
  const fixture = TestBed.createComponent(UserCard);
  server.use(http.get('/api/users/1', () => HttpResponse.error()));

  fixture.componentRef.setInput('userId', 1);
  await fixture.whenStable();

  expect(screen.getByRole('button', { name: /retry/i })).toBeVisible();
});

This one fails if the request handling breaks, if the error state stops rendering, if the button loses its accessible name, or if the retry affordance is removed. Four real regressions, one test, no knowledge of the component's internals.

The coverage conversation#

Dropping from 94% to 71% was the hardest part to sell, because coverage is the number leadership has been trained to look at.

What helped was reframing it as two separate numbers. We started reporting coverage of code changed in the last 90 days alongside total coverage. Changed code sat at 88% and total sat at 71%, and the gap was almost entirely stable code written years ago that nobody was touching. That is exactly where test value is lowest — a module that has not changed in three years is not where your next regression comes from.

What I would keep#

Three practices survived the audit and I would start any new codebase with them.

Test at the boundary the code actually has. If a service's job is to turn an HTTP response into a domain object, mock the HTTP layer and assert on the domain object. Do not mock the thing under test.

Write the test that would have caught the last bug. After every incident, the postmortem action item is a single test, added to the suite, that fails against the pre-fix code. Those tests are earned rather than speculative, and in our repo they have the highest hit rate by a wide margin.

Delete tests without ceremony. A test that has never failed for a real reason in two years is a liability with a maintenance cost. We treat deleting one as routine, not as a loss of coverage to be justified.

The suite is now four minutes.