How I Test Rails Applications in 2024
25 Mar 2024I’ve advocated for and practiced automated testing as part of the software development process for a long, long, (long) time. During this epoch, my thoughts on testing have evolved somewhat. This post is a quick rundown of how I approach testing vanilla Rails apps these days.
I’m not offering this as the one true way to write tests–it’s just how I do it. I’m providing it for entertainment purposes only!😀
Testing stack
Here are the tools I use when testing. I used these because I like them, I’m familiar with them, and they do the job!
- RSpec
- FactoryBot
- Capybara
- Webmock
- ChatGPT and Copilot
General approach, philosophy, and observations
In general, I’ll start development with an RSpec system test. As I write code to make the test pass, I’ll begin writing other kinds of tests (i.e., request, model) as I feel I need them. For example, a simple accessor or helper doesn’t necessarily require a test.
I’m not dogmatic about whether something is a “unit” or “system” test. I don’t go through contortions to mock out or inject dependencies, especially in legacy code. Another reason I tend not to do a lot of stubbing or mocking is that it makes it challenging for me to refactor quickly.
I push as much data structure setup into FactoryBot as possible. I typically have many well-factored factories and traits. It helps keep tests tidy.
customer = FactoryBot.create(:customer, :with_payment_info)
I’m more permissive of code duplication in tests than in the system under test.
I’m constitutionally unable to not write tests, even in my hobby projects.
System tests
As mentioned before, I typically start developing a new feature with one or more scenarios for that feature or use case.
I use the Given
, When
, Then
use case formulation. I will typically
write the use case out like this in a comment:
scenario "a member cancels their reservation successfully" do
# Given that Sue is looking at her upcoming reservation
# When she clicks the Cancel button
# And she clicks the Confirm Cancellation button
# Then a cancellation message is displayed
# And she receives a cancellation email
end
I’ll then implement the test in code, or more often these days, Copilot will key off that comment and generate a plausible test case that I’ll then hammer into shape. (If you’ve got several scenarios in a single file, it’s scary good at guessing what the next scenario should be.)
Usually, I leave the comments above the code, and most of the code is inline. For example:
sue = FactoryBot.create(:member, :with_cancellable_reservation)
# Given that Sue is looking at her upcoming reservation
sign_in(sue)
click "Reservations"
# ... and so on ...
If the code is getting really long, I’ll create methods like this:
# Given that Sue is looking at her upcoming reservation
given_member_looking_at_reservation(sue)
I was once a fan of the Cucumber and Gherkin, but I’ve found them cumbersome and not worth the cost of having another dependency in the stack. (I’m still a fan of domain-driven design, though!)
I’ll do as much of a test’s setup as practicable by navigating through the API, but I only sometimes do that. (Like in the example I just made up above where I just created a record in the database.)
I use Javascript sparingly in Rails apps, especially greenfield ones. (That’s a whole other topic.) So, system tests are typically the only place I test Javascript!
Speaking of Javascript, system tests are often considered “flaky” (a
word I really dislike), usually due to Javascript and DOM updates.
Because of this, these tests are frequently ignored, disabled, rerun by
habit, or are “fixed” via copious injections of sleep
. Don’t do any of
that. Figure out how to use Capybara correctly.
Request specs
As I implement the system test, I’ll generally start implementing new controller actions. For each, I’ll end up writing several specs. These are the aspects I’m usually testing: Whether or not the user is authenticated and authorized Whether or not the request has valid params and body So, I’m checking HTTP return codes and redirect locations. While I may sometimes assert the presence or absence of data in the response body, I don’t do that very often.
I do very little mocking with these tests. Typically, I mock out external web requests.
Routing specs
I rarely write routing specs, but I will write them when: I’ve added a
constraint to a route and have yet to test the route in any other way.
When I’ve written a complicated direct
route When I’ve written a
catch-all route
Model specs
I end up writing lots of model specs, but I don’t approach them with the mindset that every method needs a spec—although that’s where I end up sometimes.
For any model with validation, I’ll write specs for #valid?
, which is
usually where I start. I’ve seen many people push back on this approach
in the past, saying, “ You are just testing the framework.” I disagree
with that viewpoint. Just because you’re implementing validations with a
DSL doesn’t mean it’s not code. And that’s not even considering when
you’re writing some kind of custom validation.
# Put an example here
RSpec.describe Family, type: :model do
describe "#valid?" do
it "returns 'false' when address is missing" do
family = FactoryBot.build(:family, address: nil)
expect(family).not_to be_valid
expect(family.errors.messages).to have_key(:address_street)
end
# ... and so on ...
it "returns 'true' otherwise" do
family = FactoryBot.create(:family)
expect(family).to be_valid
end
end
end
Likewise, I write specs for #destroy
.
require "rails_helper"
RSpec.describe Employer, type: :model do
describe "#destroy" do
it "destroys the associated Address" do
employer = FactoryBot.create(:employer)
expect {
employer.destroy
}.to change(Address, :count).from(1).to(0)
end
end
end
I’ll write specs for anything that has boundary conditions, usually exhaustively.
I’ll usually write specs for methods that have side effects.
I’ll write specs for any method where I don’t understand how I should implement it. I’ll write one or two tests, get them to pass, write another, and so on. Once I’ve got working and refactored code, I’ll assess whether or not it makes sense to keep all those tests around.
View specs
In my experience, folks go overboard with view specs or ignore them completely. In most cases, both of those approaches are wrong. Even worse, some teams will use system tests as their view specs. That’s a disaster.
I’ll start writing tests for views when I get uncomfortable with their complexity. The mere presence of a conditional isn’t enough to make me write one, but a couple of unique conditionals or a bit of nesting often spur me to take action. (At about the same time, I’ll also start looking for helpers or smaller partials to extract.)
Helper specs
See: View specs!
Mailer specs
I’ll rely heavily on the Rails mail previewer when implementing mails and write just a few sanity checks as specs. The view spec guidelines also apply here.
Job specs
I only write a few of these. Usually, I implement jobs by delegating to a model class that I’ve already tested, so there’s not much going on in the job class itself. Job specs are one place where I’ll often use a mock or stub to ensure that handoff is happening.
I’ll also ensure that I’ve tested any code that’s supposed to submit jobs.
In conclusion
There’s probably more to say, but this seems as good a place as any to wrap up. If you have questions or comments, feel free to hit me up on Bluesky, LinkedIn, or via email!