Resource | Testing ActiveRecord Concerns

Testing ActiveRecord Concerns

Isolate Rails concerns with temporary databases

ActiveRecord classes manage persistence and have a tight relationship with their database tables. This relationship, sometimes, makes testing tricky and even trickier when testing Rails concerns. This article describes how to test a concern in isolation from its ActiveRecord class and its associated database table.

The code examples are written using RSpec and switching to Minitest is possible but requires a fair bit of work.

Photo by [Elif Dilara Bora](https://unsplash.com/@elifborae?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) on [Unsplash](https://unsplash.com/s/photos/venice-carnival?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)

Photo by Elif Dilara Bora on Unsplash

What are concerns?

Concerns are the Rails way to grant a role or an interface to a Ruby class. They provide a nicer syntax than Ruby and aim to clarify confusion around dependencies when used with nested modules. Here is the documentation.

Example: The Reviewable concern

In this example, we’ll look at an ActiveRecord class Post which includes a Reviewable concern. To work properly the concern needs to be included in an ActiveRecord class hooked to a table with a reviewed_at:datetime column.

TL;DR solution

Below is the gist for people looking to see how it’s done. The main idea is to test every concern with a vanilla ApplicationRecord class connected to a temporary database table.

Let’s take a moment to appreciate how explicit this is. The test displays all the information to teach future devs how the Reviewable concern is setup: how to grant the role and the minimal schema required for an ActiveRecord to acquire the role. To understand what Reviewable does, someone can open 'path/to/reviewable/shared/examples' and eliminate all the noise from huge test files by only seeing the tests related to Reviewable behaviour.

Here is a full working example.

Why test concerns in isolation?

Switching to an isolated table to test concerns ensures that concerns are decoupled from the first ActiveRecord class they’ve been introduced into, Post in this example.

Failing to extract and test your concern in another class than the original ActiveRecord class is not reusable. It is also a smell that the role is not fully understood or is the wrong abstraction.

Having the concern tested this way gives you more confidence in reusing Reviewable with any ActiveRecord class that has a reviewed_at:datetime column in its table.

Testing

Concerns and interfaces

In OOP, to successfully test a role, you need to define and test its public interface and Rails concerns are no exception. Because Reviewable is included in the Post, we start by writing the interface tests in the post_spec.rb file.

Concerns and fakes

A role/concern is meant to be shared with other Ruby classes. Currently, Reviewable is only included in the Post model, however, nothing stops us from including it in other classes, especially testing classes. To do so we extract the role tests into shared tests and include those in the post_spec.rb and reviewable_spec.rb files:

Concerns and ActiveRecord

One problem with this test is that while Post and FakeReviewable share the same interface, they do not share the same behaviour. More importantly, this behaviour is tied to the existence of a table column reviewed_at:datetime hooked to the model class. Let's start by adding more tests.

While this causes no problem for Post, our FakeReviewable class is now in trouble. Few methods are now using ActiveRecord methods like #reload or #assign_attributes. Even the Reviewable module is using the #update method. This concern is only to be used with ActiveRecord classes. We could fight against ActiveRecord but a nice workaround is to embrace it and define FakeReviewable as one ActiveRecord class:

Concerns and database integrity

We could stop here and move on to write the scope tests but there is one big problem with this. More often than not, models like Post have further validation rules even in their database table. Let's imagine a scenario like this one:

We now need to give our shared examples a valid reviewable record or the tests won't pass anymore. We update our code like so:

But this will still not work, as FakeReviewable class is attached to the posts database table and it still requires :title, and :author to be populated. It almost feels like we need a dedicated table for FakeReviewable class...

Switching to temporary database tables

In an ideal world, we would need a fake_reviewables table with a single reviewed_at column so that we remove the need for title and author_id to be populated. One way to do this is to create a dedicated fake_reviewables testing table in your schema.rb but that table will also end up in your production database.

While we could argue that this is no big deal and there is nothing wrong with having testing tables in production, I’ll end this article with some code on how to switch to an in-memory SQLite fake_reviewables table.

One way to do this is to include helpers to switch to an in-memory database. Here is the InMemoryDatabaseHelpers module and its usage with FakeReviewable.

And finally the solution described in the TL;DR

Food for thought

What about testing scopes?

This article is quite long already. The same principles would apply to test scopes. If you’re interested in a fully working spec suite, here is the Gist: Testing ActiveRecord Concerns.

Raw SQL queries

Most of SQL syntax is shared across the mainstream databases and thanks to Rails the SQL is also abstracted in a DSL.

This method of testing concerns will work for most of the use cases, however, concerns introducing raw SQL queries can be a problem. Raw SQL queries can use different syntax between MySQL, SQLite or PostgreSQL. For example, PostgreSQL has a specific syntax for window functions like OVER (PARTITION BY x) which I think doesn't exist in SQLite.

In this case, another testing approach would be required for that specific concern. Hopefully, raw SQLs are the exception and not the standard in your Rails codebase.

Tests are fast

Tests run on a SQLite memory database are fast, faster than using MySQL or PostgreSQL to test your application. Here is a quick benchmark to show the differences between PostgreSQL, SQLite file and in-memory databases. The result shows the creation of a thousand posts on a rails console with each adapter.

Cost of switching

We haven’t properly profiled our test suite but our current CI time doesn’t seem to have been impacted. Here is a quick benchmark showing the cost of instantiating an in-memory SQLite database and switching back to PostgreSQL.

Switching locally to an in-memory SQLite database for some tests is not taking too long to instantiate. With those results, we could even consider switching before every test that requires a temporary database without being too significant.

Minitest

I love Minitest but I am not aware of a standard method to run expensive tasks before a group of tests like RSpec does with before(:all). One way would be to use minitest-hooks gem which helps you wrap expensive tasks in a similar fashion to RSpec.

Message sent
Message could not be sent
|