Back to all Posts

Test Driving a Rails API - Part Two

Part 2 - Setup the test environment with minitest/spec and rack-test

byJack Flannery

Recap

This is the second part of my Test Driving a Rails API series. In Part 1, we set up our development environment, generated a Rails API-only application, installed dotenv to easily store configuration values in the environment, and installed and configured PostgreSQL version 16 as our database.

In this part, we’ll set up our testing environment so that we can test our Rails API using minitest with minitest/spec. We’ll look at the differences between traditional style unit tests and spec-style tests, or specs. I’ll demonstrate why you should use minitest-rails. We’ll look at using rack-test for testing our API. We’ll even create our own generator to generate API specs.

Testing Rails applications

Testing has always been an important part of the Rails ecosystem. Writing tests allows you to ensure your code works correctly and predictably under various conditions, reducing unexpected errors and bugs. Having a repeatable test suite allows you to be constantly making changes to your codebase while ensuring existing functionality continues to work as intended.

We’ll practice Test Driven Development (TDD) by writing the tests first, then implementing the code to make the tests pass, and then refactor the code, ensuring the test continues to pass.

Minitest vs Rspec

When starting a Rails project, you have a lot of decisions to make. Whether or not to write tests should not be one of them. The big decision is to use Minitest or Rspec. Both of those testing frameworks are great and provide everything you need to test a Rails application thoroughly.

Rspec has been around longer and provides a great DSL that allows you to write very readable tests, or specs as they are called when written in a spec-style with a DSL. Minitest is more lightweight and is now the standard Rails testing framework. I really appreciate Minitest’s simplicity, but I do like Rspec’s DSL and prefer spec-style tests.

minitest/spec

Fortunately, Minitest provides a DSL of its own in minitest/spec. It offers the best of both worlds: Minitest's simplicity and the improved readability and writability of spec-style tests. When using Minitest, you have the choice of classic-style tests or spec-style tests with minitest/spec.

In this post, we’ll write specs with minitest/spec. I mentioned that Minitest is now Rails's default testing framework, and minitest/spec is part of Minitest. Technically, no extra gems are needed to use it in a new Rails project. However, I will explain later why you should also use the minitest-rails gem for better integration.

Tests vs Specs

Let's say you have an Event ActiveRecord model. A model test, in traditional test style, uses normal Ruby class declaration syntax like this:

require "test_helper"

class EventTest < ActiveSupport::TestCase
end

EventTest is a test class and subclass of ActiveSupport::TestCase, which is provided by Rails. ActiveSupport::TestCase is a subclass of Minitest::Test, a class provided by Minitest.

Any class with Minitest::Test as an ancestor is a Minitest test class.

Therefore, EventTest above is a test class.

A spec-style test, or spec, of the same Event model would look like this:

require "test_helper"

describe Event do
end

Instead of a class declaration, you use a describe block to contain the test class. describe is just a method provided by Minitest::Spec::DSL that we call, passing to it the Event class and a block. You can actually pass any number of parameters of any type to describe.

Under the hood, an instance of an anonymous class is created with the class Minitest::Spec as its superclass. Minitest::Spec is a subclass of Minitest::Test.

Just as in traditional style Minitest tests, a spec’s test class must be an ancestor of Minitest::Test.

Even though you couldn’t guess by looking at the above spec, everything that happens inside the outermost describe block is executed inside the context of an anonymous subclass of Minitest::Spec.

That means, unlike in the traditional style Event test above, ActiveSupport::TestCase will not be in the test class’s ancestor chain in the spec version.

Without ActiveSupport::TestCase you will lose all of it’s functionality including having your tests wrapped in database transactions. Test data will remain in the database between runs and will probably affect your test results.

ActiveSupport::TestCase

Rails provides ActiveSupport::TestCase as the base test class for all tests. Several subclasses of ActiveSupport::TestCase have additional useful methods for testing different types of components, including ActionDispatch::IntegrationTest for Rails Controllers and integration tests and ActionView::TestCase for Rails Views.

ActiveSupport::TestCase provides important functionality to your tests, including database transactions as mentioned earlier.

In the above Event spec, Minitest::Spec is the test class, and ActiveSupport::TestCase is nowhere in the ancestor chain.

How do we tell minitest/spec which test class to use? The tldr is to use the minitest-rails gem. I highly recommend adding the minitest-rails gem to your project. The gem does a lot to integrate the Rails testing ecosystem with Minitest, particularly spec-style tests. One primary benefit is that it configures specs to use ActiveSupport::TestCase or one of its subclasses as the spec’s test class, depending on what is passed describe.

