Test mock vs. stub vs. spy

Test mocks, stubs, and spies can be used in unit tests, with tradeoffs. Consider this system under test:

def fetch(url)
  HTTP.get(url).body
rescue => err
  ErrorLogger.log(err, { url: url })
  nil
end

The method interacts with two collaborators: HTTP for HTTP requests and ErrorLogger for logging errors.

Mock

A unit test using a mock:

describe "fetch" do
  it "logs errors" do
    allow(HTTP).to receive(:get).and_raise("error")
    expect(ErrorLogger).to receive(:log) # mock

    result = fetch("https://example.com")

    expect(result).to be_nil
  end
end

Nothing is duplicated but the phases of the test are "setup, verify, exercise, verify", which can be confusing to read.

Stub

A unit test using a stub:

describe "fetch" do
  it "logs errors" do
    allow(HTTP).to receive(:get).and_raise("error")
    allow(ErrorLogger).to receive(:log) # stub

    result = fetch("https://example.com")

    expect(ErrorLogger).to have_received(:log)
    expect(result).to be_nil
  end
end

allow stubs the collaborator. expect asserts an expectation was met on the stub.

This style keeps a Four-Phase Test order, emphasized by newlines separating setup, exercise, and verification phases.

Spy

A unit test using a spy:

describe "fetch" do
  it "logs errors" do
    allow(HTTP).to receive(:get).and_raise("error")
    spy(ErrorLogger) # spy

    result = fetch("https://example.com")

    expect(ErrorLogger).to have_received(:log)
    expect(result).to be_nil
  end
end

The spy is more informative about the test double's purpose and it removes duplicated references to log.