Ruby
Article

Max out Your TDD with Maxitest and Minitest

By Jesse Herrick

Stress Level

The goal behind minitest can be summed up in one word: simple. Minitest gets out of your way and allows you to write expressive and readable tests. These ideals are great, but sometimes we want a sprinkling of magic atop our tests; that’s what maxitest delivers. Maxitest adds features like context blocks and running by line number which make your test suite a little bit nicer. But there’s more to it than that. Let’s dive in!

While I will mention maxitest as our “test suite”, it’s important to keep in mind that minitest is the real power behind our testing. Maxitest is simply a series of add-ons to minitest that make it easier on the eyes.

The Application

Rails is perfect for showing off maxitest in all its glory as we can encounter all forms of testing. For our purposes, we are going to make a news app with user-generated content. Throughout this article, we will use test-driven development to utilize maxitest’s major features and design our application.

Let’s get some rails new action going:

$ rails new news_feed # such a creative title, I know
# ...
$ cd news_feed

Now add maxitest and minitest-spec-rails to your Gemfile.

group :development, :test do
  # ...
  gem 'maxitest'
  gem 'minitest-spec-rails'
end

The minitest-spec-rails gem takes the pain out of integrating Minitest::Spec with Rails by extending Minitest::Spec into Rails’ own test case classes. You can read more on the readme.

First, let’s take a look at our test_helper.rb and add a require for maxitest. If you’re adding this to an existing code base, you just have to change the mini to maxi:

ENV['RAILS_ENV'] ||= 'test'
require File.expand_path('../../config/environment', __FILE__)
require 'maxitest/autorun' # requires maxitest for test running
require 'rails/test_help'

class ActiveSupport::TestCase
  # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
  fixtures :all

  # Add more helper methods to be used by all tests here...
end

Now let’s make something to test. For the basics, just create a user model with two attributes: first_name and last_name:

$ rails g model user first_name:string last_name:string

Using TDD, we’re going to concatenate these two attributes into a full name. So, being the dogmatic test-first people that we are (or at least wish we were), let’s write a test!

A test file should already be generated in test/models/user_test.rb.

# test/models/user_test.rb

class UserTest < ActiveSupport::TestCase
  describe '#name' do # our method
    it 'should concatenate first and last names' do
      user = User.new(first_name: 'Johnny', last_name: 'Appleseed')
      assert_equal user.name, "#{user.first_name} #{user.last_name}"
    end
  end
end

The class UserTest inherits from ActiveSupport::TestCase, which already has Minitest::Spec::DSL extended into it by the minitest-spec-rails gem. We then use a describe to describe what we’re testing. We use an it block to describe one of this method’s behaviors. Inside, the test creates an instance of User and then ensures that #name indeed returns the concatenation of first_name and last_name.

Let’s run the test suite:

$ rake test

test-suite-red-error

Yay! Red. Now let’s actually create the method to get green.

# app/models/user.rb

class User < ActiveRecord::Base
  def name
    "#{first_name} #{last_name}"
  end
end

To run the test this time, we’re going to copy and paste the code snippet maxitest gave to us to run this test again (on a line-by-line basis).

# the -I option tells mtest to include the 'test' directory
mtest test/models/user_test.rb:5 -I test

test-suite-first-green

Great! As you can see, maxitest adds a satisfying green color to successful tests. This feature allows for a true red – green – refactor process. Speaking of which, let’s refactor our code a bit. I’ve decided that string interpolation might not be the best way to write our code (this is hypothetical; I have no preference), so I’m going to use #join on the two strings:

# app/models/user.rb

class User < ActiveRecord::Base
  def name
    [first_name, last_name].join(' ')
  end
end

Let’s run our tests to make sure we didn’t break anything… ($ rake test) We didn’t.

let_all

Now we’re going to refactor our tests a bit and show off maxitest’s let_all method. In order to understand let_all, it’s important to understand what let itself does. Create an accessor method that is assigned to the block you pass it. It’s no different than defining a method inside of a describe block. let_all is an extension of let‘s functionality, caching the response of the passed block. This essentially creates a cached global variable for your block.

