6 rules I follow to get simple and stable tests

It took me several years of trying and failing to figure out what does work when I write tests. Here are 6 rules that I always follow no matter what kind of tests I write.

Follow AAA pattern

Clean unit tests follow the AAA pattern:

  • Arrange: Setup the test scenario
  • Act: Execute the business logic
  • Assert: Make some expectations

This is the holy grail of simple tests.

Single concept per test

Test single concept in each unit test. If you follow the AAA pattern, this should feel natural.

Oftentimes, I see tests that cover a lot of different concepts in a single unit test. That’s a code smell. It’s better to split up such a test into multiple tests - each testing its own thing.

Your tests will be easier to understand, and easier to fix.

// Bad - mixing two concepts
it('should add new item into cart', () => {
  let cart = new Cart()
  cart.add(new Item('Shoes')) // <-- // Testing adding item into cart
  expect(cart.size).toBe(1);
  cart.removeAllItems(); // <-- Testing removing all items from cart
  expect(cart.size).toBe(0);
});

// Better - split it up
it('should add new item into cart', () => {
  let cart = new Cart();
  cart.add(new Item('Shoes');
  expect(cart.size).toBe(1);
});

it('should be possible to remove all items from cart', () => {
  let cart = new Cart([new Item()]);
  cart.removeAllItems();
  expect(cart.size).toBe(0);
});

Avoid logic in your tests

Avoid any if, else, switch, or ternary operator in your test code.

This often happens with a parametrized test where you expect slightly different behavior for different inputs, but also with tests where someone wanted to avoid failure for a flaky test.

In cases like these, it’s always better to split it up and keep it flat and simple.

// Bad
it("should have focus when clicked", () => {
  render(<Input />)
  const input = screen.getByRole("input")

  // this null check is pointless
  if (input !== null) {
    click(input)
    expect(input).toHaveFocus()
  }
})

// Better
it("should have focus when clicked", () => {
  render(<Input />)
  click(screen.getByRole("input"))
  expect(screen.getByRole("input")).toHaveFocus()
})

Optimize for simplicity

Good test is short, flat, simple, and delightful to work with. When you look at a test, you should get the intent immediately.

Do not nest tests. It adds unnecessary complexity. Each nesting level adds an extra cognitive complexity to your code.

Refactor common setup code into a fixture to avoid repetition.

Only public API

If your test is doing something that your user can’t, it's most likely testing implementation details.

This is a code smell. Any change in the underlying implementation will break your test.

Test only API that your user can use.

Use TDD to drive your coverage

10/10 times when I use TDD, my code coverage climbs over 80%.

Start with the happy path - make sure that code works for your primary use cases.

Continue with unhappy paths - Cover the error conditions, unexpected input, or any other edge cases.

You can skip testing trivial one-liners, getters, and setters.

With this, you should hit the 80% coverage mark easily.

That’s it

With these few rules, you can write tests that everybody will understand and it will be a pleasure to work with.

Let me know if you have some other rules that you follow, and why.