Heading image for post: Testing Readonly Models

Ruby on Rails

Testing Readonly Models

Profile picture of Tony Yunker

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

More posts about Testing Ruby on Rails

  • Adobe logo
  • Barnes and noble logo
  • Aetna logo
  • Vanderbilt university logo
  • Ericsson logo

We're proud to have launched hundreds of products for clients such as LensRentals.com, Engine Yard, Verisign, ParkWhiz, and Regions Bank, to name a few.

Let's talk about your project