The Big TDD Misunderstanding

Oliver Wolf
4 min readMar 13, 2022

💡Rumors have it that the term “unit” in “unit test” originally referred to the test itself, not to a unit of the system under test. The idea was that the test could be executed as one unit and does not rely on other tests running upfront (see here and here). Another contradictive perspetive is this one: “The unit to be tested is the entire point of confusion and debate. As Martin pointed out “Object-oriented design tends to treat a class as the unit, procedural or functional approaches might consider a single function as a unit”, when the unit is actually a behavior”

However, when people consider a “unit” as a class/method of the system, two things usually happen. The primary consequence is that developers dogmatically write one “unit test” for every class or method. The second consequence is the isolation of these “units” from other “units” using test doubles (mocks).

Now, you change a little thing in your code base, and the only thing the testing suite tells you is that you will be busy the rest of the day rewriting false positive test cases.

The argument for isolating the units from each other is that it is easier to spot a potential bug. The idea is that the test suite will tell you exactly in which class/method/function the problem is. In my opinion, this does not pay out because of the huge amount of false positive test cases you get and the time you need to fix them. Also, if you know the code base a little you should have an idea where the problem is. If not, this is your chance to get to know the code base a little better.

Unfortunately, the status quo of unit testing is perceived as very rigid, and thinking differently is kind of a taboo topic. But maybe it helps you to loosen up your mindset a little when you know that there are actually two schools when it comes to testing: mockist vs. classicist. Here are my tips on how to write “good” tests, mostly inspired by the classicist style. But also keep in mind - in software engineering - there is no good and bad, there is only: does it satisfy your requirements.

Tip #1: Write the tests from outside in. With this, I mean you should write your tests from a realistic user perspective. To have the best quality assurance and refactor resistance, you would write e2e or integration tests. This can lead to tests that take a long time to execute and increase the feedback loop. You can try to solve this by making the tests independent of each other so they can run in parallel. Originally, the test pyramid forbade a lot of end-to-end and integration tests. Instead, the pyramid says we should write a lot of unit tests, and for most people, a unit is a class. This often leads to an inside-out approach, testing the structure of the system rather than its behavior. Challenge the traditional testing pyramid and think about how much end-to-end integration and unit tests make sense in your context. Also consider more recent alternatives to the test pyramid: “Honeycomb” and “The Testing Trophy”.

Tip #2: Do not isolate code when you test it. If you do so, the tests become fragile and will not help you in case you refactor the software. Only isolate your code from truly external services. Have a look at the port and adapter pattern (aka hexagonal architecture), which is a good starting point for decoupling your “main code” from infrastructure code. If you stub, you stub the infrastructure implementations. Also, consider not stub the infrastructure and using a real database. With tools like Docker, it is not that hard or slow anymore. Also, the more you isolate your “unit” under test, the less meaningful the test coverage report becomes. You just don’t know if your system works as a whole, even though each line is tested. This is especially true for dynamically typed languages.

Tip #3: To do proper TDD you should not change your code without having a red test. This has two benefits: 1) It is the tests of the tests itself. When it is red, you know it works. 2) It makes sure you test all scenarios. This, of course, does not apply when you refactor your code. Sometimes I have trouble wrapping my head around and write the code first and the test later; then I intentionally introduce bugs and see if the test suite turns red. Regarding the second point, you can use the test coverage report to see if you introduced untested code. If you can not reach the untested lines with the public interface of your software, maybe you can just delete those lines. Do not reach for stubs/mocks to achieve 100% test coverage, try to find a scenario of how to run through these branches by using the API from a realistic point of view. Now the coverage report is useful again and having a high coverage actually means something.

Tip #4: TDD says the process of writing tests first will/should drive the design of your software. I never understood this. Maybe this works for other people but it does not work for me. It is Software Architecture 101 — Non-functional requirements (NFR) define your architecture (A good book recommendation on this topic is actually my most favored book “Software Architecture in Practice” by Bass, Clements, and Kazman). NFRs usually do not play a role when writing unit tests.

To sum up, in my opinion, the most important decision to make when you start writing automated tests is to decide what trade-off to make. Do you want a high level of quality assurance, refactor resistance, or a fast feedback loop? Today, it is often possible to make an e2e test or integration test run fast enough.

“The less your tests resemble the way your software is used, the less confidence they can give you.” — https://twitter.com/kentcdodds/status/977018512689455106?s=20

--

--