Learning to Love Your Unit Tests

Published Mar 26, 2018Last updated Apr 26, 2018
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.

Use let and build

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.

Use build_stubbed for belongs_to associations

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.

Stub has_many associations with build_stubbed_list

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

Discover and read more posts from Tim Kleier
get started