home articles hire me

More Minitest::Spec shenanigans

While I already covered the basics of Minitest::Spec, I forgot to discuss a few aspects of the spec flavor. This post serves as a complement to the previous one and digs a bit deeper into some extra Minitest::Spec shenanigans.

let over @ivar

So far, in my setup, I only used ivars to store a User accessible in my test examples:

  class UserTest < Minitest::Spec
    before do
      @user = User.new(first_name: "buffy", last_name: "summers")
    end

    it "returns the capitalized full name" do
      expect(@user.full_name).must_equal "Buffy Summers"
    end
  end

However, Minitest::Spec allows me to use the let(:user) method instead of @user.

If you use RSpec, this will look very familiar. If you don’t, let is a method exposed by the DSL, that allows you to define memoized instance variables accessible in your test examples through accessors.

  class UserTest < Minitest::Spec
    let(:user) { User.new(first_name: "buffy", last_name: "summers") }

    it "returns the capitalized full name" do
      expect(user.full_name).must_equal "Buffy Summers"
    end
  end

No more @user, just a plain user, as Minitest::Spec let adds an accessor to my instance variable.

Also, I no longer need the before do ... end block to define variables. I’ll only use before do ... end if I need a more complex setup:

  class UserTest < Minitest::Spec
    let(:user) { User.new(first_name: "buffy", last_name: "summers") }

    before do
      Account.create(user:, tokens: 100)
    end

    it "returns the capitalized full name" do
      expect(user.full_name).must_equal "Buffy Summers"
    end
  end

before do ... end remains the standard way to create objects not directly used in test examples or to mock objects.

let lazy evaluation

let allows Minitest to lazily evaluate the variable when it’s called in a test example. Repeated references of let within the same test example won’t trigger a re-evaluation. It means Minitest won’t run User.new multiple times within a test example, but beware of mutations.

If I share a let across test examples, the let will be re-evaluated, so I don’t need to worry about mutability. States won’t leak from one it case to the next.

Minitest does not have a let! method (like RSpec does). If you need to create objects required for your tests but not explicitly referenced, use the before do ... end method.

Use subject over explicit calls to the tested method

Minitest::Spec also adds the subject method:

  class UserTest < Minitest::Spec
    subject { user.full_name }

    let(:user) { User.new(first_name: "buffy", last_name: "summers") }

    it "returns the capitalized full name" do
      expect(subject).must_equal "Buffy Summers"
    end
  end

subject replaces explicit calls to the method I test. It’s a syntactic convenience that lets me define the subject of the current scope. I find it easy to read and handy when dealing with nested contexts:

  class UserTest < Minitest::Spec
    describe "#full_name" do
      subject { user.full_name }

      let(:user) { User.new(first_name: "buffy", last_name:) }

      describe "when the user has a one-word last name" do
        let(:last_name) { "summers" }

        it "returns the capitalized full name" do
          expect(subject).must_equal "Buffy Summers"
        end
      end

      describe "when the user has a two-word last name" do
        let(:last_name) { "anne summers" }

        it "does not return the capitalized full name" do
          expect(subject).wont_equal "Buffy Anne Summers"
        end
      end
    end
  end

The output for these tests is:

  lab/minitest-post → ruby user_test.rb --verbose
  Run options: --verbose --seed 4199

  # Running:

  UserTest2::#full_name::when the user has a one-word last name#test_0001_returns the capitalized full name = 0.00 s = .
  UserTest2::#full_name::when the user has a two-word last name#test_0001_does not return the capitalized full name = 0.00 s = .

  Finished in 0.001340s, 1492.5373 runs/s, 1492.5373 assertions/s.
  2 runs, 2 assertions, 0 failures, 0 errors, 0 skips

I like how combining let and subject lets me run multiple contexts while only adjusting the relevant variable. Since lets are lazily evaluated, the initial let(:user) can leverage contextual lets to instantiate a new User with the ad hoc local variables.

In plain Minitest, it’d look like this:

  class UserTest < Minitest::Test
    def setup
      @user = User.new(first_name: "buffy", last_name: "")
    end

    def test_full_name_with_one_word_last_name
      user.last_name = "summers"
      full_name = user.full_name

      assert_equal "Buffy Summers", full_name
    end

    def test_full_name_with_two_word_last_name
      user.last_name = "anne summers"
      full_name = user.full_name

      refute_equal "Buffy Anne Summers", full_name
    end
  end

Since @user = User.new is evaluated immediately during setup, it can’t take advantage from local variables to create different variations of a User on the fly.

Note that there is no named subject (aka subject(:name)) in Minitest.

An aside: I guess this is one of the reasons Minitest maintainers aren’t fond of porting assertions to expectations, because on top of the work, they constantly have to deal with comparison. Which, let’s be honest, is a pain in the back.

Nested describe blocks

My last example shows that we can nest describe blocks!

There are no context in Minitest, but describe blocks are still a nicer way to show context granularity compared to plain Minitest nested classes.

