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.
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.
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.
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.
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.
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.
Basically, as I mentioned earlier, inside-out makes me lose focus on the use case and may lead me to add tests and code 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.