Let’s consider two software development workflows. Their outcomes differ in many ways, but in this article I just want to focus on code quality.
Workflow #1: Straight to the implementation
- Look around the codebase for existing places that will need to be changed.
- Change and add code until the new functionality works, doing manual testing.
- Think about refactoring, but decide against it, because it’s working, and you’ll have to manually test everything again if you make changes.
- Write some tests to cover the new code.
- Refactor some of the code, without breaking the tests you just wrote.
This workflow does not involve design. Changes are made haphazardly until the implementation works. The resulting design is an afterthought — just a combination of all the changes.
Next, the implementation is locked in place by tests. The tests aren’t written very well, because they were also an afterthought. They are written in the same way as the implementation: haphazardly adding and changing code until they pass.
This might limit your ability to refactor. The tests are often tightly coupled to implementation details. The implementation is right there in front of you, so when you write the tests, of course they’re going to match up. You don’t want to touch the tests because they are supposed to remain green and unchanged during refactoring. And besides, why write tests if you’re immediately going to break them? That seems like a waste of time.
This is a common test-last workflow. The implementation is the first code that worked (plus some of the code that didn’t). The design is accidental. The tests are sloppy, and inhibit refactoring.
The repeated application of this workflow will turn a codebase into spaghetti.
Workflow #2: Starting with tests
- Open or create a test file
- Imagine what a good quality implementation might look like
- Write a test for this imaginary implementation
- Iterate on the design, adapting it to best fit your requirements
- Run the test, fix the error, and repeat until the test passes
- Refactor the implementation until it meets your desired standard of quality
- Repeat until all of the functionality is implemented
Design is the first thing that happens in this workflow. You dream up the perfect code, and then write it down. Then you improve the code by experimenting, trying to find better approaches.
The test reads nicely, because it only contains dream code. You’re not concerned whether it will pass, because you know that it won’t. You’re only concerned with the quality of the code.
Next, the dream is methodically turned into reality. The test failure message tells you exactly what you need to implement next. It doesn’t matter how sloppy the implementation is at this point, because it can be cleaned up after the test passes. This gives you laser focus.
Once the test passes, refactoring is easy. The test has locked in the dream interface, not the implementation, because the implementation didn’t even exist when the test was written. This gives you the freedom to change any of the implementation details without breaking the test.
This is a test-first workflow. The tests read well, you have a quality design that fits your requirements, and the implementation is as clean as you want it to be.
The repeated application of this workflow helps to maintain a high level of quality in a codebase.
I can design code properly without writing the tests first. My code isn’t spaghetti.
This is the “grandma smoked a packet of cigarettes every day and she lived to be 102” argument. I’m sure it’s true, but did smoking cause grandma to live longer? No. Does it prove that smoking is safe? No. Grandma lived to be 102 in spite of smoking, not because of it. Imagine how much money she could have saved, how much better her health could have been, and how long she might have lived if she didn’t smoke.
A workflow with deliberate design and ease of refactoring will produce better code than a workflow with accidental design and difficulties refactoring. You might be able to use your experience and self-discipline to overcome those problems, but if the software development industry has taught us anything, it’s that relying on the self-discipline of software developers is a bad idea. It’s just human nature.
I’ve seen test-first code that was terrible.
Test-first doesn’t guarantee that the design is perfect, or even good. Design is an advanced skill, and newer developers will produce suboptimal designs regardless of the testing workflow. Making them write tests last won’t improve the code — it will make the code worse.
With test-first, at least the developer has a chance to exercise their design skills. Prompting people to think about their requirements before writing the implementation is a great way to improve.
I write tests last and I don’t have any problems refactoring.
Which workflow is more likely to create tests that are coupled to implementation details:
- one where the implementation is written first, and then tests are written afterwards?
- Or one where the implementation doesn’t even exist at the point when the tests are written?
It seems like common sense to me.
Again, it comes down to human nature. You can use experience and self-discipline to write better tests, or you can follow a process that produces better tests naturally.
Automated testing doesn’t suit every situation.
If automated testing doesn’t work well for your situation, then testing first vs last is a moot point. But if you are going to write a test, you might as well write it first.
Sometimes you can’t write the test first because you have no idea what the code will look like.
Often we know roughly what we want, but don’t know how to write it. To put it another way, we have some idea about the interface, but not the implementation. TDD is a process for discovering the implementation, so it works perfectly in this situation.
If you don’t know what you want, then you can’t write a test for it. In this situation, you might want to do a “spike”, which is experimentation without any tests. Make a mess, and do whatever you have to, to learn what kind of interface you need. Once you have an idea about the interface, consider throwing away all the code from the spike, and starting from scratch with a test.
In terms of design quality, test quality, and implementation quality, I think that test-first wins in all three categories. It doesn’t guarantee quality code, it’s just better than test-last, on average.
When you dream up code that doesn’t exist yet, you dream up good code. Imaginary code has unlimited possibilities, from which you can pick the best choices. Nobody chooses to write bad code on purpose, it’s just something that happens accidentally. One way to accidentally write bad code is to jump straight into the implementation, without considering the design, and then lock it in with tests.
When you consider how the effects accumulate over months and years, I think most of us would prefer to accumulate the dreamy test-first code.
Dream code first, before you start implementing it.