Skip to content

Unit Test Guidelines

Core Principles

1. Be clear and concise

  • Test names should describe behavior, not implementation details.
  • Use the pattern: "[does something] when [condition]".
  • Avoid filler words like "should", "correctly", and "properly".
  • Keep each test focused on one behavior or scenario.
// Bad
it('should correctly return the user when findById is called with a valid id', ...)

// Good
it('returns the user when found', ...)

When multiple assertions describe one coherent behavior, keep them in one test. Do not split into separate tests only to force one assertion per test.

// Good - one cohesive behavior, verified together
it('delegates options to repo.list and returns the result', async () => {
  repo.list.mockResolvedValue(listResult);
  const result = await service.list(options);
  expect(repo.list).toHaveBeenCalledWith(options);
  expect(result).toEqual(listResult);
});

3. Avoid duplicate coverage

Do not create multiple tests that exercise the same code path with trivial input variations. If a behavior is already covered, extend the existing test with an additional assertion or use parameterized tests.

4. Test observable behavior

  • Prioritize return values, side effects, thrown errors, and externally visible state changes.
  • Avoid asserting internal implementation details unless they are contractually important.
  • Skip trivial delegation tests unless argument correctness is the behavior under test.

Project Patterns

Service-level unit tests

  • Use vi.fn() for dependency mocks.
  • Build a fresh mock set and service instance in beforeEach.
  • Use fake timers only when code depends on current time.
  • Use expect.objectContaining(...) when only selected fields matter.
beforeEach(() => {
  vi.useFakeTimers();
  vi.setSystemTime(new Date(NOW));
  repo = createMockRepo();
  service = new MyService(repo as unknown as IMyRepository);
});

afterEach(() => vi.useRealTimers());

Shared fixtures

  • Prefer shared fixture factories/constants over repeated inline values.
  • Override only fields that differ for each scenario.
  • Keep fixture names explicit (baseUser, expiredSession, invalidToken).

What Not To Do

Anti-pattern Instead
One assertion per test for the same scenario Combine related assertions in one descriptive test
Repeating the same happy-path setup in every test Move shared setup into beforeEach
Asserting internals and mock call counts by default Assert externally observable outcomes
Copy-pasting tests with tiny input tweaks Consolidate or parameterize

Troubleshooting

Error: A unit test fails after implementation changes.
Cause: Either the implementation regressed or the test no longer reflects desired behavior.
Solution: Apply a TDD loop: update expected behavior in the test first, confirm it fails for the right reason, implement the change, then rerun tests.