TDD and Code Quality

Every time I see an article claiming that TDD improves code quality, a part of me cries. It’s not that I don’t think it can be true. It’s because it’s not necessarily true and those articles rarely bother to provide a satisfying explanation. Here’s my try.

TDD and Better Design

There’s a lot of talking about TDD making your codebase more modular, less coupled and the designs you produce being better in general. I think this might be true, mainly because of two factors: the act of design and characteristics of testable code.

The Act of Design

A lot of people (judging mostly by comments sections on dev portals) tend to skip the design part of software development and jump straight into coding. They try to get a rough implementation working, then refactor it a little and write the tests in the end. While this approach can succeed, it gives a lot of attention to just making it work and little to no attention to representing end-user or domain models that lie at the heart of object orientation.

In my understanding of TDD, before I even sit down to start coding I need to know (at least) the starting point. Especially when going the inside-out way, I need to first think of the objects and their behavior necessary to solve the given problem. Given I’m a homo-sapiens and not a code-o-sapiens, I will get those objects from the end-user or someone’s domain knowledge and thus ensure that the code does not diverge from the root of requirements. This is what I called “the act of design”.

How Could It Go Wrong?

Well, it happens that even when doing TDD people forget the design or do it without due diligence. They just go and test drive the first idea that comes to their mind and then bend it until it works for all test cases. Such approach, although not meant by TDD pioneers, happens and can be actually detrimental to the design produced.

Avoiding the Pitfalls

Pay attention to the design process. Before you start coding, make sure you understand the underlying domain and/or technical concepts. If you find yourself implementing an “awkward” design, don’t be afraid to take a step back and figure out what’s missing. TDD by Example has some good insights into how flexible we should be with deleting the code and trying different approaches during TDD.

Characteristics of Testable Code

When you’re focused on making it work and not thinking about the test, there’s a chance that the code that you’ll produce will be to some degree untestable. In the best case, you’ll notice the flaw and correct it; in the bad case, you’ll hack here and there to make the test pass and in the worst case, you won’t test it at all. When doing TDD you start from a test; from your dream vision of how the test would look like. Again, given you’re a homo-sapiens, you won’t make things worse unless you’re forced to and thus the code produced will be testable.

Testability itself is by most considered a sign of good design. But it often happens to bring you some other design benefits “in the package”. I think the most popular design benefit associated with TDD is lower coupling. In my experience, this might actually be true – code which can be tested without excessive mocking and complex object initialization (most likely) isn’t highly coupled to the rest of the system.

How Could It Go Wrong?

Well, I’ve seen a lot of people that just “mock all the things”, don’t care about the size of the test setup (you can always create a base class or an util) and the testability benefit is gone. Obviously, other characteristics of code written like this won’t be perfect either.

Avoiding the Pitfalls

Treat testability seriously. Do the minimum amount of setup and mocking required to test a functionality and strive not to change it during actual implementation. Follow other good unit testing practices.

TDD and Simpler Solutions

Some say that TDD leads to simpler solutions than other approaches. This is because, in TDD, you should never write more production code than it is necessary to pass the currently failing unit test. This has profound implications. You no longer think about the whole implementation upfront. You let the tests guide you. It might happen that complex algorithms, extra methods, classes or other constructs that you initially thought were necessary weren’t actually needed. If test case by test case you arrived at a solution without those, then you have saved yourself some accidental complexity – good for you!

How Could It Go Wrong?

Supposedly you did no design or lousy design up front and you’re going inside out. Then, there’s no way that TDD saves you from unnecessary classes and such. Another common scenario is to write a single test or even all the author can think off, and then spin off the entire implementation as you imagined it. Simplicity? Doubt it.

Avoiding the Pitfalls

Again, pay attention to the design. Don’t break the TDD rules – have at most one failing test at a time and produce just enough implementation to make it work. At every point of the process, do the simplest thing that could possibly work. Switch between the modes of TDD to adjust the steps you’re making to the complexity of the problem you’re dealing with.

TDD and Better Tests

The last claim that I wanted to discuss is how TDD can make your tests better. Better in this context means mostly that the tests are less coupled to the internals of production code. The reason for this would be that the production code did not exist at the time of writing the test. From my own experience, I can say that this can work very well. At the same time…

How Could It Go Wrong?

Well, in most codebases that I’ve seen it actually went wrong. People are so used to mocking everything around that they often develop their mocky-TDD approach. Like.. you write a failing unit test that the code will hit the mock and implement it. Then they add another one for another mock and so on. In the end, when all mocks are in place and verified, they check that the tested method actually returns the correct result. I would not consider such a test loosely coupled to the production code.

Avoiding the Pitfalls

Take the time to understand the differences between different types of test doubles and between state and interaction testing. Always focus on the outcomes of the functionality and not on the details of its implementation. Consider driving the functionality with a more coarse-grained test if you feel that unit testing would barely test that the compiler works and you’ve set up all mocks correctly.

Conclusion

TDD can make your code better, but there’s a ton of ways to do it wrong (trust me, I listed only a few). Take your time to understand the underlying dynamics and improving your skills. And when it comes to improvement, don’t just read and talk – practice, practice, practice!

About the Author Grzegorz Ziemoński

King of Tidy Java, nerd that thinks about producing perfect software all the time and proud owner of 2 cats.

follow me on: