Learning to Love Your Unit Tests
The best developers I know swear by testing. Many of them also swear at their tests. There seems to be this love-hate relationship that developers have with their test suites — they know testing is critical, yet maintaining tests is to them a most loathsome task. In my focused journey towards simultaneously discovering how to test and how to test well, I was led to a few different tools that, when integrated, resulted in a lightweight but still solid unit testing strategy.
My Context
I was faced with one of the most difficult challenges of my professional career: resurrecting a test suite for an application with a hundred dependency-ridden models and almost as many bulky and logic-heavy controllers, with no previous testing experience, very little knowledge of the application, and no help from other team members.
Talk about being thrown into the deep end. Without a floatie. The test suite had been abandoned six months prior, and no wonder — the tests took ages to run and every single one failed.
I turned to my community and was counseled to first write integration tests to verify functionality. It wasn't long before I realized that my bulky controllers would make for incredibly slow integration tests. I was new to testing and also wasn't eager to repeat history and have my tests abandoned.
I needed to start somewhere simpler and focus on getting test coverage for a mission-critical portion of the application, so I started at the unit level in those tightly coupled models.
As I had to avoid 1) slow tests 2) too many tests and 3) code that was hard to test, I was forced to implement a lean and focused approach to testing — and, by extension, a well-formed refactoring strategy. What I learned, due to my own personal limitations, and that of the code base, proved invaluable.
Testing Strategies
For the purpose of keeping this article brief, I'll focus on what I learned about testing--for further resources on refactoring, see the last item of the "Additional Resources" section below. There were two testing strategies that became vital to my success in creating a testing framework that would actually be used down the road.
- Minimalistic Testing
- Resist the Persist (whenever possible)
We're going to dive into these strategies, but first, a note on our toolset and the goal of testing.
For our purposes here, we'll be using Ruby, Rspec, and FactoryBot. Ruby is a simple enough language that even if you're not well-versed in it, you should be able to catch the overall concepts.
Rspec (a Ruby testing framework) and FactoryBot (a library for generating test data) are two of the more common testing tools used in conjunction with Ruby, and their syntax is pretty straightforward as well. For brevity, we'll just focus on unit tests at the model level.
Testing is about confidence, being able to trust the current state of your application, as well as its ability to handle modification (i.e. new features), gracefully. Without tests, you have no objective measure of its stability.
Speaking of confidence, not having tests bolsters your confidence level around the likelihood of breaking existing features while implementing new ones. The "I'm fixing the code that I broke while fixing the code that I broke while..." cycle is one we'd like to avoid, and testing can be the solution.
However, a clunky test suite helps no one and frustrates many due to the maintainance work involved. That leads us back to our unit testing strategies.
Minimalistic Testing
The best way to decrease the work required in creating and/or maintaining a test suite is simply to write less tests. Can I get an "Amen?"
Am I suggesting that you skimp on key tests that validate your application's behavior? No, I am suggesting that you test only the critical features so that you can focus your efforts on maintaining a high confidence level in the most important aspects of your application.
Sandi Metz, one of the most respected voices in object-oriented design with Ruby, has a minimalistic testing philosophy that covers the major aspects of your Ruby objects without creating coupling/dependency issues within your test suite.
In her talk on the "Magic Tricks of Testing" at Rails Conf, she distills object-oriented programming in Ruby down to the passing of messages between objects, there being two different types of messages, queries, and commands. The former simply requests data to be returned, and the latter performs some operation.
Messages can be sent to an object (Incoming), within an object (Self), and from an object (Outgoing). Given these categories, she identifies how each should be tested.
Query | Command | |
---|---|---|
Incoming | Assert result | Assert direct public side effects |
Self | X | X |
Outgoing | X | Expect to send |
Here we see a purposeful ignoring of messages sent to Self as well as Outgoing query messages. Testing queries and commands sent to Self are redundant, provide no additional safety, and actually binds you to the current implementation. As the receiver of Incoming messages is responsible for asserting values, we only need to expect to send Outgoing command messages.
The advantage of this minimalistic testing strategy, though it may seem to neglect some test coverage, is that it actually aims to distribute testing responsibilities to the appropriate objects, avoiding unnecessary overlapping of tests that then cause issues when adding new features and/or refactoring code.
Resist the Persist (whenever possible)
Extraneous or overreaching tests are costly to maintain and develop around. The cost of persisting data is also costly in tests. For each place you persist an object instead of building/mocking, you're slowing down your test suite. When you have a few tests, this won't be noticeable, but when you've got hundreds, and they have to be run even relatively often, you'll feel the benefit of faster tests.
let
and build
Use With the way RSpec tests work, you'll need to create any variables before each test —hence the common using of the before
block.
RSpec.describe Thing do
before(:each) do
@thing = Thing.new
end
...
end
By definition, @thing
will be instantiated for every single test at that level — in this case, it's every single test on the Thing
object. This is obviously doing extra work (and adding extra time) if only half of your tests use this basic version of @thing
.
A better option as a default for instantiating variables is to use let
. Here we'll also bring in a factory inside the let
block.
RSpec.describe Thing do
let(:thing) { build(:thing) }
...
end
This functions in the same way as before(:each)
, allowing for the variable to be accessible (as thing
instead of @thing
) in each test. However, let
is lazily loaded — it's only loaded for each example that uses it, and we get the flexible implementation of FactoryBot's build
method.
build_stubbed
for belongs_to
associations
Use Oftentimes in your tests, you need to query objects or send a command in a way that doesn't require persistence to the database but does rely upon an association to another object. You want to instantiate associated objects without the heavy lifting of persisting them to the database.
The way to navigate this, for belongs_to
relationships, is to use FactoryBot's build_stubbed
method, which builds out an association that looks like a persisted object relationship, giving you the ability to send a message to the associated object.
RSpec.describe Child, type: :model do
let(:parent) { build_stubbed(:parent, name: 'Mom') }
let(:child) { build_stubbed(:child, parent: parent) }
# assuming we have a 'parent_name' method on the Child object
it 'has a parent named "Mom"' do
expect(child.parent_name).to match('Mom')
end
end
Here, testing the Child
object, we have effectively stubbed out the belongs_to
association to the Parent
without persisting the association.
has_many
associations with build_stubbed_list
Stub For has_many
relationships, you'll need to use FactoryBot's build_stubbed_list
after stubbing your original object.
RSpec.describe Parent, type: :model do
let(:parent) { build_stubbed(:parent) }
it 'should have three children' do
allow(parent).to receive(:children).and_return(FactoryBot.build_stubbed_list(:child, 3))
expect(parent.children.count).to eq(3)
end
end
We've stubbed out a has_many
relationship, without persisting data, and have successfully tested that the parent has three children.
Conclusion
Writing and maintaining a test suite can be a loathsome task. But it doesn't have to be. Using key strategies such as those outlined in this article can help reduce superfluous, redundant, and overreaching tests, as well as speed up your test suite by selectively avoiding the persisting of data to the database.
Hopefully these strategies will lead to a lighter test suite that lightens your mood around testing as well.
Additional Resources
- RSpec documentation
- FactoryBot documentation
- Use Factory Girl's build_stubbed for a Faster Test Suite
- This kind of testing philosophy applied to most code bases will undoubtedly lead you to some substantial refactoring, and for that task I'd recommend two resources: Working Effectively With Legacy Code by Michael C. Feathers and Refactoring: Ruby Edition by Jay Fields, et al.