Let’s look at how minitest-rails achieves this. It takes advantage of the register_spec_type method in the Minitest::Spec::DSL module.

register_spec_type

The Minitest::Spec::DSL module provides the register_spec_type method for the purpose of deciding which class to use as a spec’s test class.

There are two ways to call register_spec_type. The first is by passing a class constant as the only parameter, and a block. When Minitest sees an outer describe call, all parameters passed to describe are passed to the block. The block is used to test the parameters and determine if the passed in class should be used as the test class for that spec. If the block returns a truthy value, then the class passed as the first parameter will be used for that spec’s test class.

To demonstrate how it works, consider again this spec for the Event ActiveRecord model.

require "test_helper"

describe Event do
end

Here, we pass the Event class to describe. Event is a subclass of ActiveRecord::Base. We want to tell minitest/spec to use ActiveSupport::TestCase whenever an ActiveRecord::Base class is passed to describe.

To achieve this with register_spec_type, it would look something like this:

register_spec_type(ActiveSupport::TestCase) do |desc|
  desc < ActiveRecord::Base if desc.is_a?(Class)
end 

In the spec, the Event model class is the only argument passed to describe and thus will be the value of the desc block parameter. It returns true if desc is a subclass of ActiveRecord::Base. In the case of Event, the block will return true, making ActiveSupport::TestCase the spec’s test class.

Another strategy to call register_spec_type is like this:

register_spec_type(ActiveSupport::TestCase) do |_desc, *addl|
  addl.include? :model
end

In this case, we disregard the first parameter and check for a second parameter. _desc still represents the first parameter passed to describe; by convention, it’s prefixed with a _ because we won’t be using it. The addl parameter, thanks to Ruby’s splat operator *, is an array of the remaining arguments passed to describe. If the symbol :model was passed as an argument, the block will return true, again making ActiveSupport::TestCase the spec’s test class.

That allows us to structure our model spec like this:

require "test_helper"

describe 'Event', :model do
end

The second form of register_spec_type accepts a Regexp as the first argument and a class as the second. If the Regexp matches the first argument to describe, then the class passed as the second argument will be the spec’s test class. We’ll use this second form later on.

This demonstrates how register_spec_type gives you complete control over a spec’s test class.

minitest-rails

minitest-rails does many things for you to improve the experience of using Minitest with Rails. One of those things is taking care of all of the register_spec_type calls such that each type of Rails component that you test will use the intended test class.

To give you an idea of how you might implement this on your own, consider the following code. This is inspired by the code in minitest-rails/lib/minitest/rails.rb, but this is what it would look like in your own test_helper.rb file:

# test/test_helper.rb

ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
require "rails/test_help"
require 'minitest/autorun'

module ActiveSupport
  class TestCase
    # Run tests in parallel with specified workers
    parallelize(workers: :number_of_processors)

    extend Minitest::Spec::DSL

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

    register_spec_type(self) do |_desc, *addl|
      addl.include? :model
    end
  end
end

class ActionDispatch::IntegrationTest
  register_spec_type(self) do |desc|
    desc < ActionController::Metal if desc.is_a?(Class)
  end

  register_spec_type(self) do |_desc, *addl|
    addl.include? :integration
  end
end
 

ActiveSupport::TestCase must be extended with Minitest::Spec::DSL before it can use register_spec_type.

This is what minitest-rails does for you, but what you see above only scratches the surface. This code above only takes care of model, controller, and integration specs; there are other types of specs to consider as well. And register_spec_type is not the only thing minitest-rails does for you.

To install it, let’s add the gem to the development and test group of our Gemfile.

  group :development, :test do
    # (other gems)

    gem "minitest-rails", "~> 7.1.0"
  end

And install the gem:

$ bundle install

Then run the minitest-rails install task, which will generate a new test/test_helper.rb file. Run this task from the app’s root with the argument ., representing the current directory:

$ rails generate minitest:install .

We already have the default test/test_helper.rb file created by Rails, so there is a conflict. When asked if you want to overwrite it, just say yes Y, since we haven’t modified or added anything important to the file yet. If you already have code in your test_helper.rb file that you don’t want to lose, make a backup and then merge it with the newly generated one.

