On Tests.

Just a decorative image for the page.

Intro

When I started writing tests, I mostly focused on pure unit tests and followed what’s commonly known as the testing pyramid. This meant lots of unit tests and mocking, a handful of integration tests, and only a few end-to-end (e2e) tests. It’s a widely accepted approach, and for good reason. But as with everything in software, there are trade-offs.

If you’re unfamiliar with the testing pyramid, I highly recommend reading Martin Fowler’s Practical Test Pyramid for a thorough overview.

Over time, I’ve come to appreciate the nuances between unit tests, integration tests, and e2e tests. Each style serves a different purpose, and understanding their strengths and weaknesses can help you make better decisions about where to invest your testing effort. Because testing is a) expensive ad b) you can’t test everything. And no - 100% test coverage does NOT mean you’ve tested everything.

Unit Tests: Horizontal Testing

Unit testing - sometimes called “horizontal testing” - focuses on verifying a single layer in isolation. The idea is to test one service (or function, or class) while mocking out the rest. This isolation means you’re really just testing your code, not its dependencies.

Pros:

  • Pinpointing Failures: When a unit test fails, you know exactly where the problem is. Cool!
  • Lower Maintenance (usually): Since each test only targets a single component, changes in other parts of your application typically don’t break your tests.

Cons:

  • Refactoring Pain: If you change the interface or behavior of the system, you’ll often need to rewrite a lot of tests. Mocking dependencies can also become a headache as your code evolves.
  • Limited Coverage: Unit tests might miss issues that only appear when components interact.

Integration and End-to-End Tests: Vertical Testing

On the other end of the spectrum, we have integration tests and end-to-end (e2e) tests - what you might think of as “vertical testing.” Instead of isolating components, these tests test multiple layers of your application together, sometimes even the entire stack from the API/controller down to the database.

In the most extreme form, some teams only write e2e tests to verify the overall behavior of their applications (see Yann Simon’s article for more on this).

Pros:

  • Easy Refactoring: You can change how services are wired together or even reorder their calls without needing to update your tests - as long as the external behavior stays the same.
  • Realistic Coverage: These tests are more likely to catch bugs that only appear when components interact, such as serialization issues or misconfigured dependencies.

Cons:

  • Harder Debugging: If a test fails, it can be tough to figure out exactly what went wrong, since so many classes or services are involved.
  • Maintenance Overhead: As your application grows, maintaining e2e or broad-scope integration tests can be time-consuming. Adding new features may cause existing tests to fail in unexpected ways, and it’s not always clear why.

A Pragmatic Middle Ground

Personally, I rarely rely only on full-stack e2e tests. They’re great for catching subtle integration bugs, but when something goes wrong, finding the root cause can be frustrating and time-consuming.

Instead, I favor tests that resemble small and fast integration tests. These tests cover key interactions without mocking everything. For example, I’ll test my REST controllers using real JSON requests to ensure serialization and deserialization work correctly over the wire. This catches issues that pure unit tests would miss, while still keeping tests relatively isolated and fast. On the database level tools like TestContainers make it easy to spin up real database instances for your tests. That allows to test repository code with the real database just as it would in production.

The key is to make sure your tests are fast and cover specific parts of your system for easy debugging.

Conclusion

In the end, there’s no perfect recipe when it comes to testing. The right mix of unit, integration, and end-to-end tests depends on your team, your application, and your appetite for balancing speed, reliability, and coverage. While pure unit tests offer precision and quick feedback, they can miss real-world integration issues. On the flip side, full e2e testing provides confidence in your application’s behavior but can be slow and hard to diagnose when failures occur.

The most effective approach is often a pragmatic blend: leverage unit tests for core business logic, use focused integration tests to check how components communicate, and reserve end-to-end tests for critical user journeys. Tools like TestContainers make it easier than ever to bridge the gap between unit and integration testing.

Further Reading:

Related posts