Ruby on Rails
Testing Readonly Models
I was working with a readonly model in Rails the other day and ran into an issue whilst testing it. Here's what I ran into and the solution I came up with.
Readonly models are a great way to signal that, well, you should only ever read them, not write them. Maybe you have some external system that connects to the database for writes, or maybe your Rails app connects to some data warehouse for some queries or reports. It's can be useful to have a safeguard to prevent accidental errant writes. It's actually really simple to make a model readonly, you just need to override the readonly? method:
class ReadOnlyPost < ApplicationRecord
def readonly? = true
end
Now any attempt to create/save/update/delete a ReadOnlyPost will raise a friendly ActiveRecord::ReadOnlyRecord exception.
The Problem
You might be able to see where this is going.
For any tests that can avoid saving this readonly model to the database, (i.e. using ReadOnlyPost.new or FactoryBot.build), then we're all good. But often tests need to persist some records to the test database. And if I try to create a ReadOnlyPost, I'm going to have a bad time.
RSpec.describe ReadOnlyPost, type: :model do
let(:post) { ReadOnlyPost.create(title: "Title", body: "body") }
it "can create a post" do
expect(post).to be_a(ReadOnlyPost)
end
end
% bundle exec rspec
F
Failures:
1) ReadOnlyPost can create a post
Failure/Error: let(:post) { ReadOnlyPost.create(title: "Title", body: "body") }
ActiveRecord::ReadOnlyRecord:
ReadOnlyPost is marked as readonly
# ./spec/models/read_only_post_spec.rb:4:in `block (2 levels) in <top (required)>'
# ./spec/models/read_only_post_spec.rb:7:in `block (2 levels) in <top (required)>'
This makes sense, the readonly property of the model doesn't go away in test - creating a record is creating a record is writing to the database, so it will fail.
The Solution
What we want to do is override this constraint to temporarily allow us to persist data. Ideally, we do this very narrowly to not impact the system under test. If the model is writable during test execution, then the behavior in the test is different from production behavior and that can lead to bugs in production that we cannot capture in a test. So ideally we can override this during test setup only and revert back to readonly by the time the test executes.
Given those constraints, a nice API would look like this:
RSpec.describe ReadOnlyPost, type: :model do
# In a let
let(:post) { with_writable(ReadOnlyPost) { ReadOnlyPost.create(title: "Title", body: "body") } }
# Or in a before block
before do
with_writable(ReadOnlyPost) do
ReadOnlyPost.create(title: "Title", body: "body")
end
end
it "can create a post" do
# Even inline in a test
with_writable(ReadOnlyPost) { ReadOnlyPost.create(title: "Title", body: "body") }
expect(post).to be_a(ReadOnlyPost)
end
end
The model would be writable only inside the block of with_writable, and outside the normal readonly value would hold true. Specifying the model that we want to override as the argument allows us to be intentional about which model we're overriding rather than blanket making everything writable (which could lead to some unexpected consequences).
Ruby's malleable nature allows us to make this override a reality with relative ease. We can open up the class to modification with class_eval and override readonly?, execute the block, and then revert readonly? back to it's original value.
# spec/support/readonly_helper.rb
def with_writable(klass)
klass.class_eval do
alias_method :_original_readonly?, :readonly?
define_method(:readonly?) { false }
end
yield
ensure
klass.class_eval do
alias_method :readonly?, :_original_readonly?
remove_method :_original_readonly?
end
end
With this definition living in spec/support, it can be used in any test, but makes it inconvenient to accidentally use in app code (nothing is impossible in ruby - you could still require it in app code, but doing so would be a code smell and hopefully convince you that's not the way to go).
You could extend this helper to accept an array of classes and override readonly? for each if you had multiple readonly models for which you needed to create data.
Other Options
I think the above solution provides the clearest intent and doesn't change the model's behavior during test execution. There are other ways to implement this but I think the drawbacks make them less desirable.
As an RSpec Example Group Helper
describe "example group", writable: ReadOnlyPost do
# ...
end
You could make a writable helper to add to RSpec example groups or individual tests. This is arguably a cleaner interface than my preferred solution, but I couldn't find a way to make the override apply only during test setup, not during execution. That makes this a no go for me, since I lose confidence the test will behave the same way the application does in production - meaning I can't rely on this test.
Write SQL Directly
RSpec.describe ReadOnlyPost, type: :model do
before do
ActiveRecord::Base.connection.execute("INSERT INTO read_only_posts VALUES ('Title', 'Body'")
end
it "can create a post" do
expect(ReadOnlyPost.last).to be_a(ReadOnlyPost)
end
end
You could write SQL statements directly to insert/update records. But this bypasses all of the ORM features of ActiveRecord and really feels like fighting Rails rather than extending it.
Conclusion
So there's a way to test readonly models effectively. Hope it's helpful, and let me know if you use this or a different solution yourself!
Photo by John Cardamone on Unsplash