How to Write Tests That Actually Help

Most developers know they should write tests. Fewer know how to write tests that genuinely help. The difference between a useful test suite and a burdensome one is not the number of tests; it is whether those tests give you confidence to ship and refactor without fear.

If your tests break every time you change an implementation detail, if they pass even when real bugs slip through, or if they take so long to run that nobody bothers, something has gone wrong. In my experience working across dozens of codebases, the root cause is almost always the same: tests that verify how code works rather than what it does. Here is how to write tests that actually earn their keep.

Test Behaviour, Not Implementation

This is the single most important principle in testing, and the one most frequently violated.

A good test describes what your code should do, not how it does it internally. Consider this example:

// Bad: tests implementation details
test('adds item to internal array', () => {
  const cart = new ShoppingCart();
  cart.addItem({ id: 1, price: 10 });
  expect(cart._items).toHaveLength(1);
  expect(cart._items[0].id).toBe(1);
});

// Good: tests observable behaviour
test('added item appears in cart summary', () => {
  const cart = new ShoppingCart();
  cart.addItem({ id: 1, name: 'Widget', price: 10 });
  const summary = cart.getSummary();
  expect(summary.items).toContainEqual({ name: 'Widget', price: 10 });
  expect(summary.total).toBe(10);
});

The first test breaks if you rename the internal array or change the data structure. The second test only breaks if the actual behaviour changes. You can refactor the internals freely without touching a single test.

The Testing Trophy

You have probably heard of the testing pyramid: lots of unit tests at the base, fewer integration tests in the middle, and a handful of end-to-end tests at the top. It is a decent starting point, but many teams take it too literally and end up with thousands of unit tests that mock everything and test nothing meaningful.

A more practical approach is the testing trophy, popularised by Kent C. Dodds ↗. The bulk of your tests should be integration tests that verify real behaviour across multiple modules. Unit tests cover pure functions and complex logic. End-to-end tests cover critical user journeys. Static analysis (TypeScript, ESLint) catches an entire category of bugs before any test runs.

The key insight is that integration tests give you the most confidence per test. A single integration test that hits your API endpoint, processes the request through your middleware, service layer, and database, and verifies the response catches far more bugs than a dozen unit tests with mocked dependencies.

The Testing Trophy E2E Integration Most confidence per test Unit Static Analysis Few, critical user journeys Bulk of your tests live here Pure functions, complex logic TypeScript, ESLint, linters Source: Kent C. Dodds, "The Testing Trophy"

Write Tests That Read Like Specifications

A well-written test serves as documentation. Someone reading your test file should understand what the code does without looking at the implementation. This is something I have found teams consistently underestimate: a good test suite is often the best documentation developers actually read.

Use Descriptive Test Names

// Vague
test('handles error', () => { ... });

// Clear
test('returns 404 when user does not exist', () => { ... });
test('retries failed payment up to 3 times before giving up', () => { ... });

Follow the Arrange, Act, Assert Pattern

Structure every test into three clear sections:

test('applies percentage discount to order total', () => {
  // Arrange
  const order = createOrder([
    { name: 'Laptop', price: 1000 },
    { name: 'Mouse', price: 25 },
  ]);
  const discount = percentageDiscount(10);

  // Act
  const discountedOrder = applyDiscount(order, discount);

  // Assert
  expect(discountedOrder.total).toBe(922.50);
  expect(discountedOrder.discountApplied).toBe('10%');
});

This structure makes tests easy to scan. You immediately see the setup, the action being tested, and the expected outcome.

Avoid These Common Testing Mistakes

Testing Framework Features Instead of Your Code

If your test is verifying that jest.mock works correctly, you are testing the framework, not your application. This happens when you mock so extensively that the test is just checking that your mocks return what you told them to.

Duplicating Production Logic in Tests

// Bad: duplicates the production logic
test('calculates tax correctly', () => {
  const price = 100;
  const taxRate = 0.20;
  const expected = price * taxRate; // This IS the logic you're testing
  expect(calculateTax(price, taxRate)).toBe(expected);
});

// Good: uses a known expected value
test('calculates 20% tax on £100', () => {
  expect(calculateTax(100, 0.20)).toBe(20);
});

When you duplicate the logic, your test will pass even if both the production code and the test have the same bug.

Writing Tests After the Bug Is Fixed Without Reproducing It First

When fixing a bug, write a failing test first that reproduces the exact scenario. Then fix the code and watch the test pass. This confirms your fix actually addresses the problem and prevents the bug from reappearing.

What to Test and What to Skip

