Testing Rails Model Concerns
When I first started using model concerns I struggled with how I could test the functionality of the concern, without having to replicate the tests across all models. Here I show you how I decided to test model concerns. It may be controversial, so buckle up.
What are Model Concerns?
Model concerns are an interesting concept that — if I recall correctly— debuted with Rails 4. They allow a developer to refactor common functionality out from various models and centralize it into a module, called a concern. A non-contrived example of this shows how several of my models can be deactivated at any time.
# app/models/customer.rb class Customer < ApplicationRecord def disable! update!(disabled_at: DateTime.now.utc) end def disabled? !disabled_at.nil? end def enable! update!(disabled_at: nil) end def enabled? disabled_at.nil? end def self.enabled where(disabled_at: nil) end def self.disabled where.not(disabled_at: nil) end end # app/models/realm.rb class Realm < ApplicationRecord def disable! update!(disabled_at: DateTime.now.utc) end def disabled? !disabled_at.nil? end def enable! update!(disabled_at: nil) end def enabled? disabled_at.nil? end def self.enabled where(disabled_at: nil) end def self.disabled where.not(disabled_at: nil) end end
This gets repetitive quickly. We can refactor this using a model concern to DRY it up. While there's no idiomatic naming convention for concerns, I have seen people attach the suffix "able", making things like
Commentable and so on. This works for some words, but I find it is lacking in clarity when I use it, so I use the convention of "CanBe", as in
CanBeDisabled. Another convention I've toyed with is "Allows", as in
AllowsDisabling. Whatever your choice, make sure it is readable. My perspective is that, aside from working, code — first and foremost — is about readability.
# app/models/concerns/can_be_disabled.rb module CanBeDisabled extends ActiveSupport::Concern def disable! update!(disabled_at: DateTime.now.utc) end def disabled? !disabled_at.nil? end def enable! update!(disabled_at: nil) end def enabled? disabled_at.nil? end module ClassMethods def enabled where(disabled_at: nil) end def disabled where.not(disabled_at: nil) end end end # app/models/customer.rb class Customer < ApplicationRecord include CanBeDisabled end # app/models/realm.rb class Realm < ApplicationRecord include CanBeDisabled end
It is important to note that the class methods used in the models must be added to a
module ClassMethods block in the concern, due to the fact that the module is being included into a model. If you don't do this, then code won't see the class methods.
So the concern is simple to understand, scoped to just the context that one needs, and the models are much smaller. Now, how should this refactor be tested?
Difficulties in Testing Model Concerns
The problem with testing model concerns is that, if StackOverflow is to be believed, you need to create a dummy model class and then test it that way. But the problem there is two-fold:
- Dummy model classes will need to fake the database attributes, which are supposed to be tied to actual database columns.
- This isn't testing the models themselves to ensure that they can — in this specific situation — be disabled.
The latter problem is the biggest one I see but the former issue bites you in an insidious way. The former issue is testing something against a real database column, but testing a dummy model class doesn't test against the back-end. I do agree with the idea that we don't test the things we don't own, but the database schema is part of something we own — as opposed to the database itself — so it's something we want to test against. From my perspective, if only the concern is being tested, then each model doesn't have test coverage for the
include CanBeDisabled line of code, and that's an issue because there is no way — in the future, mind you — to know that a model has the correct database column.
How I Test Model Concerns
I know people hate duplicating code, but I like to ensure that each model that can be disabled has tests in it verifying that functionality works. That means I have duplicate tests in each of my models verifying that a model can be disabled. One model might have more thorough tests than the others, but they are all ensuring that the
include CanBeDisabled line is being covered by at least one test. As for the concern itself, I don't have any tests for them and I instead rely on full test coverage of my models. Test the classes, not the modules.
As always, there are pros and cons to everything, and I know some of you may disagree, but that's the beauty of programming.