Test Driving a Rails API - Part Two
Tags: rails, minitest, ruby, testing
Published: 2024-03-22 01:28:25

## 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](https://dev.to/jackf/test-driving-a-rails-api-part-one-4ej0) we set up our development environment, generated a [Rails API-only application](https://guides.rubyonrails.org/api_app.html), installed [dotenv](https://github.com/bkeepers/dotenv) to easily store configuration values in the environment, and installed and configured [PostgreSQL version 16](https://www.postgresql.org/docs/16/index.html) as our database.

In this part, we’ll set up our testing environment so that we can test our Rails API using [minitest](https://github.com/minitest/minitest) with [minitest/spec](https://github.com/minitest/minitest?tab=readme-ov-file#specs-). 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](https://github.com/minitest/minitest-rails). We’ll look at using [rack-test](https://github.com/rack/rack-test) for testing our API. We’ll even create our own generator to generate API specs.

### Testing Rails applications

[Testing](https://guides.rubyonrails.org/testing.html) 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)](https://martinfowler.com/bliki/TestDrivenDevelopment.html) 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](https://github.com/minitest/minitest) or [Rspec](https://rspec.info/). 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](https://thoughtbot.com/blog/writing-a-domain-specific-language-in-ruby) 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](https://github.com/minitest/minitest/blob/master/lib/minitest/spec.rb). 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](https://github.com/minitest/minitest-rails/tree/master) 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:

```bash
require "test_helper"

class EventTest < ActiveSupport::TestCase
end
```

`EventTest` is a test class and subclass of [ActiveSupport::TestCase](https://api.rubyonrails.org/v7.1.2/classes/ActiveSupport/TestCase.html), 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:

```bash
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](https://github.com/minitest/minitest/blob/master/lib/minitest/spec.rb#L106C1-L106C38) 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](https://api.rubyonrails.org/v7.1.2/classes/ActionDispatch/IntegrationTest.html) for Rails Controllers and integration tests and [ActionView::TestCase](https://api.rubyonrails.org/v7.1.2/classes/ActionView/TestCase.html) 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](https://github.com/minitest/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](https://api.rubyonrails.org/v7.1.2/classes/ActiveSupport/TestCase.html) 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.

```ruby
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:

```ruby
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:

```ruby
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:

```ruby
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](https://github.com/minitest/minitest-rails/blob/master/lib/minitest/rails.rb), but this is what it would look like in your own `test_helper.rb` file:

```ruby
# 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`.

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

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

And install the gem:

```bash
$ 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:

```bash
$ 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.

```bash
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:

```ruby
require "minitest/rails"
```

I highly recommend reviewing [the code in that file](https://github.com/minitest/minitest-rails/blob/master/lib/minitest/rails.rb). 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](https://github.com/rack/rack-test) for testing APIs. It provides better support for things like setting headers and request cookies and maintains a [Cookie Jar](https://github.com/rack/rack-test/blob/main/lib/rack/test/cookie_jar.rb) between requests.

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

```ruby
group :test do
  gem "rack-test"
end
```

And install the gem:

```bash
$ 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:

```ruby
# 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](https://github.com/thoughtbot/factory_bot_rails) is a fixtures replacement and generates test model instances. [faker](https://github.com/faker-ruby/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:

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

  gem "factory_bot_rails"
  gem "faker"
end
```

And install the gems:

```bash
$ 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:

```ruby
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:

```ruby
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:

```ruby
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:

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

This allows for this type of spec:

```ruby
describe 'Events', :api do
end
```

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

```ruby
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:

```bash
$ rails g integration_test events_api
```

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

```ruby
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`:

```ruby
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](https://guides.rubyonrails.org/generators.html) 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.

```bash
$ rails g generator api_spec
```

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

```bash
    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:

```ruby
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:

```ruby
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](https://github.com/minitest/minitest-rails/blob/master/lib/minitest/rails.rb#L98).

Execute the test with the following command:

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

This should give us the failure that we expect:

```bash
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:

```ruby
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:

```ruby
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:

```bash
rails g api_spec calendar_items
```

The generator class will have access to the following variables:

```ruby
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:

```bash
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:

```bash
$ 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:

```bash
$ rm test/integration/events_api_test.rb 
```

Invoke our custom generator:

```bash
$ rails g api_spec events
```

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

```bash
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.