ActiveContainer

Trim the fatty models. Use ActiveContainer.

ActiveContainer doesn’t just keep your models thin, it keeps your tests away from the database. Completely. FactoryGirl’s build and build_stubbed require db access. And even RSpec ActiveModel Mocks can’t avoid attempting to open a connection when you call ANY function on the model.

You can get away from this easily and safely with ActiveContainer.

More to come…

Installation

$ gem install active_container

Gemfile

$ gem 'active_container'

Require

$ require 'active_container'

Usage

Please note that this example is not necessarily for a Rails project. In particular, namespacing may not need to be explicit.

Naming is important! Wrappers for MyModel MUST be named MyModelWrapper.

Example

Let’s wear out the blog post example:

# models/person.rb
class Person < ActiveRecord::Base
  has_many :posts

  validates :first_name, :presence => true
  validates :last_name, :presence => true
  validates :full_name, :presence => true

  validate :custom_validation

  def custom_validation
    # do custom validation
  end
end

# models/post.rb
class Post < ActiveRecord::Base
  has_one :author, :through => :person

  validates :title, :presence => true
  validates :body, :presence => true
end

# models/wrappers/post_wrapper.rb
class PostWrapper < ActiveContainer::Wrapper
end

# models/wrappers/person_wrapper.rb
class PersonWrapper < ActiveContainer::Wrapper
  include Wrappers::Person::FullName
  include Wrappers::Person::PostCount

  delegate :first_name, :last_name

  wrap_delegate :posts
end

# models/wrappers/person/full_name.rb
module Wrappers
  module Person
    module FullName
      def full_name
        "#{first_name} #{last_name}"
      end

      # This logic is overly simplified for example purposes only.
      def full_name=(value)
        names = value.split(' ')

        @record.full_name = value     # Must use `@record` here
        self.first_name = names.first # We can use either `@record` or `self`.
        self.last_name  = names.last  # We can use either `@record` or `self`.
      end
    end
  end
end

# models/wrappers/person/post_count.rb
module Wrappers
  module Person
    module PostCount
      def post_count
        post.count
      end
    end
  end
end

It is important that the helpers are included prior to calling delegate as there are checks to ensure that assignment methods do not already exist.

ActiveContainer::Wrapper automatically passes id to the underlying model, so it does not need to be included in the list sent to delegate.

Commentary

Goals

Reduce code in models (including include statements)

The only code that is left in the model is limited to ActiveRecord/ActiveModel relationships, validations, etc.

These can get out of hand on their own in large projects. This way, nothing but the essentials are in your models.

Increase test speed

One of the primary goals was to get completely away from the database during testing.

This means not even so much as opening a connection and this may not even be possible if you work with anything that has knowledge of the ActiveRecord object you’re working with.

This includes FactoryGirl’s build and build_stubbed. Even mock_model will attempt to open a database connection if you call a method you haven’t mocked out (it wasn’t mocked because we wanted to run the code!).

More explicit model interfaces

ActiveRecord models tell you little about what attributes you actually have. You specify the methods that pass through to the underlying models, so that interface is clearly seen when examining a Wrapper.

Along with this, it is recommended that functionality is grouped in VERY SMALL chunks via mixins for the wrappers.

This allows easier testing of the mixins and tells you more about the interface for the wrapper.

Encapsulation of model lifecycle events.

ActiveRecord callbacks are the devil’s work. ActiveContainer lets you take control again.

It can be used only for that, if you like:

$ Person.new(:first_name => 'John').wrap.save # where save has custom logic

A simple example:

# models/wrappers/person_wrapper.rb
class PersonWrapper < ActiveContainer::Wrapper
  def save
    if !@record.save
      # do something
    end
  end
end

If you want to apply logic to all models, simply create your own BaseWrapper:

# models/wrappers/base_wrapper.rb
class BaseWrapper < ActiveContainer::Wrapper
  def save
    if !@record.save
      # do something
    end
  end
end

# models/wrappers/person_wrapper.rb
class PersonWrapper < BaseWrapper
  def save
    if !@record.save
      # do something
    end
  end
end

Testing

Like Drake, let’s start at the bottom.

#spec/models/wrappers/person/full_name_spec.rb
# It is expected that `spec_helper` will load you files
# without even THINKING about a database connection.
require 'spec_helper'

RSpec.describe Wrappers::Person::FullName do
  subject { PersonWrapper.new person }

  let(:person) do
    OpenStruct.new \
      :first_name => first_name,
      :last_name  => last_name,
  end

  describe '#full_name' do
    shared_examples_for 'a full name' do |first, last, expected|
      context "given a first name of #{first.inspect}" do
        let(:first_name) { first_name }

        context "given a last name of #{last.inspect}" do
          let(:last_name) { last_name }

          it "returns #{expected.inspect}" do
            expect(subject.full_name).to eq expected
          end
        end
      end
    end

    it_behaves_like 'a full name', 'Tom', 'Jones', 'Tom Jones'
    it_behaves_like 'a full name', 'Tiny ', ' Tim ', 'Tiny  Tim '
  end
end

#spec/models/wrappers/person_wrapper_spec.rb
require 'spec/app_helper' # or rails_helper or whatever includes the full app.
RSpec.describe PersonWrapper do
  subject { PersonWrapper.new person }

  let(:person) do
    # this or FactoryGirl.build_stubbed
    Person.new \
      :first_name => first_name,
      :last_name  => last_name,
  end

  describe '#full_name' do
    context 'given first and last name of Dizzy and Gillespie' do
      let(:first_name) { 'Dizzy' }
      let(:last_name)  { 'Gillespie' }

      it 'returns Dizzy Gillespie' do
        expect(subject.full_name).to eq 'Dizzy Gillespie'
      end
    end
  end
end

Notes

The PersonWrapper is a fairly poor example. The important thing here is that #full_name gets called at some point in this group of tests.

It is not important that it be tested explicitly. By reducing the number of tests at this level to be just enough to ensure integration with the individual components (including the model), we can speed up our test suite dramatically.

The biggest concern at this level is not whether the logic is correct, but whether we have changed attribute names without updating the mocked objects we use at the lower level.

This is the tradeoff.