CategoryTest?ReasoningExamples
Business logicAlwaysCore of your application, most costly to get wrongCalculations, validation rules, state transitions
Edge casesAlwaysWhere bugs hideEmpty inputs, boundary values, error conditions
Integration pointsAlwaysWhere components meet and misunderstandings cause failuresAPI endpoints, database queries, message handlers
Trivial getters/settersSkipAdds noise without valueMethods that just return a property
Third-party librariesSkipTrust your dependencies; test how you use themLibrary internal behaviour
UI layout detailsSkipBrittle and low valueAsserting specific CSS classes

Always Test

  • Business logic: calculations, validation rules, state transitions. These are the core of your application and the most costly to get wrong.
  • Edge cases: empty inputs, boundary values, error conditions. These are where bugs hide.
  • Integration points: API endpoints, database queries, message handlers. These are where components meet and misunderstandings between them cause failures.

Probably Skip

  • Trivial getters and setters: if a method just returns a property, testing it adds noise without value.
  • Third-party library behaviour: trust that your dependencies work. Test how you use them, not that they function correctly.
  • UI layout details: asserting that a button has a specific CSS class is brittle and low value. Test user interactions and outcomes instead.

Make Your Tests Fast

Slow tests are tests that do not get run. If your test suite takes 20 minutes, developers will push code without running it locally and rely on CI to catch problems. By then, the context has switched and fixing failures is more expensive. Research from the DORA State of DevOps report ↗ consistently shows that fast feedback loops are one of the strongest predictors of software delivery performance.

Use an In-Memory Database for Unit Tests

If your tests need a database, consider using SQLite in-memory mode for fast unit tests and reserving your production database engine (PostgreSQL, MySQL) for integration tests.

Parallelise Test Execution

Most test runners support parallel execution. Jest runs test files in parallel by default. Ensure your tests do not share mutable state, and you can cut your test time significantly. This is directly related to the broader principle of reducing context switching costs; the faster your tests run, the shorter the feedback loop.

Isolate Slow Tests

Mark slow tests (network calls, browser tests) and run them separately. Your fast tests should complete in under 30 seconds for a tight feedback loop.

Testing in Practice: A Pragmatic Approach

Start with the code that scares you most. Which module would you be most nervous to change? That is where tests will provide the most value. Working with teams over the years, I have found that identifying the “scariest” module is a surprisingly effective prioritisation exercise.

For greenfield code, write tests alongside your implementation. You do not have to follow strict TDD, but writing the test within minutes of writing the code keeps both fresh in your mind.

For legacy code, add tests before making changes. Michael Feathers ↗ calls this “characterisation testing”: write tests that describe what the code currently does, then refactor with confidence that you have not changed the behaviour. Tackling technical debt becomes far less daunting when you have characterisation tests in place.

Conclusion

Good tests are not about quantity or coverage percentages. They are about confidence. Every test you write should answer the question: “Will this test tell me if I have broken something important?”

If a test does not give you confidence, it is not helping. Delete it, rewrite it, or replace it with one that does. Your test suite should be an asset that makes you faster, not a liability that slows you down. Pair strong tests with automated code quality tooling and you will have a safety net that lets you ship with confidence every time.

Frequently asked questions

How many tests should I write?

There is no magic number. Focus on testing behaviour that matters to your users and business logic that would be costly to get wrong. A small number of well-targeted tests is more valuable than hundreds of shallow ones that test implementation details.

Should I aim for 100% code coverage?

No. Code coverage measures which lines were executed during tests, not whether your tests are meaningful. Chasing 100% coverage leads to brittle tests that verify implementation details rather than behaviour. Aim for high coverage of critical paths and business logic instead.

What is the difference between unit tests and integration tests?

Unit tests verify a single function or module in isolation, usually with dependencies mocked out. Integration tests verify that multiple components work together correctly, often hitting real databases or APIs. Both are valuable, and you need a mix of each.

When should I use mocks?

Use mocks for external dependencies you do not control, such as third-party APIs, email services, or payment processors. Avoid mocking your own code excessively, as it couples your tests to implementation details and makes refactoring harder.

Is TDD worth it?

TDD works well for well-understood problems with clear inputs and outputs, such as business logic and data transformations. It is less useful for exploratory work or UI development where requirements are still being discovered. Try it for a few weeks and see if it improves your workflow.

Enjoyed this article? Get more developer tips straight to your inbox.

Comments

Join the conversation. Share your experience or ask a question below.

0/1000

No comments yet. Be the first to share your thoughts.