TDD on the Shoulders of Giants
I believe that the software industry is short-sighted. We develop useful and productive techniques, but they end up as fads. Some of these fads have more stickiness than others, like Test-Driven Development. TDD is far from universal, but its legacy lives on in the pervasiveness of automated testing.
There is not one way to do TDD. One popular style of TDD is London-style. The best book I’ve found about this style is Growing Object-Oriented Software, Guided by Tests (GOOS) by Nat Pryce and Steve Freeman. In 2022, at RubyConf Mini in Providence, I had the opportunity to speak about what my team and I learned from the book.
In the months leading up to that conference talk I ran a series of book clubs with different groups at Super Good. Each group discussed what Rubyists can learn from the book and how we can apply the technique to our day-to-day work.
Here are a few things we learned.
TDD doesn't solve every problem
I’ve heard many people say that they don’t do “strict/real TDD”. There is no such thing. TDD, like any other technique, complements all your other software development and design techniques. The authors go out of their way to show examples where they lean on other design techniques to solve problems where TDD isn’t helpful. This is normal and expected. There are no silver bullets, after all.
The Golden Rule
Never write new functionality without a failing test.
This is TDD’s golden rule. If you insist, this is one of the lines you might draw to distinguish “real” TDD from other coding activities that include testing, but I’d rather dismantle borders than create new ones.
This rule guides you to move incrementally. This is where you see how TDD relates to Agile; it’s all about feedback. Ideally, you’re writing one test, then massaging the system until that one test passes, before beginning your refactor step.
Realistically, that doesn’t always happen, but the point is that you should be making incremental changes. It’s easy to miss the feedback that TDD offers if you start by changing ten different tests.
Mock interfaces, not classes
We don’t have interface types in Ruby, so when the authors of GOOS tell us that we should be mocking interface types rather than concrete classes, we’re unable to explicitly follow their direction. Fortunately, if we look at why they offer this advice, we can see how we can still learn from the idea.
London-style TDD is all about composition. It’s trying to push you towards designs where individual objects can be composed in different ways to build up new functionality. To force you to keep your objects decoupled from their collaborators, they authors direct you to ensure their type signatures support polymorphism at all times.
In Ruby, as long as it quacks and swims like a duck, we don’t worry about it. We’re not constrained by type systems. It’s fine if we mock concrete classes, but we should consider the knock-on effects.
If we were writing Java and using interface types, naming them would give us pause. We’d consider their shape and type signatures when naming them. Consider this code that might be responsible for shipping a shipment to a customer in an eCommerce system.
class Shipment
# ...
def ship!(email_notifier)
# do shipping stuff
email_notifier.send_notification(shipment_info)
end
end
Nothing looks amiss here, but if were writing Java we’d have to give the email_notifier a type and realize that it’s not significant that it’s an email notifier. This code doesn’t care what kind of notifier it is, only that it accepts shipment_info
. As far as our Shipment
class is concerned, this object is actually just a shipment notifier.
class Shipment
# ...
def ship!(shipment_notifier)
# do shipping stuff
shipment_notifier.send_notification(shipment_info)
end
end
This is an easy change for us to make, but without having to name our interface types we’re never prompted to consider this. To avoid this coupling and embrace composition, we have to take a step back (during the refactor step) and ask ourselves, “what is the interface of this object?”
After considering the shape and purpose of our objects, we can make these kinds of subtle changes that uncover opportunities to use polymorphism to keep our conditional logic in check. Plus, it’ll save you renaming the argument when the store switches to sending the shipment notifications via SMS.
Pick a composition layer
London-style TDD pushes designs where objects arranged and rearranged to build up logic. This composition has to go somewhere, and while you could sprinkle it over all the layers of the system, that can make the system difficult to understand and navigate.
Composition is best done at a high-level in the system. If you’re using a framework, consider a point in the system where you don’t control the composition of the objects. You can’t unit test those layers without using RSpec’s more questionable features, which makes it a great place.
The tests for this kind of compositional code are there to make sure everything is wired up in a valid way and achieves the main broad objective. You don’t test all the details of what the different units do, just that they achieve the main desired effect.
In Rails, this makes controller actions and background jobs an ideal place to do this composition. This is where Rails hands of execution to your application code.
This also answers the question of what to test in your controller/job tests. Just make sure they broadly achieve the desired effect. There is no need to cover every possible case. Your unit tests cover those.
Don't fight the framework
I was pleasantly surprised to find this advice in my rereading of GOOS. The authors explicitly discourage chasing ideological test purity at the expense of violating your framework’s conventions. If your framework expects you to do something in a certain way, diverging from that will just create unnecessary work.
Rails is an opinionated framework that’s built on conventions. Don’t start trying to extract all your HTTP logic out of your controllers in search of some academic design ideal. Consistency is more important than purity.
Arrange, Act, Assert
Sometimes called “Given, When, Then”, this is the gold standard for test structure. It advocates dividing your tests into three parts:
- First, you put all your test setup code.
- Then you make calls to the object under test.
- Finally, you make assertions about the result.
This makes it easy to orient yourself within tests, see what’s being tested, and makes certain test smells clearer. A lot of test setup may mean an object is too hard to use. Too many assertions suggest the object does too many things.
To illustrate, here’s a hypothetical RSpec test for the code we saw earlier.
let(:shipment_notifier) { double "shipment notifier" }
before do
expect(shipment_notifier)
.to receive(:send_notification)
.with(...)
end
it "notifies the customer" do
shipment.ship!(shipment_notifier)
end
# More tests...
I’ve seen too many tests like this. Every one of the “more tests” will fail if the assertion in the before block fails, even though they (presumably) don’t have anything to do with notifications. Technically, we can fix that without converting this test to “Arrange, Act, Assert”.
let(:shipment_notifier) { double "shipment notifier", send_notification: nil }
it "notifies the customer" do
expect(shipment_notifier)
.to receive(:send_notification)
.with(...)
shipment.ship!(shipment_notifier)
end
# More tests...
This addresses the test feedback issue, but our test is still out of order. We can fix that with have_received
.
let(:shipment_notifier) { double "shipment notifier", send_notification: nil }
it "notifies the customer" do
shipment.ship!(shipment_notifier)
expect(shipment_notifier)
.to have_received(:send_notification)
.with(...)
end
# More tests...
Now, the code follows “Arrange, Act, Assert”. Additionally, we’ve addressed a common sticking point with RSpec. I’ve noticed that people new to the framework sometimes struggle with the idea of making assertions about things before they happen. We’ve avoided that completely.
Value objects are great
Value objects are objects represent fungible entities, entities which do not have an identity. We’re used to working with values like numbers, strings, and booleans. Value objects can be thought of as composite values. Consider a 2 dimensional coordinate. Two objects representing the coordinate (3, 7) are totally interchangeable. Testing their equality is a matter of comparing their composite parts and confirming that they are the same.
Ruby’s standard library comes with some value objects (like Vector), but you can create your own to represent values like money, coordinates, scores, colours, or whatever kinds of values your application deals with.
Historically, Structs were the go-to way to implement value objects. If you needed a value object to represent colours, you might do something like this:
Colour = Struct.new(:r, :g, :b)
blue = Colour.new(0, 0, 255)
red = Colour.new(255, 0, 0)
also_red = Colour.new(255, 0, 0)
blue == red
#=> false
red == also_red
#=> true
Just like any other class, Structs can be augmented with helpful instance methods.
Colour = Struct.new(:r, :g, :b) do
def darken(percentage = 0.9)
Colour.new(
r * percentage,
g * percentage,
b * percentage
)
end
end
This works great for most purposes, but there’s one drawback to using Structs: you can reassign their members.
red.b = 255 # Ooops! Red isn't red anymore.
You don’t have to be a functional programmer to recognize that mutating values isn’t a good idea. You wouldn’t mutate the number 3, would you? Fortunately, Ruby now has the Data class, which provides nearly the same features as Structs do, but without writer methods for their members. They also have more flexible constructors.
Colour = Data.define(:r, :g, b)
blue = Colour.new(0, 0, 255)
red = Colour.new(r: 255, g: 0, b: 0)
also_red = Colour.new(255, 0, 0)
blue == red
#=> false
red == also_red
#=> true
red.b = 255
# undefined method `b=' for #<data Colour r=255, g=0, b=0> (NoMethodError)
Watch for opportunities to use value objects; look for clusters of data that is passed around together and consider whether the values make up some larger concept. Capture that concept in a value object and then attach relevant functionality to it.
Value objects can allow you to write most expressive tests by allowing you to write code at a higher level of abstraction, and they’re fast too. There’s no need to mock out your value objects any more than there’s a need to mock out numbers or strings.
Do horrible things with RSpec
I actually like RSpec, but it allows you to write some really terrible, brittle tests. Heavy use of allow_any_instance_of
and stubbing things out on globals is a sign your code is hard to test. If your code is hard to test, it likely means it will be difficult to change and reuse too.
That said, if you’ve got a class that isn’t tested and you want to get it under test before changing it, by all means use those problematic RSpec features. TDD is a design tool. If you’ve already got the code, there’s no reason not to use these features.
Just remember that when it comes to changing that code, those nasty, brittle tests are your feedback. If you can break your dependencies and make the code more testable, you’ve probably made the code significantly more reusable and hopefully easy to change too.
Tests are designed to fail
Green tests aren’t valuable to anyone; tests are only valuable when they fail. When writing tests, make sure to use all the tools at your disposal to make those failure messages useful to the person reading them.
RSpec lets you supply custom error messages if the default ones aren’t self-explanatory.
expect(result.code).to eq("RSP_1.1_SU"),
"The result wasn't successful."
You can’t always write the assertion you want to write. In those cases, explain what you’re trying to do.
You can also use custom matchers to write more expressive tests. In that last example you could write custom matchers for handling your weird response codes.
Familiarize yourself with RSpec’s various matchers. I always point out that there are collection matchers that don’t care about order, whereas equality does.
# This will fail if a, b, and c aren't in that order.
expect(some_query).to eq [a, b, c]
# If your query isn't ordered, don't assert order. Use one of these matchers:
expect(some_query).to contain_exactly(a, b, c)
expect(some_query).to match_array([a, b, c])
Not only are you more precisely asserting the thing you care about, avoiding erroneous failures, but you’re also communicating that intent just a little bit better.
Small tests also communicate intent better. It’s much easier to understand and debug small tests when they fail.
Finally, use descriptive names for your examples and generally focus on readability. Nobody is going to touch the tests until they fail next, so make sure they are as easy to navigate as possible by the next developer who has to make sense of them.
Don’t over-assert
This is a common mistake. Tests aren’t meant to be an exhaustive specification of the functionality of your objects. You don’t need to write a test for everything you can think of. This leads to bloated, hard-to-change test suites.
If an assertion isn’t dependent on test inputs or relevant to the object’s collaborators, then you don’t need to make it.
Conclusion
Whether you’re already doing TDD or just writing tests because your team insists on it, hopefully these techniques allow you to get more value out of your tests. I’ve only scratched the surface, so if you want to learn more about these topics, I highly recommend getting yourself a copy of Growing Object-Oriented Software, Guided by Tests. It’s a fantastic read for anyone at any point in their testing journey.