Think first, test later

2,650 reads

photo by Elizaveta Korabelnikova

I have been doing Test-Driven Development (TDD) for quite a while and it’s great. It gives me a canvas (test file) to draft the design and way to think about how the interaction with other components should look like, what are the desired behaviors, and most of all, it gives me a very fast feedback loop.

When the time comes, you want to refactor. Doing TDD already gives you the safety net which is a bunch of test cases you have written to drive your implementation. When you refactor, according to Martin Fowler’s definition, you do not change the observable behavior.

Refactoring (noun): a change made to the internal structure of software to make it easier to understand and cheaper to modify without changing its observable behavior.

So, when refactoring, do tests need to be changed?

It depends on what level are you saying you are not changing behavior. Who is the observer? Interface changes require altering tests. Here, everything remains the same; just the form of input and output change even if they are isomorphic to previous ones. Does that count as changing behavior?

End-user might not notice but other consumers (your fellow developers and some other parts of the code base) will notice the change of the interface.

Even worse, the tests might know too much about implementation. You neither change any behavior nor interface but you still need to change the test.

TDD is useful for driving design but does not mean it always yields good design. If it goes wrong, the safety net ends up being spider web that prevents you from moving away instead. It turns out that you spend more time on maintaining the test and it discourages people from refactoring because it requires a lot of work.

So how can we stay sane?

Before anything else, think.

Sometimes people take TDD too far, they might say “don’t think, just write a test first” and hope that TDD will eventually guide us to better design. If not, we can refactor later. That doesn’t always work.

The thing is if we don’t think enough before writing the first test, the poor problem modeling will leak into implementation and spread like wildfire.

At some point, you will realize that the thing you just wrote doesn’t fit well in the bigger picture and you will spend more and more time refactoring, or even worse, you don’t have enough time to refactor it because of the so-called delivery pressure. Your teammates start building stuff on top of it and it’s getting even costlier to refactor. Domain understanding is a very important key to successful software development.

So before writing any tests, ask yourself

What problem are we trying to solve?

We are solving problem not just building feature. So we should know what the problem is, and write it down. Once we know what it is, think about how to model it to fit in the context and constraints we have. While I am at work, I disturb business analyst and UX designer quite frequently (and other roles as well but that’s less frequent) to understand the logic and reason behind a user story before diving into technical details. Understanding the problem we are trying to solve is the very first step of good problem modeling.

Photo by Jonathan Simcoe

Pen and paper or whiteboard are awesome tools for problem modeling. Computer tends to be a distraction since it gives us the urge to implement without thinking carefully enough.

So, to me, this is like a micro version of traditional analysis and design. Avoiding big design up front doesn’t mean you should ignore planning and just go code with blind eyes. You can’t be agile if you don’t plan for the design that is easy to change, evolve or even throw it away.

If you have never watch Hammock-driven development by Rich Hickey (the guy who creates Clojure), I would recommend you to watch it. It will give you some interesting ideas about how to think about problems.

Now we understand more about the problem, let’s put our understanding into…

Contract

When we are coding, we need to think about how a function, module or class will be used, how do the inputs and outputs look like, what are the constraints we want to put on. We write down the contract in our code to filter out unnecessary possibility we need to handle.

One way of doing it is by using type.

Types represent set of possible inputs and outputs. With good type system, it gives you type-level feedback by the compiler which is even faster than test. At that time you are focusing more on designing interface, not the underlying implementation. You can use types to eliminate potential bugs, but how far you can go also depends on how sophisticated type system your language of choice is.

In typed-functional setting, people tend to use algebraic data types to model problem. The idea is quite simple and can be encoded in class as well.

But that’s not the only way. The goal is to put some constraints on the input and output and document it. For example, Clojure, which is a dynamically typed language, has clojure.spec which gives you ability to do so. And it comes with test.check which can also generate tests by spec you defined. Or in JavaScript, there is a library called tcomb which allows you to perform type checking at runtime and it’s quite flexible customizing the checks.

Even without all the tools, it’s good to understand how it should look like. At least think about it before you start writing test. Designing data structure to match the nature of the problem reduces the number of tests you need to write and also makes your implementation simpler.

If you would like to know more about this topic, I would recommend Making Impossible States Impossible and Designing with types. They are all using typed-functional language to demonstrate but I think the idea is applicable to OOP as well.

Then write the first test

After we filter out lots of undesirable states by types, it’s time to be more specific. Guiding by examples, TDD is a good tool for driving design of the internal implementation of a function or a class. As I mentioned before, test file is like a canvas for us to paint our design. It will show you how it will be used and it’s the place to define the desired behavior that type can not express. You will sense the smell that if it takes too much effort to setup the test or it’s hard to assert the behavior.

Photo by Aaron Mello

As we go step by step, from very basic cases to more complicated cases, our design grows within the boundary we have defined. We add tests. We add new code. We have all the tests for regression testing and then refactor. One thing to keep in mind along the way is that we should keep the test away from the implementation detail as much as possible or else we will end up in the state in which tests hinder you from refactoring.

One tip that makes me feel productive is listing all the cases even without any assertion (aka. pending tests) to have the milestones for the implementation. By doing this, you will see the clear outline of the function or class behavior.

More cases can be added along the way if we can think of other valuable ones. Keep in mind that more tests don’t always mean good, but always mean more code to maintain. Write tests just enough to be confident and keep them in the state that is maintainable like application code.

It’s all about quality

Good design is the cheapest way to prevent bugs. From careful thoughts to types and then tests provide more abstract picture of the system: how do the components communicate with each other, what are the interfaces, what are the behaviors. They all affect how we implement one small part of the system. Writing test before anything else might lead to wrong focus in the design process.

It’s about iterative and incremental change but at different level and different cycle time. TDD is the shortest one, while the rest are longer.

There are also times that TDD doesn’t work well. If that’s the case, try something else.

TDD is not a means to an end, good quality software is.

Follow me on twitter @ibossptk

Tags