Writing Specs like Sandi Metz
The best kind of test is the one you don’t have to write. Tests are supposed to save you time and money by making software less brittle and more receptive to change. So why is it that some test suites do the exact opposite? Sandi Metz, who is the author of Practical Object-Oriented Design in Ruby and a bona fide badass, has an answer to this question. It boils down to one idea: write less tests. In 2013 Sandi gave a talk at Rails Conf called The Magic Tricks of Testing. I’ve linked to the video at the bottom. Here is an overview of her testing philosophy.
Whenever we write a test, it is to test a message between objects. Every message has a purpose and a direction. A message’s purpose is either:
- To query: These messages get information. We rely on their return value.
- To command: These messages change an object’s state. We rely on their side effect(s).
In addition to its purpose, every message has a direction. These directions are:
- Outgoing: The object is sending this message to another object.
- Incoming: The object is receiving this message from another object.
- Self-to-self: The object is sending this message, and receiving it.
Combine these purposes and directions any way you like, and you end up with six options. Let’s chart these out:
Filling this chart out is easier than it seems, and you’ll see why in a moment. First let’s deal with incoming query messages. These are usually simple methods like this one on my “Viking” class:
class Viking
attr_reader :health, strength, :name, :weapon
# ...
def damage
strength * weapon.strength
end
end
Sandi gives a very simple rule for testing incoming query messages: make assertions about what they send back. There is no need to assert that the damage method made a call to the weapon object, because that is an implementation detail. We need to be able to change implementation without causing our tests to fail. Here is how we test this method (using rspec):
describe "#damage" do it "returns the amount of damage a viking can do" do
viking = Viking.new(strength: 10)
allow_any_instance_of(Weapon).to receive(:strength).and_return(2)
expect(viking.damage).to eq(20)
end
end
Note that I stubbed out the strength method on the Weapon class, to avoid implicitly testing the Weapon class.
Now let’s take a look at the other kind of incoming message, the incoming command:
class Viking
attr_reader :health, :strength, :name, :weapon # ... def pick_up_weapon(weapon)
@weapon = weapon
end
end
This method is important because of its side effect, so that is what we’ll test:
describe "#pick_up_weapon" do let(:bow){ double("Weapon") } it "picks up the weapon" do
Viking.new.pick_up_weapon(bow)
expect(viking.weapon).to eq(bow)
end
end
Simple enough. I used a different type of mock here, a test double, to once again avoid implicitly testing the Weapon class.
Okay so now our chart looks something like this:
Here’s where things get fun. How do we test methods that begin and end in the same object? Well, let me show you:
By the time you are about to test one of these messages, you have already tested the public incoming or outgoing messages that implement it, so guess what? You’re done!
As you may have guessed, the same goes for outgoing queries. An outgoing query from one object is just an incoming query to another, so it is redundant to test them both. So now we’re here:
And that leaves just one more kind of message, the outgoing command message. Take a look at how these vikings attack one another:
class Viking
attr_reader :health, :strength, :name, :weapon # ... def attack(target)
target.receive_attack(damage)
end
end
You and I know that the target’s health will go down as a result of this attack, and so it seems natural to test that. But remember that this attack method does not know that. The important action of this method is the message it sends, and that is all we need to test. The receive_attack method would be tested separately. Here’s how I tested attack:
describe "#attack" do
let(:target){ double("Target") }
it "damages the target" do
expect(target).to receive(receive_attack)
Viking.new.attack(target)
end
end
With that, our chart is complete:
Wasn’t so bad, right?
Testing can be hard for a lot of reasons, but most people say that the hardest part is knowing what to test. These rules aren’t commandments, but they offer a great reference point for those just getting started in testing. Best of all, they let you recognize opportunities to just skip writing a test all together. So get out there, and don’t test all the things!
Original Rails Conf talk: https://www.youtube.com/watch?v=URSWYvyc42M