Overwrite /Users/jack/projects/public/my_api/test/test_helper.rb? (enter "h" for help) [Ynaqdhm] Y

The install task will also attempt to generate several test directories. Most already exist, but you might wind up with new test/helpers and test/fixtures directories. You can delete those, but if you want to commit the empty directories to your git repository, commit the .keep files inside.

We now have a new line in our test/test_helper.rb file:

require "minitest/rails"

I highly recommend reviewing the code in that file. Among other things, you’ll see many different calls to register_spec_type. Thankfully, we don’t have to write and maintain all of those.

Testing APIs with rack-test

As mentioned earlier, Rails provides ActionDispatch::IntegrationTest for controller and integration tests. While it does give you some handy methods for making HTTP requests and testing your services, I prefer to use rack-test for testing APIs. It provides better support for things like setting headers and request cookies and maintains a Cookie Jar between requests.

To install rack-test, add it to the test group in your Gemfile:

group :test do
  gem "rack-test"
end

And install the gem:

$ bundle install

To integrate rack-test, let's create our own custom test class for API specs. In test/test_helper.rb after the ActiveSupport::TestCase reopening, add this ApiIntegrationTestCase class definition:

# test/test_helper.rb

class ApiIntegrationTestCase < ActiveSupport::TestCase
  include Rack::Test::Methods

  def app
    Rack::Builder.parse_file('config.ru')
  end
end

We’ll get all of the behavior of ActiveSupport::TestCase plus everything we need to use rack-test.

While we’re at it, let's add a couple of other gems we’ll need for our test environment: factory_bot_rails is a fixtures replacement and generates test model instances. faker is handy for generating fake strings of data to be used in tests. Add those gems to the development and test group of your Gemfile:

group :development, :test do
  # (other gems)

  gem "factory_bot_rails"
  gem "faker"
end

And install the gems:

$ bundle install

To finish adding factory_bot_rails to the project, go back into test_helper.rb. We want the convenient factory_bot methods to be available in all types of tests, so add this line into the ActiveSupport::TestCase class:

include FactoryBot::Syntax::Methods

While you’re there, you can remove the fixtures line, unless you plan to use fixtures, which is fine, but I won’t be using them in this series, in favor of factories.

We need to do one more thing: configure minitest/spec to use our new ApiIntegrationTestCase test class for API specs. For that, we’ll reach for our old friend register_spec_type, who we know very well at this point.

First, let's consider what an API spec might look like:

require 'test_helper'

describe 'Events API' do
end

describe 'EventsApi' do
end

describe 'Events', :api do
end

We want specs in all the above forms to use ApiIntegrationTestCase.

As you can see above, when testing an API, we’ll pass a string to describe. In that case, we can use the second form of register_spec_type, which accepts a Regexp as the first parameter.

We can add this call to register_spec_type inside the ApiIntegrationTestCase class:

register_spec_type(/\w+\s?API$/i, self)

This will match a string, followed by one optional space, then the string “API”, all case insensitive. self here will be ApiIntegrationTestCase.

For good measure, let's support the alternate way of declaring an API spec:

register_spec_type(self) do |_desc, *addl|
  addl.include? :api
end

This allows for this type of spec:

describe 'Events', :api do
end

Your complete test/test_helper.rb file should look like this:

ENV["RAILS_ENV"] ||= "test"
ENV["MT_NO_EXPECTATIONS"] = "true"

require_relative "../config/environment"
require "rails/test_help"
require "minitest/rails"
require "minitest/pride"

module ActiveSupport
  class TestCase
    # Run tests in parallel with specified workers
    parallelize(workers: :number_of_processors)
    
    include FactoryBot::Syntax::Methods
  end
end

class ApiIntegrationTestCase < ActiveSupport::TestCase
  include Rack::Test::Methods

  register_spec_type(/\w+\s?API$/i, self)

  register_spec_type(self) do |_desc, *addl|
    addl.include? :api
  end

  def app
    Rack::Builder.parse_file('config.ru')
  end
end

I like to follow the recommendation and set ENV["MT_NO_EXPECTATIONS"] = "true". We’ll talk more about that later.

Be sure to require minitest/pride because everyone deserves Fabulous tests!

Create an Events API spec

Another benefit of minitest-rails is support for generating spec-style tests. With minitest-rails installed as described above, any test generated will use the spec style by default.

Since we’re practicing TDD, let’s generate an integration test for the Events API, even before creating the Event model, with this command:

