Testing Model Concern in Rails

If you have been using Concerns in Rails, you might have noticed both controller and model directories have their own concern sub directory. It is helpful to think Concerns are modules meant for both components, as such might contain code specific to each component.

For model, you might want to use ActiveRecord specific features, for example Callbacks. But how do you test a model concern? since it is a just module without a table. Consider the following concern, which can be used to log changes (as JSON array) done to a model every time it is updated:

module Loggable
  extend ActiveSupport::Concern 

  class MissingColumnError < StandardError; end

  included do
    before_update :append_log

    if self.columns.find { |c| c.name == 'changelog' && c.type == :text }.nil?
      raise MissingColumnError.new("#{self.table_name} has no text column 'changelog'")
    end
  end

  def append_log
    logs = JSON.parse(self.changelog || '[]')
    logs << self.changed_attributes
    self.changelog = logs.to_json
    nil
  end
end

If you mock a class to include Loggable, you will immediately get NoMethodError: undefined method `before_update' for Foo:Class.

‘Temping’ gem

To workaround that, you can run a migration programmatically to create a temporary table. Or you can just use temping gem, which will also create the model class. For example:

require 'test_helper'

class LoggableTest < ActiveSupport::TestCase
  self.use_transactional_tests = false

  setup do
    Temping.create :things do
      with_columns do |t|
        t.string :name
        t.text :changelog
      end

      include Loggable
    end
  end

  teardown do
    Temping.teardown
  end

  test 'should raise missing column error' do
    assert_raises Loggable::MissingColumnError do
      Temping.create :wrong_things do
        with_columns { |t| t.string(:name) }

        include Loggable
      end
    end
  end

  test 'should append log on update' do
    thing = Thing.create!(name: 'foo')
    assert_nil thing.changelog
    thing.update!(name: 'new name')
    assert_equal([{ 'name' => 'foo' }], JSON.parse(thing.changelog))
  end

end

Temping.create helpfully accepts a block that will be executed in the model class context, so you can include a concern there. Be sure to include concerns after with_columns to ensure the columns are created first.

I am not sure about other databases, but you need self.use_transactional_tests = false for MySQL. See similar issue on GitHub.