Accepting Adam's TDD Challenge
Main Thread • 4 min read
Last week at Laracon Online Adam Wathan gave a talk entitled "Lies you've been told about testing". Following the talk, Adam posted a challenge. Amid Adam's post, he makes a single claim and presents a challenge.
The claim
isolated unit testing is incompatible with TDD
The challenge
Write a unit test that tests in isolation from its collaborators and passes for all three implementations.
As a member of an extreme programming team, I have practiced TDD every day for the past 2 years. As such, I'm compelled to accept the challenge. However, I'm going to focus first on the claim.
The premise of Adam's claim is centered around the refactor phase of TDD. Yet, there are other phases of TDD which can make the challenge easier.
TDD is about driving your implementation through tests. So, if we're talking about TDD, it doesn't make sense to go from implementations to your test.
Nonetheless, I want to accept the spirit of the challenge. So, let's follow the full TDD process and see where we end up.
The missing TDD phases
While refactor is the final phase in the TDD process, there are two others.
- The red phase where we write a failing test
- The green phase where we make the test pass
However, a core tenant of the TDD process is that we only write enough code to make the test pass. This promotes doing the simplest thing possible.
In this case, I would go through several red/green cycles testing:
- Class existence
- Method existence
- Method returns value
- Injection of
Redirector
- Method returns response from
Redirector
- Injection of
CommandBus
CommandBus
is calledCommandBus
is called withCommand
built withRequest
data
The resulting implementation might look something like:
1<?php 2 3class ProductsController extends Controller 4{ 5 private $commandBus; 6 private $redirector; 7 8 public function __construct(CommandBus $commandBus, Redirector $redirector) 9 {10 $this->commandBus = $commandBus;11 $this->redirector = $redirector;12 }13 14 public function store(Request $request)15 {16 $command = new AddProductCommand(17 $request->user()->id(),18 $request->name,19 $request->description,20 $request->price21 );22 23 $this->commandBus->dispatch($command);24 25 return $this->redirector->to('/products');26 }27}
Testing Styles
The background for this challenge comes from Adam's frustrations regarding testing styles ("Classist" vs "Mockist"). It's important to point out that TDD does not dictate a testing style. The only style, if any, is minimal amount of code to make the test pass.
In this case, since CommandBus
and Redirector
are under contract, mocking likely requires less test code. We could simply mock the interface and reliably stub and verify collaboration.
While we could also mock the Request
object, it's used in several places and primarily represents a data object. As such, mocking would require more test code than testing with a real Request
object. So, we'll just use a real one.
Refactoring
So, we've reached the final TDD phase - refactoring. Let's see how we're doing.
Much of the variance between Adam's implementations was avoided. For example, by following the full TDD process we would not have required an Auth
dependency. Anything we need we can get from the Request
object.
We would also be able to freely refactor our use of the Request
object since we are testing with a real one.
That leaves one bit of variance to support Adam's claim - refactoring the use of the Redirector
.
There are a few options:
- The change to a named route could be considered a new feature as it requires a test to drive the existence of the named route.
- An agreement on a code standard. For example, always use named routes.
- Abstracting the
Redirector
interface. In this example,Redirector
is part of the Laravel framework. As such, it's not something we own potentially making it harder to test.
It seems this last option is what Adam is tired of hearing. And so, I concede that some coupling between the test and implementation does exist. As such, there will be times a refactor requires a change in the corresponding test.
However, the refactor phase should include refactoring the test. If there is a better, simpler, or more consistent way to do something by all means change it.
In the end, I believe Adam's frustration is not with TDD or unit tests, but the specificity of the test code. I too have never liked when a test matches an implementation line for line. But this should indicate an opportunity to improve either the test or implementation.
Stepping back from the code, I think there are two final take aways:
- Don't throw the baby out with the bath water. Any practice has limitations. Part of mastering a practice is knowing how to work through them. If the refactor phase is hard - determine why it's hard, don't claim TDD is broken.
- There are no silver bullets. You'll receive a lot of advice. Especially in programming. Remember to keep an open mind. Especially when you're learning. Don't get caught up in the testing styles or refactor phase. Just keep doing the simplest thing that works.
Find this interesting? Let's continue the conversation on Twitter.