Notice in user_test.rb that we setup the it block using a variable assignment. We can refactor this into a let_all block inside of the describe #name block. This is practical because all it blocks inside of the describe #name block can rely on the same setup data and thus run faster.

Our new, refactored user_test.rb:

require 'test_helper'

class UserTest < ActiveSupport::TestCase
  describe '#name' do # our method
    let_all(:user) { User.new(first_name: 'Johnny', last_name: 'Appleseed') }

    it 'should concatenate first and last names' do
      assert_equal user.name, "#{user.first_name} #{user.last_name}"
    end
  end
end

let!

On the other side of the spectrum, maxitest adds the let! method. let! ensures that the block passed to it is always evaluated on each usage of the method.

Implicit Subjects: Never, ever use them.

As an aside from the current code, let’s look at one of maxitest’s more eccentric features: implicit subjects. I highly suggest against using this. Ever. “Implicit subject” is a synonym for a Mystery Guest, which makes code far less maintainable and useful. While an implicit subject might be useful to you right now by saving a few seconds, when you or another developer adds a test-breaking feature into the code base five months from now and you can’t figure out what the test is verifying, you’ll be sorry.

Due to this, I cannot in good conscience demonstrate maxitest’s implicit subject feature. The author himself did not include implicit subjects by default. They are in their very nature extremely hacky.

Context Blocks

One thing I really wish minitest had is context blocks. Functionally, they are no different than describe blocks, but semantically they are. Sometimes it just makes more sense to write part of a test (especially acceptance tests) using context.

Maxitest made my wish come true in one line:

# https://github.com/grosser/maxitest/blob/master/lib/maxitest/autorun.rb#L23

# ...

Minitest::Spec::DSL.send(:alias_method, :context, :describe)

# ...

Context has no more functionality than describe, but it allows for more expressive and simple tests. To demonstrate the use of context blocks, we’re going to write an acceptance test for the listing of posts:

$ mkdir test/acceptance
$ touch test/acceptance/main_feed_test.rb

A great thing about the test-driven development methodology is its design aspect. While TDD is great for preventing regression, it also allows you to design your software before writing it. Acceptance tests are a higher-level test that allow you to design the features of your website before making it. With this in mind, let’s write a test for the main feature of our app: the news feed.

In our application we have users who have the ability to post text. In the body of our app we have a thread of these posts. We’re not going to add in anything complex, just a simple feed feature.

To build this right, we’re going to need a Post model that belongs to a user. Let’s generate that:

$ rails g model Post user_id:integer title:string body:text

While we’re at it, let’s add some validations and associations to our posts and users:

# app/models/post.rb
class Post < ActiveRecord::Base
  validates :title, :body, presence: true

  belongs_to :user
end

# app/models/user.rb
class User < ActiveRecord::Base
  validates :first_name, :last_name, presence: true

  has_many :posts

  # ...
end

Now we need to make some fixture data for our users and posts. Rails automatically generates samples fixtures in test/fixture, but we need to customize them a bit. Here’s some fixture data I made up, feel free to change it to whatever you would like:

# test/fixtures/users.yml
john:
  first_name: John
  last_name: Appleseed

jane:
  first_name: Jane
  last_name: Doe

# test/fixtures/posts.yml

one:
  user: john
  title: A Taste of Latin Filler Text
  body: Aenean pretium consectetur ex, at facilisis nisl tempor a. Vivamus feugiat sagittis felis. Quisque interdum, risus vel interdum mattis, ante neque vestibulum turpis, iaculis imperdiet neque mi at nisl. Vivamus mollis sit amet ligula eget faucibus. Vestibulum luctus risus nisi, et congue ante consectetur a. Quisque mattis accumsan efficitur. Curabitur porta dolor in nisi consectetur rhoncus. In quis libero scelerisque, sagittis neque at, porta tellus. Aenean metus est, tincidunt sed pellentesque id, aliquet a libero. Sed eget sodales nunc. Fusce lacinia congue felis at placerat.

two:
  user: jane
  title: Something Catchy
  body: I have nothing to say!

