How to Test Private Methods in Ruby and Rails?
So you have written an implementation for that new feature in your Rails app, but your team won’t accept your Pull Request until there are unit tests. What’s more your changes are to private methods of a class. In frustration, you shout, “How do I unit test this code?”
The key to unit testing private methods, in any language, is to test them through the public ones.
Through almost two decades of practicing test-driven development, and writing unit tests in many languages at several large companies, including GitHub and Pivotal Labs, I have found you have only two strategies for maintainable unit tests around pre-existing private methods. The two strategies are: “unit test private methods through the public interface”, or “move private methods to a different class and add test coverage”. These strategies can get your feature merged, and make your life easier.
Strategy 1: Unit test private methods through the public interface
Imagine you are working on a finance application, and your Product Manager assigns the following story to your iteration:
As an account holder
I want to NOT see pending transactions applied to my balance
So that I know the current amount of money I have in the account
There currently is an Account
class that allows client code to add, and sum transactions. Each Transaction
has an amount, description, and pending flag. The pending transaction feature requires changes to the private sum
method, where the account balance is calculated.
class Account
def initialize
@transactions = []
end
def add(transaction)
@transactions << transaction
end
def balance
sum(@transactions)
end
private
def sum(transactions)
transactions
.map { |t| t.amount }
.reduce(0) { |acc, amount| acc + amount}
end
end
class Transaction
def initialize(amount, description, pending)
@amount = amount
@description = description
@pending = pending
end
attr_reader :amount, :description, :pending
end
A modification to the private method, filters out the transactions that have the pending
flag set to a truthy
value.
# Account
# ...
def sum(transactions)
transactions
.reject { |t| t.pending } # filter out pending
.map { |t| t.amount }
.reduce(0) { |acc, amount| acc + amount}
end
# ...
Now that the feature work is done, how do you test this change?
How to write unit tests to exercise private function
Since public functions ultimately invoke the private ones. We would expect that a change in those private methods, should either result in a change of state, or a change in behavior of the class. These difference will be observable via the public methods of the class. Said another way, we can test the sum
method via the balance
method that calls it.
In order to test that behavior we need to construct a set of test data that will exercise the private method in the way we want.
Here we do this by passing in only pending transactions to the Account
, then we check that balance
returns 0
.
it 'ignores pending transactions' do
account = Account.new
account.add(Transaction.new(10, "Transaction 1", true))
account.add(Transaction.new(5, "Transaction 2", true))
account.add(Transaction.new(-5, "Transaction 3", true))
expect(account.balance).to eq(0)
end
Strategy 2: Move private methods to a different class and add test coverage
Another story comes into our backlog. This one asks us to calculate the statement balance for an Account
.
As an account holder
I want to see the transactions for this statement
So that I know how much I have spent this month
This change also affects our Account
class. We have added the statement_balance
method, which looks at the current date and sums the transactions for the current month. This code introduces a new filtering condition for the is_statement
flag. If the is_statement
flag is true
, then the transactions for that month are selected, else the pending transactions are ignored.
class Account
# ...
def balance
sum(@transactions)
end
def statement_balance
sum(@transactions, true)
end
private
def sum(all_transactions, is_statement = false)
transactions_to_total = is_statement ?
all_transactions.select do |t|
t.date.month == Date.today.month && t.date.year == Date.today.year
end
: all_transactions.reject {|t| t.pending}
transactions_to_total
.map {|t| t.amount}
.reduce(0) {|acc, amount| acc + amount}
end
end
How to add unit test coverage to an existing class
In order to solve this testing issue, let’s look at testing this function by moving it to another class.
The first thing you should notice, is that the user story mentions the idea of a Statement
. This is a missing domain concept in our system.
Creating a class for the statement concept would provide a more logical place to hang code related to it. With the new class created, we can move the sum
logic to it. The method will be public on that class, allowing us to test it more easily.
The first step in the move method refactoring, is to not misapply the DRY Principle and separate the two unrelated filtering operations from the code. Notice that sum
and sum_statement
are separate, and neither contain if-statements.
class Account
# ...
def balance
sum(@transactions)
end
def statement_balance
sum_statement(@transactions)
end
private
# Removed code
# def sum(all_transactions, is_statement = false)
# transactions_to_total = is_statement ?
# all_transactions.select do |t|
# t.date.month == Date.today.month && t.date.year == Date.today.year
# end
# : all_transactions.reject {|t| t.pending}
# transactions_to_total
# .map {|t| t.amount}
# .reduce(0) {|acc, amount| acc + amount}
# end
# Added. Sums all non_pending transactions
def sum(all_transactions)
all_transactions
.reject {|t| t.pending}
.map {|t| t.amount}
.reduce(0) {|acc, amount| acc + amount}
end
# Added. Sums statements for the current month
def sum_statement(all_transactions)
all_transactions
.select {|t| t.date.month == Date.today.month && t.date.year == Date.today.year}
.map {|t| t.amount}
.reduce(0) {|acc, amount| acc + amount}
end
end
Next let’s move the code to the Statement
class, and parameterize the date. The parameter will make it more testable.
# Account class
def statement_balance
Statement.sum(@transactions, Date.today)
end
# Statement class
class Statement
def self.sum(all_transactions, today)
all_transactions
.select {|t| t.date.month == today.month && t.date.year == today.year}
.map {|t| t.amount}
.reduce(0) {|acc, amount| acc + amount}
end
end
Let’s write a test around Statement#sum
that passes in a variety of dates and verifies the sum only contains the transactions for the parameterized month.
it 'Statement#sum' do
transactions = [
Transaction.new(10, nil, false, last_year),
Transaction.new(10, nil, false, last_month),
Transaction.new(-5, nil, false, Date.today),
Transaction.new(13, nil, false, Date.today),
]
expect(Statement.sum(transactions, Date.today)).to eq(8)
end
The only maintainable ways to unit test private methods
Unit Testing can be a frustrating endeavor as it gives you feedback on the design of your code. Your choice can be to respond to or ignore that feedback.
You can act on the feedback of your code being difficult to test in the following ways:
- Test via the public methods of the class
- Move the method to another class and make it public
It may be tempting to try and find ways to invoke your private method using the reflection APIs of Ruby, but this will make your tests brittle, and hard to read. Whenever possible, it is better to simplify the design of the code, rather than come up with cunning solutions.
If you enjoyed this post, please follow me @soonernotfaster for updates on when new content is posted. Thank you for reading and happy coding!Photo by Micah Williams on Unsplash