blowmage A Mike Moore joint

Adding Minitest Spec in Rails 4

Rails 4 is out, and among its many improvements is upgrading the default testing library from Test::Unit to Minitest. And although Minitest has some surprisingly interesting features, the most discussed addition is its spec DSL. It is designed as a subset of RSpec's DSL, though I'll leave to others any direct comparisons to RSpec. Suffice it to say it its focus is to give you a friendly syntax to generate the test classes, methods, and assertions you'd normally write in plain Ruby.

It'll take a little configuration, and yes, there's a gem for that, but the DIY approach takes surprisingly little elbow grease and will teach you a couple of cool Minitest features. Let's dive in!

Step 1: Setting the Minitest Dependency

Rails 4 sets the dependency on Minitest to "~> 4.2". This means that it will use any Minitest 4.x release that is 4.2 or above. This also means that we can't use the newly released Minitest 5, or the older 4.1. Since we want the spec DSL, we need to set the dependency to "~> 4.7". To do that, let's set the dependency in the Gemfile:

group :test do
  gem "minitest", "~> 4.7"
end

Step 2: Extending MiniTest::Spec::DSL

Minitest 4.7 introduced the MiniTest::Spec::DSL module. To add the spec DSL to our Rails tests, we'll add this to the test/test_helper.rb file.

Let's require the source file just after the rails/test_help require:

ENV["RAILS_ENV"] ||= "test"
require File.expand_path('../../config/environment', __FILE__)
require 'rails/test_help'
require "minitest/spec"

The second change is to extend MiniTest::Spec::DSL in ActiveSupport::TestClass. Luckily for us, there is already a place in the helper for us to make these changes:

class ActiveSupport::TestCase
  ActiveRecord::Migration.check_pending!

  # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
  #
  # Note: You'll currently still have to declare fixtures explicitly in integration tests
  # -- they do not yet inherit this setting
  fixtures :all

  # Add more helper methods to be used by all tests here...
  extend MiniTest::Spec::DSL
end

Cool Minitest trick #1: register_spec_type

The last change is to tell MiniTest::Spec to use ActiveSupport::TestCase when describing an ActiveRecord model. We do this by calling Minitest's registerspectype method.

class ActiveSupport::TestCase
  ActiveRecord::Migration.check_pending!

  # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
  #
  # Note: You'll currently still have to declare fixtures explicitly in integration tests
  # -- they do not yet inherit this setting
  fixtures :all

  # Add more helper methods to be used by all tests here...
  extend MiniTest::Spec::DSL

  register_spec_type self do |desc|
    desc < ActiveRecord::Base if desc.is_a? Class
  end
end

Step 3: Writing Specs

Now that we've configured the spec DSL, let's use it! Let's assume we have the following test in test/models/user_test.rb:

require "test_helper"

class UserTest < ActiveSupport::TestCase

  def valid_params 
    { name: "John Doe", email: "john@example.com" }
  end

  def test_valid
    user = User.new valid_params

    assert user.valid?, "Can't create with valid params: #{user.errors.messages}"
  end

  def test_invalid_without_email
    params = valid_params.clone
    params.delete :email
    user = User.new params

    refute user.valid?, "Can't be valid without email"
    assert user.errors[:email], "Missing error when without email"
  end

end

We can convert this test to the spec DSL one section at a time. Let's start with replacing the class with a describe block:

require "test_helper"

describe User do

  def valid_params 
    { name: "John Doe", email: "john@example.com" }
  end

We can bypass the need to explicitly define a class inheriting from ActiveSupport::TestCase because User inherits from ActiveRecord and we registered the spec type in the previous step.

Next we can replace the test methods with it blocks:

def valid_params 
  { name: "John Doe", email: "john@example.com" }
end

it "is valid with valid params" do
  user = User.new valid_params

  assert user.valid?, "Can't create with valid params: #{user.errors.messages}"
end

it "is invalid without an email" do
  params = valid_params.clone
  params.delete :email
  user = User.new params

  refute user.valid?, "Can't be valid without email"
  assert user.errors[:email], "Missing error when without email"
end

Now let's replace the calls to the assertions with Minitest's expectations. In the first test block, we are passing user.valid? to assert. The spec DSL provides many assertions as expectations, and in this case we can write this test using the must_be expectation. That would look like this:

it "is valid with valid params" do
  user = User.new valid_params

  user.must_be :valid? # Must create with valid params
end

In the next test block, we are refuting that the user is valid. We can use the wont_be expectation for that. And then we are asserting that there are errors on the email attribute. We can use a combination of the must_be expectation and the present? method Rails adds to clean that up a bit:

it "is invalid without an email" do
  params = valid_params.clone
  params.delete :email
  user = User.new params

  user.wont_be :valid? #Must not be valid without email
  user.errors[:email].must_be :present? # Must have error for missing email
end

We can also move some helper methods to let blocks. In the end, here is what the test can look like using the spec DSL:

require "test_helper"

describe User do

  let(:user_params) { { name: "John Doe", email: "john@example.com" } }
  let(:user) { User.new user_params }

  it "is valid with valid params" do
    user.must_be :valid? # Must create with valid params
  end

  it "is invalid without an email" do
    # Delete email before user let is called
    user_params.delete :email

    user.wont_be :valid? # Must not be valid without email
    user.errors[:email].must_be :present? # Must have error for missing email
  end

end

Step 4: Smoothing The Rough Edges

The Minitest spec DSL does not support nested context blocks, but it does support nested describe blocks. Except... ActiveSupport::TestCase also defines a describe method, which stomps on the spec DSL. Oh no!

But wait, this is Ruby! To use nested describe blocks in your tests, we just need to remove the method from ActiveSupport::TestCase. To do this, add a call to remove_method just before MiniTest::Spec::DSL is added in the test helper:

class ActiveSupport::TestCase
  ActiveRecord::Migration.check_pending!

  # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
  #
  # Note: You'll currently still have to declare fixtures explicitly in integration tests
  # -- they do not yet inherit this setting
  fixtures :all

  # Add more helper methods to be used by all tests here...
  class << self
    remove_method :describe
  end

  extend MiniTest::Spec::DSL

  register_spec_type self do |desc|
    desc < ActiveRecord::Base if desc.is_a? Class
  end
end

If you prefer expectations to assertions, we'll need to add expectations for the several assertions that Rails provides, such as assert_response, assert_redirected_to, and assert_difference. We can do all this in the test helper file.

Cool Minitest trick #2: infect_an_assertion

First, create a new module and use the method infectanassertion that Minitest provides:

module MyApp::Expectations
  infect_an_assertion :assert_difference, :must_change
  infect_an_assertion :assert_no_difference, :wont_change
end

Then we can include that module in Object so that those expectations are available everywhere:

class Object
  include MyApp::Expectations
end

Now we can use these expectations in our tests. Yay!

it "is able to be saved when valid" do
  lambda { user.save }.must_change "User.count", +1
end

That's a Wrap!

As you can see, Minitest and Rails go hand in hand. I would even go so far as to say they are BFFs, like I did in this presentation. I hope you give Minitest a shot. Don't let its size fool you. It may be small, but it's a surprisingly powerful, full-featured testing library.

If this seems like too much configuration to manage on your own, that's okay! Feel free to check out the minitest-rails gem, which does all this for you, includes some handy rake tasks, and lets you generate tests using the spec DSL. Either way, hopefully you know a little more about the test framework that comes with Rails 4.