Back to all Posts
Test Driving a Rails API - Part Two
Part 2 - Setup the test environment with minitest/spec and rack-test
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.