TDD as a Practice in Empathy

by Emil Ong — on  , 

cover-image

Key takeaways

  • Writing your tests first is using the code before it’s written.
  • Listen to your experiences while using your own code. You are the first user.
  • Make sure your code and interface deliver clear value to the user.
  • If you don’t want to use your code (i.e. write tests), nobody else will either.
  • The size of test code scales with the versatility of the code, not the size of the implementation.

Definitions and purpose

Test-driven development is often summed up as “write your tests first, then your code.” There’s a lot more subtlety to the practice (as we shall discuss here), but this epithet is often all that’s carried forward. This reductive description thus leads to misunderstanding, code and tests no better (or possibly worse) than before hearing about TDD, and often ultimately discarding the practice.

This post attempts to explain a purpose and practice of empathetic interface design while using TDD. I define empathy as understanding of someone else’s situation that you’ve developed as a consequence of experience with a similar situation. The key point of empathy (at least for the purposes of this post) is experience.

Tests first

When you are writing your tests first, you are using the code you are about to write. You are defining the interface from the perspective of the user. You are gaining experience using your own code. If you are attentive to your own aches and pains during this part of the process, you will know how the users of your code will feel in the future. If you are delighted in the interface, well, at least someone else has a better chance of being delighted.

Classical/pure functional code

Functional units of code (in the sense of functional programming) are much easier to reason about both for users and for purposes of testing than those that require dependency injection or side effects. This statement is not made to say that those approaches are not useful, but you must balance the benefit you get from those patterns with the ease of comprehension of the code.

To give yet another reductive definition, functional programming is when you have a defined input which totally determines the output.

Best case scenario

Functional code

In the context of an interface and testing, you know:

  • what you’re sticking into the code,
  • where it goes,
  • what comes out of the code, and
  • where it comes from.

This is a wonderful situation and it’s the code that most people would be happy to use because the transaction is clear. I give you something I have, you give me something I want.

Overpricing

If your code requires a lot of inputs to do what it needs to do, this is the picture a user gets when they approach it as an interface:

Too many inputs

The setup for this code becomes painful and the transaction just became totally lopsided. As a test writer, you wrote a lot setup to validate a simple assertion at the end. It sucks. As the user of this code, you just did a whole lot of work to get very little out. The output better justify that work.

An embarrassment of riches

The transaction imbalance can swing wide the other way as well by giving a lot of output for not much input.

Too many outputs

In certain contexts, this might be a nice scenario as you’re giving the user “extra value,” but in most cases they probably feel like they’ve just bought a massive variety pack at the warehouse store and are digging through for their favorite flavor. Testing all of these outputs is also a pain in the neck and looks like a checklist of a ship manifest.

Common forms of this pattern include:

  • A function returning an array or map with multiple values
  • A data object or struct with many fields and/or deep nesting

There’s also a danger in losing empathy with your users in situations like these. If your output can be used for so many purposes, it becomes harder to focus on solving any one of the problems. Is it easy for the user of output 5 to get the right input? If not, they may not be well-served by this code while the user of output 2 loves it, but never uses output 5.

Pattern example: Dependency Injection

Let’s look at applying a pattern, namely Dependency Injection (DI), for more complicated/structured code and how we can apply empathy in our application of it. Dependency injection can help the user when they don’t need to know how the code should be configured or are passing in redundant inputs all the time.

Best case scenario

The diagram below shows a situation where we pass in an input and a dependency to the code with a single, direct output.

Dependency injection

This picture is simple but gives us the additional flexibility of configuring the code. We have two knobs and only one output. To use this code, we use the following pattern:

    Setup: Create an object, closure, or other mechanism carrying the code and inject the dependency.

    Use: Provide an input, receive an output. User disregards the dependency after setup.

Your tests look exactly the same. Your user has struck a great bargain - for a little bit of setup, they get an effectively functional piece of code that works in many scenarios.

Dependency overload

DI code can experience both the problems of input and output overload as in the classical functional examples, but they also may suffer from dependency overload.

Dependency injection overload

Here we see that while the cost of setup is quite high, the use of the code remains straightforward. As a consequence, you might say that there’s still a good bargain being struck here, especially if the user employs this code in a high transaction situation. This might be true, but try writing tests for this code. You only have to set it up once, just like the user, and you might then be able to test multiple inputs based on the dependencies. But was it painful to setup? If so, your users might be driven away from your code because their first impression is one of a painful setup, no matter how nice the payoff in the long run.

Test size versus code size

Some complaints about TDD include that it can lead to over-testing and that the tests often end up bigger than the code. I’d venture a hypothesis that this feels wrong to some people because we intuitively expect the tests to be proportional to the size of the implementation, but this isn’t quite right. The size of test code scales with the uses and versatility of the code, not with the size of the implementation itself.

This isn’t to say the intuitive understanding of the size of test code is totally off; code is often as useful as it is long. But this certainly isn’t always the case. Some small functions can be extremely versatile and require a large number of tests to expose their various different workings. Some large code can be pretty limited in its application, but provide great value in those cases. It’s case dependent and you must revert to your empathy in using your own code to find the line between what should be considered too many or too few tests for the unit under consideration. Remember too that you may have the option of refactoring the interface to make the unit’s purpose clearer and/or more concise.

Conclusion: Empathy for the TDD practitioner

I wrote this post out of empathy with you, the TDD practitioner. I know it’s hard to start down a path of TDD and in the initial stages, it requires discipline. But perhaps by understanding the empathetic aspect of TDD, you’ll see it through and become a great practitioner or even find a better way for the world to write great code and interfaces. By building code that you want to use, you’ll begin to build code that others will want to use as well.

cf.

David Heinemeier Hansson’s (a.k.a. DHH) arguments against TDD are often quoted by folks who’d rather not practice TDD. I’d encourage any experienced developers to read his posts TDD is dead. Long live testing. and Test-induced design damage because they contain valuable insights about how he came to form his opinion. Two factors dominate, from my reading:

  1. He felt bullied into practicing TDD and dogmatic developers made him feel like TDD was some shining light for every situation.
  2. He wants well-designed code that isn’t littered with test injection points and TDD was not doing this for him.

Hopefully experienced developers can appreciate both points. It’s ridiculous to bludgeon people with a methodology when you can’t actually show how it provides value. If your reaction to someone not practicing the way you do is to shame them, they aren’t going to practice it for very long when you’re not there looking over their shoulder or yelling at them.

We all want well-designed code. Implementation code that exists purely to support tests is also pointless. The tests you are writing to use your code should look like how you expect your code to be used. If you have to bend over backwards to use your code in tests, so will your users, if you have any.

This is not say that I agree with all his points and certainly not his conclusion, but I’d encourage anyone practicing or considering practicing TDD to read DHH’s experiences and find their own path.

Comments