Be careful not to nest describe block too deeply:

  class UserTest < Minitest::Spec
    describe "#full_name" do
      subject { user.full_name }

      describe "when the user has a two-word last name" do
        let(:user) { User.new(first_name: "buffy", last_name:) }
        let(:last_name) { "anne summers" }

        it "does not return the capitalized full name" do
          expect(subject).wont_equal "Buffy Anne Summers"
        end

        describe "when the two-word last name is hyphenated" do
          let(:last_name) { "anne-summers" }

          it "does not return the capitalized full name" do
            expect(subject).wont_equal "Buffy Anne Summers"
          end
        end
      end
    end
  end

The output for these tests becomes messy:

  lab/minitest-post → ruby user_test.rb --verbose
  Run options: --verbose --seed 50406

  # Running:

  UserTest2::#full_name::when the user has a two-word last name#test_0001_does not return the capitalized full name = 0.00 s = .
  UserTest2::#full_name::when the user has a two-word last name::when the two-word last name is hyphanated#test_0001_does not return the capitalized full name = 0.00 s = .

  Finished in 0.001022s, 1956.9472 runs/s, 1956.9472 assertions/s.
  2 runs, 2 assertions, 0 failures, 0 errors, 0 skips

Output legibility is a common critique I hear about Minitest. At the same time, deep nesting makes reading both the files and the output difficult, no matter the framework.

Solving the tricky inheritance in Rails apps

Eric, from the Ruby on Rails Links Slack, pointed out the rails-minitest gem.

Maintained by the same people behind Minitest, rails-minitest provides a Minitest integration for Rails applications.

Its benefits include:

How to install minitest-rails

The gem follows the versioning of Rails, so I added gem "minitest-rails", "~> 8.0.0" to my Gemfile.

minitest-rails comes with a handy installation generator - rails generate minitest:install- which adds all the necessary files to the application.

Be careful, though, the generator will overwrite your existing setup (much like the rails app:update command).

To prevent this, I dry-ran the generator with the --pretend flag, which shows all the changes the gem would make: rails generate minitest:install <APP_PATH> -p.

The changes in my application_system_test_case.rb were minimal:

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  some existing stuff

+  register_spec_type(self) do |desc, *addl|
+    addl.include? :system
+  end

  more existing stuff
end

The generator only added require "minitest/rails" and some parallelization configuration to my test_helper.rb.

Implicit and explicit test class typing

One of the benefits of the Minitest integration is that it lets me declare which class I’m testing with a describe block:

  describe User do
    ...
  end

Instead of:

  class UserTest < Minitest::Spec
    ...
  end

I don’t need to suffix my test class with Test (ie UserTest) and minitest-rails is able to infer the inheritance from the declared class.

I also can cast the type explicitly to help Minitest figure out which kind of test I want to run.

  describe User, :model do
    ...
  end

minitest-rails even lets me declare custom types and custom inheritance, but I haven’t (yet) figured out how this works (and where I could use it).

More spec-flavored expectations

minitest-rails also exposes extra expectations, specifically for mailers, jobs or routing. We don’t need to choose between expectations for testing models, and assertions for testing jobs. Expectations everywhere!

For instance, Rails’ default Minitest syntax would look like this:

  assert_enqueued_jobs 1 do
    NotifyUser.perform_later(user_id: user.id)
  end

And minitest-rails will look like this:

  must_enqueue_jobs 1 do
    NotifyUser.perform_later(user_id: user.id)
  end

The assertion becomes an expectation, harmonizing the syntax of my tests.

Yes, but…

While I managed to install the gem and update the declaration of my tests, one thing refused to work out of the box: mailers expectations.

Consider this test:

  describe NotifyUserJob, :job do
    let(:user) { User.new(first_name: "buffy", last_name: "summers") }

    it "sends a notification email to the user" do
      must_enqueue_email_with UserMailer, :notify, args: [user]
    end
  end

I used the new describe declaration for my test case, explicitly cast the :job type, and used the newly available must_enqueue_email_with expectation.

Easy, right? Well, no matter how I declared my test case, whether with implicit or explicit typing, must_enqueue_email_with raised a NoMethodError.

It means the Minitest::Rails::Expectations::ActiveMailer module isn’t mixed-in automatically.

I had to include Minitest::Rails::Expectations::ActionMailer within my test case to make this expectation work:

describe CalendlyEventsManagerJob, :integration do
  include Minitest::Rails::Expectations::ActionMailer

  ...
end

There’s no mention of this in the documentation, but I’ve found this comment in the ActiveJob expectations module:

This exists as a module to allow easy mixing into classes other than ActiveJob::TestCase where you might want to do job testing e.g. in an Active Record model which triggers jobs in a callback.

So it seems there is still some manual mixin to do, despite most of the inheritance being automatically handled.

Is it normal? Did I do something wrong? I don’t know (yet)!

Wrapping up

Well, this was a fun chase!

Let’s recap:

Big thank-yous to Cecile for pointing out the topic of let and subject, and to Eric for helping me out with setting-up the minitest-rails gem.