Software Developer

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 Cipher.

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

In our 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 with and and_return on our test double. With those two methods, the double will provide the specified output, given that input.

allow(Cipher).to receive(:encode).with("hello").and_return("mystery")

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 encode on 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.

Debriefing 🔗

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.