three:
  user: jane
  title: A Very Short Post
  body: This is witty and short.

These YAML files will generate fixtures for our tests accessible through model_plural_name(:fixture_name). Now that we have fixtures, let’s setup our acceptance testing environment. Rails does not come with a test case class for acceptance testing, so we’ll have to make one ourselves:


# test/test_helper.rb

ENV['RAILS_ENV'] ||= 'test'
require File.expand_path('../../config/environment', __FILE__)
require 'maxitest/autorun'
require 'rails/test_help'
require 'capybara/rails'

class ActiveSupport::TestCase
  fixtures :all
end

class AcceptanceCase < ActiveSupport::TestCase
  include Rails.application.routes.url_helpers
  include Rack::Test::Methods
  include Capybara::DSL

  def app
    Rails.application
  end
end

We add Capybara to the suite here, so make sure to add gem 'capybara' to your Gemfile.

The class we created (AcceptanceCase) gives us fixtures, route helpers, Rack-Test helpers, and Capybara.

Now let’s write a test to begin designing our feed feature.

require 'test_helper'

class FeedTest < AcceptanceCase
  it 'should exist' do
    get root_path

    assert last_response.ok?, 'GET "/" does not respond OK'
  end
end

Let’s run the suite now and see what happens:

1) Error:
FeedTest#test_0001_should exist:
ActionController::RoutingError: No route matches [GET] "/"
    test/acceptance/feed_test.rb:5:in `block in <class:FeedTest>'

It looks like we don’t have a route. Let’s make one. In order to have a route, we need a controller, so let’s generate a posts controller with an #index action:

$ rails g controller posts index

This generator also makes a posts controller test. Let’s add a test for the route there as well:

# test/controllers/posts_controller_test.rb

require 'test_helper'

class PostsControllerTest < ActionController::TestCase
  describe '#index' do
    it 'GET /' do
      response = get :index
      assert response.ok?
    end
  end
end

Let’s run the suite again to see the new errors:

  1) Error:
FeedTest#test_0001_should exist:
ActionController::RoutingError: No route matches [GET] "/"
    test/acceptance/feed_test.rb:5:in `block in <class:FeedTest>'


  2) Error:
