skip to content

In an RSpec spec, how does asserting with spy and have_received differ from expect(...).to receive, and when does have_received refuse to work?

level: middleimportance: should knowfreq 48%

answer

  1. assert after the action
  2. spy is double.as_null_object
  3. stub it first on other objects
  4. not a spy or not stubbed
  5. mocked instead of stubbed or spied

basics

~10 s

expect(...).to receive is set before the action; have_received checks afterwards that a recorded message arrived. It needs a spy or a message allowed beforehand, otherwise it fails: not a spy or method not stubbed.

solid answer

~40 s

`expect(gateway).to receive(:deliver)` must precede the call, so the assertion sits in the arrange section. The spy style keeps arrange, act, assert in order: create the collaborator with `spy` (a `double` made `as_null_object`) or `instance_spy(SmsGateway)`, run the code, then `expect(gateway).to have_received(:deliver).with(to: "+15550100", body: "Hi").once`. `have_received` accepts the same `with`, count and `ordered` constraints. It only works on messages rspec-mocks recorded: a spy records everything, but a plain double or a real object must first `allow(...).to receive(:deliver)`, otherwise the failure reads `expected to have received deliver, but that object is not a spy or method has not been stubbed.` A message set up with `expect(...).to receive` cannot be re-checked with `have_received` either. And a plain `spy` returns itself for any message, so it can hide calls to methods that do not exist; `instance_spy` rejects those.

code

ruby · 10 lines
ruby
RSpec.describe NotificationService do
  it "texts the user once" do
    gateway = instance_spy(SmsGateway)

    NotificationService.new(gateway:).notify(phone: "+15550100", text: "Hi")

    expect(gateway).to have_received(:deliver)
      .with(to: "+15550100", body: "Hi").once
  end
end

go deeper

for a junior

Remember that have_received is checked after the action and needs a spy or a message allowed beforehand, while expect(...).to receive goes before the action.

for a middle

Explain that spy is a null-object double, name the two failure messages have_received gives, and show that with, counts and ordered work on it too.

for a senior

Point out the null-object risk: a plain spy absorbs renamed methods and returns itself, so prefer instance_spy for real collaborators and configure return values the code relies on.

for a principal

Set a team convention on one assertion style per message, since mixing message expectations and spies on the same collaborator produces confusing failures and uneven specs.

## Two ways to assert a message rspec-mocks (RSpec 3.13) offers two ways to check that the code under test sent a message to a collaborator. - **Message expectation**: `expect(gateway).to receive(:deliver)` is written *before* the action. It registers an expectation that is verified when the example ends. - **Spy check**: `expect(gateway).to have_received(:deliver)` is written *after* the action. It inspects the messages rspec-mocks has already recorded on that object. The second form lets the example read top to bottom as arrange, act, assert, which is why many teams prefer it for the one call an example is about. ## What makes an object spy-able `have_received` can only look at messages rspec-mocks was watching. Three setups qualify: 1. **`spy("sms gateway")`**, which is literally `double("sms gateway").as_null_object`: it accepts and records every message. 2. **`instance_spy(SmsGateway)`**, `class_spy` and `object_spy`: verifying doubles turned into null objects, so they record every message the real class responds to and reject any it does not. 3. **Any double or real object with the message allowed first**: `allow(Mailer).to receive(:deliver)` turns that method into a recorded stub, so `expect(Mailer).to have_received(:deliver)` works afterwards. If none of these applies, `have_received` fails immediately with `expected to have received deliver, but that object is not a spy or method has not been stubbed.` If the method was set up with `expect(...).to receive` instead, it fails with `... but that method has been mocked instead of stubbed or spied.`: pick one style per message. ## The same constraints, after the fact `have_received` supports the constraint vocabulary of message expectations: | Constraint | Example | |---|---| | arguments | `.with(to: "+15550100", body: "Hi")` | | exact counts | `.once`, `.twice`, `.thrice`, `.exactly(3).times` | | bounds | `.at_least(:once)`, `.at_most(2).times` | | order | `.ordered` across several `have_received` checks | One caveat from the rspec-mocks documentation: `have_received(...).with(...)` cannot work properly when the arguments are **mutated after** the spy recorded them, because it compares against the same objects, not snapshots. A hash the service mutates after sending will be compared in its mutated state. ## A worked example In a notification-service spec, several examples share one gateway and each asserts something different: ```ruby RSpec.describe NotificationService do let(:gateway) { instance_spy(SmsGateway, deliver: "msg-42") } let(:service) { NotificationService.new(gateway:) } before { service.notify(phone: "+15550100", text: "Hi") } it "sends one text" do expect(gateway).to have_received(:deliver).once end it "sends the message body unchanged" do expect(gateway).to have_received(:deliver).with(to: "+15550100", body: "Hi") end end ``` With `expect(...).to receive`, each example would have to set its expectation before the shared action, which forces the action out of the `before` hook. The spy lets the action run once in `before` and leaves each example a single, readable assertion. ## The null-object trade-off A plain `spy` returns **itself** for any unconfigured message (with a few conversions special-cased, such as `to_int` returning `0` and `to_str` returning its `to_s`). That keeps setup short, but it has costs: - code that uses the return value (`receipt = gateway.deliver(...)`) gets the spy back and keeps running, possibly down a path no real gateway would allow; - a call to a misspelt or renamed method is silently absorbed and recorded; - nothing fails until you assert. `instance_spy(SmsGateway)` removes the second problem: a message the class does not implement raises, and arguments are checked against the real signature. Configure return values explicitly (`instance_spy(SmsGateway, deliver: "msg-42")`) where the code depends on them. ## Choosing a style - Use `expect(...).to receive` when the expectation must also shape the reply in one line, or when the team convention prefers it. - Use a spy plus `have_received` when you want assertions at the end of the example, or several examples share setup in a `before` hook and each asserts something different. - Do not mix them on the same message in the same example.

  • Why can a plain spy make a broken notification service look healthy?
    A `spy` answers every unconfigured message with itself, so code that calls a renamed or misspelt gateway method, or uses the return value as a receipt, keeps running without error. Only the final `have_received` check can fail, and only for the method you assert. `instance_spy(SmsGateway)` rejects methods the class does not implement and checks arguments against the real signature.
  • Can have_received check the order in which two messages were sent?
    Yes. Chain `ordered` on successive checks, for example `expect(gateway).to have_received(:connect).ordered` followed by `expect(gateway).to have_received(:deliver).ordered`. rspec-mocks compares the order in which the recorded messages arrived and fails if they came the other way round.

A spy is like a hotel front desk that writes every guest request into a ledger. You can check the ledger after checkout, but only at a desk that was told to keep one; a desk that kept no ledger leaves nothing to check.

saying these in an interview costs you the question

  • have_received works on any object without stubbing first
  • a spy raises when it receives a message nobody configured
  • have_received can re-check a message set up with expect(...).to receive
  • have_received cannot take with or count constraints
  • a plain spy rejects methods the real class does not have