Symmetric Spies: RSpec Test Organization
Special Operation Briefing 🔗
We’re managing a network of secret agents. We need to send sensitive information to our mole who has infiltrated their target.
To do so, our agents will only send a scrambled version of the message. The mole will have to convert that into meaningful text.
class Agent def pass_secret(message, to:) to.accept_secret(Cipher.encode(message)) end end
Our mole can receive this random-seeming string of characters. After deciphering the real meaning, they hold on to it for later.
class Mole def accept_secret(message) messages << Cipher.decode(message) end end
We want to make sure our message-passing scheme works. But we don’t want to go through the laborious process of encoding and decoding these messages. It’s computationally intensive and not the focus of our test. The
Cipher is a collaborator in our class, but it’s not the star of the show. We can test that implementation, and bear those costs, in the unit tests of the
Cipher class. We’re testing the
Agent class today, not
Efficient Espionage Expectations 🔗
We’ll avoid encoding the message, but make sure it happens, by asking RSpec to expect the message.
it "encodes the message" do agent = Agent.new mole = Mole.new expect(Cipher).to receive(:encode).with("hello") agent.pass_secret("hello", to: mole) end
Our test passes. We’ve verified
pass_secret encodes the message provided to it. We can move on; however, I’d prefer a different test construction.
Organization Infiltration 🔗
The domain we’re testing is about obfuscation, but I want as few surprises as possible in my tests. I like to follow a familiar pattern of how to construct my tests, so they read from top to bottom in a consistent way.
Some people refer to this as four-phase tests (setup, exercise, verify, teardown). Others call it the AAA pattern (arrange, act, assert).
Name aside, we want a convention that we can rely on. That makes it easier to scan a test and understand where we are in the process as a reader. The difficulty with our existing test is it breaks that convention. We state what we want to verify, or assert, before exercising, or acting on, the unit under test.
it "encodes the message" do # setup agent = Agent.new mole = Mole.new # verification expect(Cipher).to receive(:encode).with("hello") # exercise agent.pass_secret("hello", to: mole) end
Double Agent 🔗
We can change our test to follow this pattern by using spies. Our
Cipher will be a partial double, and we’ll assert we call the method we’re spying on.
it "encodes the message" do # arrange allow(Cipher).to receive(:encode) agent = Agent.new mole = Mole.new # act agent.pass_secret("hello", to: mole) # assert expect(Cipher).to have_received(:encode).with("hello") end
allow statement, we have no constraints on what we pass to
encode. That’s fine in this case - we tighten up that restriction in the assertion. There we make sure not only that we call the
encode method on
Cipher, but also with the right input (“hello”).
I call this symmetry, or symmetric spies. We start our test by
allowing the message to be received. We end our test by resolving to
expect that the message has been received.
Phase 2 of the Operation 🔗
As of now, we’ve verified that our agent encodes the message. We also want to make sure the mole receives the message. The encoded message. And we want to verify that without actually running our message through the encoder.
Luckily, RSpec can help us out here too. We can use
and_return on our test double. With those two methods, the double will provide the specified output, given that input.
Ciper.encode will only return
"mystery" if we call it with
"hello" as an argument.
Let’s use this to write a test that makes sure the mole gets our encoded message.
it "sends the encoded message to the receiver" do agent = Agent.new mole = Mole.new allow(Cipher).to receive(:encode).with("hello").and_return("mystery") allow(mole).to receive(:accept_secret) agent.pass_secret("hello", to: mole) expect(mole).to have_received(:accept_secret).with("mystery")
Breaking the Code, or Symmetry 🔗
You may have noticed something in that last test that feels like it betrays the intent of symmetry. We are spying (with partial doubles) on two methods. We only expect to receive one. That’s not symmetrical at all!
We already tested that
pass_secret will call
encode. We have existing coverage verifying that. The focus of this test is ensuring the encoded message gets to the mole. We’re using our partial double on
Cipher to avoid a method call that takes a long time. Another use case may be to avoid a side-effect that we don’t want to incur in our test. We’re not using it to verify system behavior.
We are setting up the mole as a partial double to verify behavior. We want to make sure the agent interacts with the mole as expected. Because of that, we verify that we call the right method, with the right input, to the mole. We don’t verify how we encode the message.
There would be nothing wrong with asserting that we call
Cipher. It’s at best duplicative, given our prior test. Yet, it does take away focus on the intent of our test, which is verifying communication with the mole.
The goals of the test drive what expectations we make in that test. The goal of this test isn’t about encoding; it’s about communicating with the mole. Seeking symmetry isn’t having the same number of doubles or spies and expectations. It’s organizing the test in a manner consistent with the four-phase test or AAA pattern.
We may not need the symmetry within an individual test. Keeping it, and our test organization scheme, in mind helps us write readable, predictable tests. The element of surprise may be critical to a spy, agent, or mole in the field. It’s less of an asset in your test suite.