PostsControllerTest::#index#test_0001_GET /:
ActionController::UrlGenerationError: No route matches {:action=>"index", :controller=>"posts"}
    test/controllers/posts_controller_test.rb:6:in `block (2 levels) in <class:PostsControllerTest>'

We have seen the first error before, but the second one is new; it’s from our posts controller test.

Now that we have test coverage and thus a goal of what needs to be implemented, we can write a few lines of code to make them pass.

# config/routes.rb
Rails.application.routes.draw do
  root to: 'posts#index'
end

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
  end
end

<!-- app/views/posts/index.html.erb -->

<h1>A Listing of Posts!</h1>

4 lines of code later and we have 2 new passing tests! Obviously, we don’t have anything right now other than a working route and a static page, but it gives us a stable framework to build off of. Now we can build real functionality. Let’s head back to feed_test.rb.

Here use a context block to better describe a new feature in different contexts. For our purposes, there are two contexts: with posts and without.

require 'test_helper'

class FeedTest < AcceptanceCase
  it 'should exist' do
    get '/'
    assert last_response.ok?, 'GET "/" does not respond OK'
  end

  context 'with posts' do
    # posts are created before the test runs and can be found in the fixtures
    #
    # post #1 -> posts(:one)
    # post #2 -> posts(:two)
    # post #3 -> posts(:three)
    before do
      visit '/'
    end

    let_all(:posts) { page.all('.post') }

    it 'should list all posts' do
      assert_equal Post.count, posts.count
    end

    it 'should list all post titles' do
      assert_each_post_has 'title'
    end

    it 'should list all post bodies' do
      assert_each_post_has 'body'
    end

    it 'should list all post authors' do
      assert_each_post_has 'user.name'
    end
  end

  context 'without posts' do
    before do
      Post.delete_all

      visit '/'
    end

    it 'should say "There are no posts to be found"' do
      assert page.has_content? 'There are no posts to be found'
    end
  end

  private

  def assert_each_post_has(attribute)
    Post.all.each do |post|
      # The second argument is a message to display if the assertion fails.
      # When looping in a test, it's a best practice to ensure that identifying
      # loop information is returned on failure.
      assert page.has_content?(post.instance_eval(attribute)),
             "Post ##{post.id}'s #{attribute} is not displayed"
    end
  end
end

That’s a lot of code, but it’s fairly easy to understand; it reads like English. Let’s just run the tests and see if they’re descriptive enough on their own without explanation:

Run options: --seed 44175

# Running:

.F..FFFF

Finished in 0.257127s, 31.1131 runs/s, 31.1131 assertions/s.

  1) Failure:
FeedTest::post feed::without posts#test_0001_should say "There are no posts to be found" [/Users/jesse/code/examples/news_feed/test/acceptance/feed_test.rb:47]:
Failed assertion, no message given.


  2) Failure:
FeedTest::post feed::with posts#test_0004_should list all post authors [/Users/jesse/code/examples/news_feed/test/acceptance/feed_test.rb:35]:
Post #113629430's user.name is not displayed


  3) Failure:
FeedTest::post feed::with posts#test_0003_should list all post bodies [/Users/jesse/code/examples/news_feed/test/acceptance/feed_test.rb:31]:
Post #113629430's body is not displayed


  4) Failure:
FeedTest::post feed::with posts#test_0001_should list all posts [/Users/jesse/code/examples/news_feed/test/acceptance/feed_test.rb:23]:
Expected: 3
  Actual: 0


  5) Failure:
FeedTest::post feed::with posts#test_0002_should list all post titles [/Users/jesse/code/examples/news_feed/test/acceptance/feed_test.rb:27]:
Post #113629430's title is not displayed

8 runs, 8 assertions, 5 failures, 0 errors, 0 skips

Focus on failing tests:
mtest test/acceptance/feed_test.rb:46
mtest test/acceptance/feed_test.rb:34
mtest test/acceptance/feed_test.rb:30
mtest test/acceptance/feed_test.rb:22
mtest test/acceptance/feed_test.rb:26

Let’s look at the first failure: Immediately I know that a post’s (#113629430 to be specific) body is not being displayed within the post feed. Let’s fix this then! I see this list as a feature guide. It gives me incremental steps to completing a feature; which is great! We’re only going to fix the first test first, then we’ll fix the rest.

# app/controllers/posts_controller.rb

class PostsController < ApplicationController
  def index
    @posts = Post.all
  end
end

<% # app/views/posts/index.html.erb %>

<h1>A Listing of Posts!</h1>

<ul>
<% @posts.each do |post| %>
  <li><%= post.title %></li>
<% end %>
</ul>

That’s it! Now let’s try the test… (mtest test/acceptance/feed_test.rb:26 -I test) Success! That was pretty easy, let’s fix the rest. Testing is great for allowing incremental feature building. To fix the next tests, all we have to do is look at one error, fix it, then fix the next one.

Here’s the final product!

<% # app/views/posts/index.html.erb %>

<h1>A Listing of Posts!</h1>

<% if @posts.empty? %>
<p>There are no posts to be found!</p>
<% else %>
<ul>
  <% @posts.each do |post| %>
    <li class="post">
      <%= post.title %>
      <ul>
        <li><strong>Author: </strong> <%= post.user.name %></li>
        <li><em><%= post.body %></em></li>
      </ul>
    </li>
  <% end %>
</ul>
<% end %>

Obviously, this is a nowhere near complete feature, but we get the core of TDD with great additions from maxitest.

Conclusion

I merely skimmed the surface of a vast philosophy and a great tool. Minitest with maxitest make a potent, yet lightweight combination for implementing test driven development into your Ruby applications. Next time you’re starting a new project, think twice about RSpec and consider giving minitest and maxitest a try.

Interested in more testing topics? I’d love to hear them! Feel free to leave a comment below.

No Reader comments

Recommended

Learn Coding Online
Learn Web Development

Start learning web development and design for free with SitePoint Premium!

Get the latest in Ruby, once a week, for free.