TDD (Test driven development)

Different schools

Posted by Martín Peveri on December 27, 2023 · 8 mins read

TDD (Test driven development)

Different schools

Before starting I would like to clarify that in this post I am going to talk, of course, about TDD, but as the subtitle says about different schools and not the famous red-green-blue flow of TDD and also my examples will be focused following the hexagonal architecture. So let's get started.

Inside-Out

In the Inside-Out approach (also known as Classic TDD or Chicago style TDD), we start by developing unit tests for the innermost units of the system and then build outward. The main goal is to test and build the internal features of objects before working on the outer layers. This is because the inner units are considered more stable and can be built more independently.

For example, from the domain, to the application, and then to the controller.

This school follows an approach in which the internal behavior of a unit (for example, a class) is tested. The idea is to ensure that the internal logic of the unit functions correctly before considering its interaction with other units or external dependencies. This involves focusing on the outcome and internal logic of the unit under test, without considering how it interacts with other units or external dependencies.

When to use Inside-Out

  • When the overall picture is not clear. You only need an entity to start.

Advantages of Inside-Out

  • Helps focus development when the task is not clear.
  • Facilitates Learning and Experimentation: Starting from the inside allows developers to learn more about the internal interactions of the system and experiment with solutions before addressing full integration. This can lead to more informed design decisions.

Disadvantages of Inside-Out

  • Unnecessary tests: Sometimes, you add tests that are not necessary from the user's perspective.
  • Initial Complexity: Starting from the internal units may result in higher initial complexity. This is because internal units are often more interconnected and dependent on other parts of the system. This complexity can make the creation of tests and implementation of functional code more challenging.
  • Lack of Global Vision: Starting with internal units may cause you to lose sight of the overall system picture. You might end up with a set of well-tested components that do not effectively integrate to meet the system requirements.
  • Harder Refactoring: If internal units are too tightly coupled, it can be difficult to refactor without breaking multiple tests. This can lead to less flexible and more error-prone code.
  • Higher Possibility of Rewriting: If decisions made in the internal units turn out to be suboptimal as the system develops, you may need to rewrite substantial parts of the code. Starting "outside-in" can help avoid this by providing a clearer view of the system's needs from the outset.

These are some disadvantages, but for me the most important one is that you are not starting from the user's perspective and you can get lost in the internals and not be focused on the use case.

To illustrate this further, if we were to develop an endpoint to calculate a fee following this school's approach, we would do the following: for instance, we could start from the domain, where we have a domain service FeeCalculator, a value object UserId, and then move inward to the use case CalculationFeeQueryHandler. In this school, each class would have its independent test suite; for example, we would have a FeeCalculator.test.ts, a UserId.test.ts, and a CalculationFeeQueryHandler.test.ts and so on.

Outside-In TDD

In the Outside-In approach (also known as Mockist TDD or London style TDD), we start by developing tests from the outside of the system, usually from the higher layers such as controllers, and then move inward. In this approach, we use simulated objects or mock objects to define the expected behavior of external dependencies of the system and to build the shape and interactions of objects.

For example, from the controller, to the application, and then to the domain.

This school follows an approach where we have larger test units, where child classes are tested indirectly, and infrastructure is mocked. That's why testing behavior rather than implementation details is encouraged here. This last one is very important.

Adding most of your tests is encouraged, as long as it makes sense, in the main class, and if it doesn't fit, specific tests are added in the child classes. The important thing is to add the test where it actually occurs.

The idea of this approach is avoid early abstractions and YAGNI. It's about discovering the domain and letting the domain ask for what it needs. Basically, code what you have to do.

When to use Outside-In

  • When you know the use case and when it's focused on user experience first.
  • For early integration testing, as this approach facilitates the detection of integration issues with other components.

Advantages of Outside-In

  • It forces you to focus on the use case and think about different tests to cover all use cases (oriented towards the user and the use case requirements). Hence, greater clarity.
  • Better understanding of how objects interact with each other (easier to identify issues related to this). Detecting problems among collaborators is easier.
  • Less fragile tests, as you only need to modify tests if the behavior changes; otherwise, if it's a refactor, they shouldn't fail.
  • By testing behavior and not implementation, it helps you see if you are covering the entire domain from the perspective of the use case; this is reflected in the coverage. If you are forgetting to test something in the domain, it will show up in the coverage. Hence, better coverage, more real and more security.
  • More independent code.
  • Improved delivery (less fragility in tests, better delivery).

Disadvantages of Outside-In

  • Starting with higher-level tests can sometimes result in initial complexity, especially in large or complex use cases.

To illustrate this further, following the same example that if we had to develop an endpoint to calculate a fee following this school's approach, we would have a test suite CalculationFeeQueryHandler.test.ts that indirectly tests FeeCalculator and UserId.

Why Outside-In?

Personally, I consider this the best approach, as it achieves what is sought in the testing aspect—safer tests, hence better confidence, broader coverage, improved maintainability, and delivery. This is because my tests only fail if I modify behavior and not implementation.

Furthermore, I focus on the user's perspective/the use case, as I mentioned earlier, which makes my tests less fragile.

Why not Inside-Out?

Basically, as I mentioned earlier, inside-out makes me lose focus on the use case and may lead me to add tests that I don't need or that are meaningless. Moreover, it makes the tests more fragile since I am coupled to the implementation, and at the same time, I have to maintain many more files. All of this harms the delivery.