$ rails g integration_test events_api

That will generate the file test/integration/events_api_test.rb:

require "test_helper"

describe "Events api", :integration do
  # it "does a thing" do
  #   value(1+1).must_equal 2
  # end
end

This is an integration spec. Modify it so that it's an API spec that uses ApiIntegrationTestCase:

require "test_helper"

describe "Events API" do
end

The string "Events API" matches the Regex /\w+\s?API$/i that we used in our register_spec_type call, so the ApiIntegrationTestCase test class will be used here.

Create an API spec generator

This is good, but it would be better if we could generate an API spec directly, without having to make those modifications. Let's create a custom generator, that generates an API spec. I highly recommend reading through the Rails Guides on Generators to get a better understanding.

Where do we begin when writing a custom generator? The answer is to use the generator generator, of course. Use this command to generate the skeleton of an api_spec generator.

$ rails g generator api_spec

You can see in the output that some files and directories were created:

    create  lib/generators/api_spec
    create  lib/generators/api_spec/api_spec_generator.rb
    create  lib/generators/api_spec/USAGE
    create  lib/generators/api_spec/templates
    invoke  minitest
    create    test/lib/generators/api_spec_generator_test.rb

One thing that was generated was a test for our new generator. Open the generated test file test/lib/generators/api_spec_generator_test.rb and replace the commented-out test with this one:

it 'generates an API spec' do
  run_generator ["nifty_things"]

  assert_file "test/integration/nifty_things_api_spec.rb" do |content|
    assert_match(/describe "NiftyThings API" do/, content)
  end
end

The test file should look like this:

require "test_helper"
require "generators/api_spec/api_spec_generator"

describe ApiSpecGenerator do
  tests ApiSpecGenerator
  destination Rails.root.join("tmp/generators")
  setup :prepare_destination

  it 'generates an API spec' do
    run_generator ["nifty_things"]

    assert_file "test/integration/nifty_things_api_test.rb" do |content|
      assert_match(/describe "NiftyThings API" do/, content)
    end
  end
end

Thanks to minitest-rails and register_spec_type, the test class will be Rails::Generators::TestCase. You don’t need to pass :generator as a second argument to describe because ApiSpecGenerator is a subclass of Rails::Generators::Base. You can see how that works here.

Execute the test with the following command:

$ rails test test/lib/generators/api_spec_generator_test.rb

This should give us the failure that we expect:

Failure:
ApiSpecGenerator::generator#test_0001_generates an API spec [test/lib/generators/api_spec_generator_test.rb:12]:
Expected file "test/integration/nifty_things_api_test.rb" to exist, but does not

To implement our generator and make the test pass, open lib/generators/api_spec/api_spec_generator.rb and replace the contents of the file with this:

class ApiSpecGenerator < Rails::Generators::NamedBase
  source_root File.expand_path("templates", __dir__)

  def create_api_spec_file
    template 'api_spec.rb.erb', "test/integration/#{file_name}_api_test.rb"
  end
end

This expects an ERB template to exist. Create the file: lib/generators/api_spec/templates/api_spec.rb.erb and add the following contents:

require "test_helper"

describe "<%= class_name %> API" do
end

With those files in place, the tests should now be passing.

In the ApiSpecGenerator, we used the variable file_name, and in the template, we used the variable class_name. That is thanks to using Rails::Generators::NamedBase as our generator’s superclass.

If you invoke the generator and give “calendar_items” as the name argument:

rails g api_spec calendar_items

The generator class will have access to the following variables:

class_name = "CalendarItems"
file_name  = "calendar_items"
table_name = "calendar_items"

Finally, update the generated USAGE file lib/generators/api_spec/USAGE with the following contents:

Description:
    Generates an API spec

Example:
    bin/rails generate api_spec nifty_things

    This will create:
        test/integration/nifty_thing_api_spec.rb

Our new generator now has decent help documentation:

$ rails g api_spec --help

Create the Event API spec with the api_spec generator

Now that we have our new generator, let's use it. Delete the Event API spec we generated previously:

$ rm test/integration/events_api_test.rb 

Invoke our custom generator:

$ rails g api_spec events

That gives us our new test/integration/events_api_test.rb

require "test_helper"

describe "Events API" do
end

We now have a good starting point for testing the Events API.

This is a good time to wrap up for now. In the next part, we’ll finally get into building the Events API. Our testing environment is all set up to start using TDD to drive our API’s development.