One of the common complaints we hear is that developers hate writing tests.
This is also one of the reasons they won’t try TDD.
Since they aren’t keen on writing tests, they will struggle to succeed at Continuous Delivery or Continuous Deployment.
They’re right, though.
There has been very little joy in their test writing to date, generally because of the context in which they experienced testing.
Old Legacy Code
In some cases, the programmer is faced with code that they didn’t write, and which doesn’t have tests already. This is the Legacy Code situation: they have inherited something of value, and now it is their job to curate and maintain it.
This has some test-resistant qualities:
- It was written without tests, so testing was never a design consideration, so methods tend to be long and convoluted
- To write a test, one must study the code carefully and worm their way through a maze of conditional statements.
- You have to reverse-engineer the requirements from the way the code works now. You don’t know the reasons for some of the conditions it checks.
- The greatest fear: if you change it, you might break it.
Sometimes you are lucky and some pure functions rely only upon their parameters and return a value you can test. Tests for pure functions are fairly trivial to write.
Sometimes you are less lucky and the code directly accesses databases and services, or internally constructs objects that do. Maybe the functions require deep data structures or data with rich variation and history.
Writing tests against such code is a kind of puzzle that requires significant ingenuity and many trial-and-error attempts.
The code may be hard to read, so it is hard to reason about.
Some areas of the code may be difficult to reach with tests.
The first test in a tough legacy situation may take a long time to write, requiring a lot of test doubles and complicated logic, though additional tests for the same code tend to be easier.
Legacy code is quite difficult. Though some people love working through these tough puzzles and situations, most programmers dislike going to such an effort just to prove that the code that already works every day in production can pass the tests. It seems an absurd labor to go through just to raise the “code coverage” metrics.
It takes time.
It doesn’t have obvious benefits compared to writing new code.
This is why it seldom happens.
New Legacy Code
One of the dominant, less-disciplined, processes that programmers follow is:
- Write a bunch of code (being very careful, of course)
- Edit it until it will compile.
- Debug it until it gets the right answers.
- Look it over for other errors and correct them.
- Send it off to code review
If they write tests, they usually do so between (4) and (5).
Notice that by performing steps 1-4 first, they have placed themselves in the legacy code situation.
- The code already works.
- It doesn’t have tests.
- If you change it, you might break it.
- The way it is written now, we’ll have to make complicated setup and mocks.
- It hardly seems worth the effort just to raise the code coverage numbers.
- There is more work waiting, so we shouldn’t waste time on the current task.
By placing ourselves in a legacy code situation, we have ensured that testing will be a joyless distraction from “real work.”
Very few people can enjoy testing in those circumstances.
So, About TDD…
Why do people say that TDD is fun and useful, then? It seems that writing tests is unpleasant, so why does writing tests first make people happy?
The reason is that it is not a legacy code situation.
A new flow emerges where:
- We make sure all the existing tests run and pass - we “start clean.”
- We look at the rules involved in the change we want to make. Often we will write a list of what tests we think will be useful.
- We write a test (from the list, if we have a list) that expresses a success criteria for a situation in the code.
- We write the code that makes that test pass.
- We run the new test and all the other existing tests.
- We look at the code and decide if we like how it passes the test, or if there is a more straightforward or explicit way to pass the test. We can change the code, and the tests should still pass, as will all other tests to date. If the change causes a test to fail, we can revert it.
- We commit locally. We may also pull from the repository. We may also push to the repository.
What is different about this approach?
- The code has tests that express the requirements directly. We don’t have to reverse-engineer the code.
- The code was written to pass a test, so it has been written to be testable; there isn’t any deep conditional logic to worm our way through.
- If we change the code in a bad way, tests will fail. We don’t have to be afraid to change the code even though it already works. Any design that continues to pass the tests is acceptable. This gives us the freedom to refactor and evolve the design.
- You did not write implementation-specific tests. You couldn’t, because the implementation didn’t exist yet
- Each test you write contributes to safe refactoring in the future; they’re not there just to raise a code coverage metric.
- When you are done, the code provably works.
This is an entirely different developer experience from test-after.
It is not only less troubling, it is something you can do for yourself that will help you maintain coding speed in the future.
Is it trivially easy? No.
It does require you to develop some new habits and aesthetics.
Some 90% of preference is just familiarity, and this way of work is unfamiliar. It’s “weird” and “not how we would usually do it.” You have to approach a new technique as you would approach eating a dish from a new, foreign cuisine.
It goes against your current set of habits. You will have to develop some new mental muscles and techniques.
You’ll likely be a little slower while you learn and may make some mistakes; that’s how skill acquisition is.
But I think you’ll find the Developer Experience of TDD to be vastly superior to the developer experience you have had in the hack-edit-debug-test method you may have experienced before.