In any codebase there are places that are that bit harder to test. If you are testing after the fact, and trying to cover as much code as possible, testing private methods can be a very appealing tool.

TL;DR

✅  Always test through the public interface of an object.

❌  Never test private methods directly.

Let’s take a look at how private methods arrive in our codebase.

Private methods are extracted from the public methods. This happens for one of two reasons.

  1. To make the public method easier to understand, by extracting well named private methods.
  2. To remove duplication across the public methods.

In both these cases private methods are integral to public methods successfully doing their job. All application code that interacts with an object does so through the public interface and our tests should be mimicking that behaviour too.

Therefore tests should only interact with an object’s public interface.

Why write these kinds of tests?

When there is a lot happening in a class, and you have to add one little piece of functionality that is getting complex and you need to get it tested. This is where the little devil on your shoulder whispers, “use send.”

In the example below, the BankAccountPresenter receives a hash of account_data, and the payload method returns a number of fields based on that data for use in the UI. At this point, our task is to calculate the total savings across a number of bank accounts and expose :total_savings in the response from the payload method.

# bank_account_presenter.rb
class BankAccountPresenter
  def initialize(account_data)
    @account_data = account_data
  end

  def payload
    {
      name: @account_data[:customer_name],
      total_savings: calculate_total_savings(@account_data)
      # lots more fields
    }
  end

  private

  def calculate_total_savings(account_data)
    account_data[:accounts].sum do |account|
      account[:savings]
    end
  end
end
# bank_account_presenter_spec.rb
RSpec.describe BankAccountPresenter do
  let(:account_data) { {} }
  subject { BankAccountPresenter.new(account_data) }

  describe "#payload" do
    context ":total_savings" do
      it "contains the correct value in the payload" do
        allow(subject).to receive(:calculate_total_savings).and_return(250)

        expect(subject.payload).to include(total_savings: 250)
      end
    end
  end

  describe "#calculate_total_savings" do
    context "with multiple accounts" do
      let(:account_data) do
        {
          name: "Hamish",
          accounts: [
            {
              name: "Current account",
              savings: 500
            },
            {
              name: "Savings account",
              savings: 1000
            }
          ]
        }
      end

      it "sums the savings in each account" do
        expect(subject.send(:calculate_total_savings, account_data)).to eql(1500)
      end
    end
  end
end

Above you can see our tests cover both

  • the payload returning a stubbed result for the calculate_total_savings.
  • and a specific test on the private calculate_total_savings.

Why not fake private methods?

Here the tests don’t exercise the public and private methods working together which raises two points:

  1. Can the code be relied upon at runtime?
  2. Given the private methods are extracted from the public methods, they should be easy to refactor with the supportive tests already in place, rather than tests that are brittle and need updating every time a private method has its name, parameter list or behaviour updated.

Building on point 2, anytime the source code changes, of course tests will need to change. However, we should be able to test via the public interface, providing a safe, reliable safety net and feedback loop and minimise the impact of a code change that results in an unnecessary proliferation of test failures.

Code Smells

A Code Smell is an indicator that there is something not-quite-right about the design of our code and should seek to refactor to a simpler, easier to test, solution.

With tests being harder to write, it is usually due to surrounding code being too complex.

Testing a private method directly is a Code Smell. Ruby has many powerful language features. Here, the use of send can be an indicator of tests directly calling a private method.

Usually this is done as the only way to test the functionality, which suggests the code is missing an abstraction. In our example this is not the case.

The updated code below demonstrates testing can be achieved with less overhead using the public interface.

There is no:

  • stubbing of the private method,
  • direct testing of the private method.

Leaving one single test that will fail if any future breaking change is made to calculate_total_savings.

# bank_account_presenter_spec.rb
RSpec.describe BankAccountPresenter do
  describe "#payload" do
    subject { BankAccountPresenter.new(account_data) }

    context ":total_savings" do
      let(:account_data) do
        {
          name: "Hamish",
          accounts: [
            {
              name: "Current account",
              savings: 500
            },
            {
              name: "Savings account",
              savings: 1000
            }
          ]
        }
      end
      it "contains the correct value in the payload" do
        expect(subject.payload).to include(total_savings: 1500)
      end
    end
  end
end