Don't Stub the System Under Test

Joe Ferris

xUnit Test Patterns defines the System Under Test (SUT) as:

whatever class, object or method we are testing; when we are writing customer tests, the SUT is probably the entire application or at least a major subsystem of it.

The System Under Test helps us focus on what we’re really testing, what Depended-On Components (DOC) interact with it, and how to replace a Depended-On Component with a Test Double (such as a stub, spy, or fake).

However, it can be tempting to also stub parts of the System Under Test. This should be avoided.

Why not stub the System Under Test

The goal of the guideline “Don’t Stub the System Under Test” is to help us use tests as a guide for when to split up a class. If a behavior is so complicated that we felt compelled to stub it out in a test, that behavior is its own concern that should be encapsulated in a class.

Example

Let’s say you’re test-driving the client library for a credit card processing gateway. You start with a basic test case for the CreditCard class:

describe CreditCard, '#create_charge' do
  it 'returns transaction IDs on success' do
    body = { 'transaction_id' => '1234' }.to_json
    stub_request(:post, 'payments.example.com/cards/4111/charges').
      to_return(body: body)
    credit_card = CreditCard.new('4111')

    result = credit_card.create_charge(100)

    expect(result.transaction_id).to eq('1234')
  end
end

The implementation:

class CreditCard
  def initialize(id)
    @id = id
  end

  def create_charge(amount)
    response = Net::HTTP.start('payments.example.com') do |http|
      request = Net::HTTP::Post.new("/cards/#{@id}/charges")
      request.body = { 'amount' => amount }.to_json
      http.request(request)
    end

    data = JSON.parse(response.body)
    Response.new(transaction_id: data['transaction_id'])
  end
end

Now that we can place charges, we need to be able to refund them. The test looks familiar:

describe CreditCard, '#refund_charge' do
  it 'returns transaction IDs on success' do
    body = { 'transaction_id' => '2345' }.to_json
    stub_request(:post, 'payments.example.com/cards/4111/charges/1234/refund').
      to_return(body: body)
    credit_card = CreditCard.new('4111')

    result = credit_card.refund_charge('1234')

    expect(result.transaction_id).to eq('2345')
  end
end

The implementation for #refund_charge looks similar to #create_charge:

def refund_charge(transaction_id)
  response = Net::HTTP.start('payments.example.com') do |http|
    request =
      Net::HTTP::Post.new("/cards/#{@id}/charges/#{transaction_id}/refund")
    http.request(request)
  end

  data = JSON.parse(response.body)
  Response.new(transaction_id: data['transaction_id'])
end

We can’t stand this kind of duplication. So, it’s time to extract a method for the common logic. We start by writing a test for a common, private method:

describe CreditCard, '#create_transaction' do
  it 'performs JSON POST requests' do
    request = { 'request' => 'body' }
    response = { 'transaction_id' => '1234' }
    stub_request(:post, 'payments.example.com/example_path').
      with(body: request.to_json)
      to_return(body: response.to_json)
    credit_card = CreditCard.new('4111')

    result = credit_card.send(:create_transaction, '/example_path', request)

    expect(result.transaction_id).to eq('2345')
  end
end

Next, we implement that method:

private

def create_transaction(path, data = {})
  response = Net::HTTP.start('payments.example.com') do |http|
    post = Net::HTTP::Post.new(path)
    post.body = data.to_json
    http.request(post)
  end

  data = JSON.parse(response.body)
  Response.new(transaction_id: data['transaction_id'])
end

We can expect a call to this method in our tests:

describe CreditCard, '#create_charge' do
  it 'returns transaction IDs on success' do
    expected = stub('expected')
    credit_card.
      stub(:create_transaction).
      with('/cards/4111/charges/1234/refund', amount: 100).
      and_return(expected)
    credit_card = CreditCard.new('4111')

    result = credit_card.create_charge(100)

    expect(result).to eq(expected)
  end
end

Then we can use the method in our class:

def create_charge(amount)
  create_transaction("/cards/#{@id}/charges", amount: amount)
end

We can then create a similar stub for #refund_charge. No more duplication!

However, things have gone a little wrong: we’re not listening to our tests. The need to stub out a private method in our SUT tells us there’s a concern to be encapsulated: formatting and transmitting requests to our gateway server.

Let’s extract that concern.

First, we’ll move our tests for the private method over to a new Client class test:

describe Client, '#post' do
  it 'performs JSON POST requests' do
    request = { 'request' => 'body' }
    response = { 'transaction_id' => '1234' }
    stub_request(:post, 'payments.example.com/example_path').
      with(body: request.to_json)
      to_return(body: response.to_json)
    client = Client.new

    result = client.create_transaction('/example_path', request)

    expect(result.transaction_id).to eq('2345')
  end
end

We can copy the code over from #create_transaction:

class Client
  def post(path, data = {})
    response = Net::HTTP.start('payments.example.com') do |http|
      post = Net::HTTP::Post.new(path)
      post.body = data.to_json
      http.request(post)
    end

    data = JSON.parse(response.body)
    Response.new(transaction_id: data['transaction_id'])
  end
end

Then, we’ll change our test to inject a stubbed dependency:

describe CreditCard, '#create_charge' do
  it 'returns transaction IDs on success' do
    expected = stub('expected')
    client = stub('client')
    client.
      stub(:post).
      with('/cards/4111/charges/1234/refund', amount: 100).
      and_return(expected)
    credit_card = CreditCard.new(client, '4111')

    result = credit_card.create_charge(100)

    expect(result).to eq(expected)
  end
end

Next, we’ll change our class to accept and use that dependency:

class CreditCard
  def initialize(client, id)
    @client = client
    @id = id
  end

  def create_charge(amount)
    @client.post("/cards/#{@id}/charges", amount: amount)
  end

  def refund_charge(transaction_id)
    @client.post("/cards/#{@id}/charges/#{transaction_id}/refund")
  end
end

By avoiding a stub on the SUT, we discovered that we can cleanly split our class into two: one class to handle the high level details of which requests to make for semantic actions like creating charges, and another class which knows how to translate those actions into HTTP requests.

How to avoid stubbing the System Under Test

Each time I’m tempted to stub the SUT, I think about why I didn’t want to set up the required state.

If extracting a helper or factory to set up the state wouldn’t be ugly or cause other issues, I’ll do that and remove the stub.

If the method I’m stubbing has complicated behavior that’s aggravating to retest, I use that as a cue to extract a new class, and then I stub the new dependency.

What’s next

If you found this useful, you might also enjoy: