23 Apr 2021 · Software Engineering

    Getting Started with Minitest

    17 min read
    Contents

    What is Minitest?

    Minitest is a testing tool for Ruby that provides a complete suite of testing facilities. It also supports behaviour-driven development, mocking and benchmarking. With the release of Ruby 1.9, it was added to Ruby’s standard library, which increased its popularity. Even though at first it gives off the impression of a very small testing tool, it has a very rich set of features which make it a powerful tool to have under your belt.

    In this tutorial, we will explore how to install Minitest, use it in your projects, and get started on the journey of test-driven development in Ruby.

    Building a Small Project

    We’ll learn the basics of Minitest by building a small Magic 8 Ball library. Like any Magic 8 Ball, it will respond to yes or no questions with predefined answers. We will build this project using test-driven development (TDD), using the test-first approach. If you are unsure what these phrases mean, you can head over to the Behaviour-driven Development tutorial to learn more about these concepts and then come back to complete this tutorial.

    Setting Up Minitest

    Let’s start a new Ruby project where we’ll configure Minitest as a dependency via Bundler by adding it to our Gemfile.

    Create a new directory, and put the following code in your Gemfile:

    # Gemfile
    source "https://rubygems.org"
    
    gem "minitest"

    Next, open your project’s directory in terminal, and run bundle install to install the latest version of Minitest in your bundle. The output should be similar to this:

    Fetching gem metadata from https://rubygems.org/.........
    Fetching version metadata from https://rubygems.org/..
    Resolving dependencies...
    Using minitest 5.8.2
    Using bundler 1.10.6
    Bundle complete! 1 Gemfile dependency, 2 gems now installed.
    Use bundle show [gemname] to see where a bundled gem is installed.

    Your First Unit Test

    Let’s create a new file called magic_ball.rb, and write our first test in it. We’ll start by writing both the test and the production code in the same file. The class will be separated from the test in the later steps of this tutorial. We’ll use the test-first approach, meaning that we will write the test first, and the production code afterwards.

    Start by opening magic_ball.rb in your favorite editor and typing the following:

    # magic_ball.rb
    require 'minitest/autorun'
    
    class MagicBallTest < Minitest::Test
    end

    As you can see, every test file in Minitest is just a class. This shows Minitest’s power – it’s just plain Ruby, no fancy syntax involved. While every test file is a class, every test case is just a plain method.

    Let’s add our first test case:

    # magic_ball.rb
    require 'minitest/autorun'
    
    class MagicBallTest < Minitest::Test
      def test_ask_returns_an_answer
        magic_ball = MagicBall.new
        assert magic_ball.ask("Whatever") != nil
      end
    end

    Our first method or test case is that we expect the method MagicBall#ask to return a value. In other words, it should never return nil.

    Let’s run our test. Run the file from your terminal by typing ruby magic_ball.rb. The output should be similar to the following:

    Run options: --seed 10646
    
    # Running:
    
    E
    
    Finished in 0.001484s, 674.0230 runs/s, 0.0000 assertions/s.
    
      1) Error:
    MagicBallTest#test_ask_returns_an_answer:
    NameError: uninitialized constant MagicBallTest::MagicBall
        magic_ball.rb:5:in `test_ask_returns_an_answer'
    
    1 runs, 0 assertions, 0 failures, 1 errors, 0 skips

    As you can see, Minitest is so simple that its tests are run just like any other Ruby program. The output above shows the report from the tests that were run, including information about test duration and errors.

    Our test case failed because the MagicBall class does not exist. Let’s add the class and rerun the tests:

    # magic_ball.rb
    require 'minitest/autorun'
    
    class MagicBallTest < Minitest::Test
      def test_ask_returns_an_answer
        magic_ball = MagicBall.new
        assert magic_ball.ask("Whatever") != nil
      end
    end
    
    class MagicBall
    end

    We will add the class only, without any methods. The whole idea of the test-first approach is to do your work in small increments (adding just a bit of code), and then rerun your tests until the test you’ve written passes. Let’s run the test again, with ruby magic_ball.rb:

    Run options: --seed 55028
    
    # Running:
    
    E
    
    Finished in 0.001323s, 755.8619 runs/s, 0.0000 assertions/s.
    
      1) Error:
    MagicBallTest#test_ask_returns_an_answer:
    NoMethodError: undefined method `ask' for #<MagicBall:0x007f8d0d0db168>
        magic_ball.rb:6:in `test_ask_returns_an_answer'
    
    1 runs, 0 assertions, 0 failures, 1 errors, 0 skips

    As you can see, the error has changed. Instead of an undefined class error, we are now getting the undefined method error. Let’s add the method and rerun the test:

    # magic_ball.rb
    require 'minitest/autorun'
    
    class MagicBallTest < Minitest::Test
      def test_ask_returns_an_answer
        magic_ball = MagicBall.new
        assert magic_ball.ask("Whatever") != nil
      end
    end
    
    class MagicBall
      def ask question
      end
    end

    Running the test again will show an output similar to the following:

    Run options: --seed 23565
    
    # Running:
    
    F
    
    Finished in 0.001138s, 878.9169 runs/s, 878.9169 assertions/s.
    
      1) Failure:
    MagicBallTest#test_ask_returns_an_answer [magic_ball.rb:5]:
    Failed assertion, no message given.
    
    1 runs, 1 assertions, 1 failures, 0 errors, 0 skips

    Again, our test failed, but now we are getting a different error. In TDD, getting a different error means progress.

    The error now states that the assertion has failed. In Minitest’s language, this means that the assert method in the test did not receive anything. In other words, no message was delivered to it. Making this test pass is easy — we can make the MagicBall#ask method return anything other than nil.

    Let’s make it pass:

    # magic_ball.rb
    require 'minitest/autorun'
    
    class MagicBallTest < Minitest::Test
      def test_ask_returns_an_answer
        magic_ball = MagicBall.new
        assert magic_ball.ask("Whatever") != nil
      end
    end
    
    class MagicBall
      def ask question
        "Whatever"
      end
    end

    Since the ask method will return the “Whatever” string, the test will pass because the returned value is not nil. Running the test returns the following output:

    Run options: --seed 34802
    
    # Running:
    
    .
    
    Finished in 0.001434s, 697.4182 runs/s, 697.4182 assertions/s.
    
    1 runs, 1 assertions, 0 failures, 0 errors, 0 skips

    Our first test passes, but our code is still not working properly. With this implementation, it will return “Whatever” for any question. Let’s fix this. We need an additional test that will be more specific, and that will get our code to return a meaningful answer.

    To make our Magic Ball more meaningful, let’s add some answers for it to return. According to Wikipedia, every Magic 8 Ball has some standard predefined answers. We will put these answers in an array, which will be a constant called ANSWERS in our MagicBall class.

    Let’s write a test for this:

    # magic_ball.rb
    require 'minitest/autorun'
    
    class MagicBallTest < Minitest::Test
      def test_ask_returns_an_answer
        magic_ball = MagicBall.new
        assert magic_ball.ask("Whatever") != nil
      end
    
      def test_predefined_answers_is_array
        assert_kind_of Array, MagicBall::ANSWERS
      end
    end
    
    class MagicBall
      def ask question
        "Whatever"
      end
    end

    By adding a new test case, we are enforcing the class to have an array constant called ANSWERS. Let’s run the test again:

    Run options: --seed 56425
    
    # Running:
    
    .E
    
    Finished in 0.001249s, 1601.3862 runs/s, 800.6931 assertions/s.
    
      1) Error:
    MagicBallTest#test_predefined_answers:
    NameError: uninitialized constant MagicBall::ANSWERS
        magic_ball.rb:10:in `test_predefined_answers'
    
    2 runs, 1 assertions, 0 failures, 1 errors, 0 skips

    As you can see, the test has failed. This is because the constant MagicBall::ANSWERS is not present. Let’s add it to our class:

    # magic_ball.rb
    require 'minitest/autorun'
    
    class MagicBallTest < Minitest::Test
      def test_ask_returns_an_answer
        magic_ball = MagicBall.new
        assert magic_ball.ask("Whatever") != nil
      end
    
      def test_predefined_answers_is_array
        assert_kind_of Array, MagicBall::ANSWERS
      end
    end
    
    class MagicBall
      ANSWERS = []
    
      def ask question
        "Whatever"
      end
    end

    If we rerun the test, it will pass. But should it? The ANSWERS array is empty, so it won’t do us any good. Let’s add another test that will confirm that the ANSWERS array is not empty:

    # magic_ball.rb
    require 'minitest/autorun'
    
    class MagicBallTest < Minitest::Test
      def test_ask_returns_an_answer
        magic_ball = MagicBall.new
        assert magic_ball.ask("Whatever") != nil
      end
    
      def test_predefined_answers_is_array
        assert_kind_of Array, MagicBall::ANSWERS
      end
    
      def test_predefined_answers_is_not_empty
        refute_empty MagicBall::ANSWERS
      end
    end
    
    class MagicBall
      ANSWERS = []
    
      def ask question
        "Whatever"
      end
    end

    We’ve added a new assertion. We used refute instead of assert because refute expects a false(y) value while assert expects a truth(y) value. In our case, refute_empty will fail if the object that is passed as an argument is empty, which is exactly what we want. With this test, we want to ensure that the ANSWERS array will have at least one predefined answer, so we can use it in our MagicBall#ask method.

    Let’s run the tests again:

    Run options: --seed 16504
    
    # Running:
    
    ..F
    
    Finished in 0.001200s, 2500.7502 runs/s, 3334.3336 assertions/s.
    
      1) Failure:
    MagicBallTest#test_predefined_answers_is_not_empty [magic_ball.rb:64]:
    Expected [] to not be empty.
    
    3 runs, 4 assertions, 1 failures, 0 errors, 0 skips

    Our brand new error message shows that Minitest expected [] to not be empty. This means that we now need to add some answers to the array to make the test pass.

    Let’s do that. First, we will add the predefined answers from the Wikipedia article:

    # magic_ball.rb
    require 'minitest/autorun'
    
    class MagicBallTest < Minitest::Test
      def test_ask_returns_an_answer
        magic_ball = MagicBall.new
        assert magic_ball.ask("Whatever") != nil
      end
    
      def test_predefined_answers_is_array
        assert_kind_of Array, MagicBall::ANSWERS
      end
    
      def test_predefined_answers_is_not_empty
        refute_empty MagicBall::ANSWERS
      end
    end
    
    class MagicBall
      ANSWERS = [
        "It is certain",
        "It is decidedly so",
        "Without a doubt",
        "Yes, definitely",
        "You may rely on it",
        "As I see it, yes",
        "Most likely",
        "Outlook good",
        "Yes",
        "Signs point to yes",
        "Reply hazy try again",
        "Ask again later",
        "Better not tell you now",
        "Cannot predict now",
        "Concentrate and ask again",
        "Don't count on it",
        "My reply is no",
        "My sources say no",
        "Outlook not so good",
        "Very doubtful"
      ]
    
      def ask question
        "Whatever"
      end
    end

    If we run the test again, it will pass:

    Run options: --seed 42118
    
    # Running:
    
    ...
    
    Finished in 0.002505s, 1197.5895 runs/s, 1596.7860 assertions/s.
    
    3 runs, 4 assertions, 0 failures, 0 errors, 0 skips

    Now, let’s go back to the first test. Although it’s passing, it’s what we call a “false positive test”. It appears to be working, but the test’s logic is actually broken. If you read the test, you’ll see that we expect the answer to the question to be different from nil. What we actually need is for the answer to always be derived from the MagicBall::ANSWERS array.

    Let’s refactor our test:

    class MagicBallTest < Minitest::Test
      def test_ask_returns_an_answer
        magic_ball = MagicBall.new
        assert_includes MagicBall::ANSWERS, magic_ball.ask("Whatever")
      end
    
      def test_predefined_answers_is_array
        assert_kind_of Array, MagicBall::ANSWERS
      end
    
      def test_predefined_answers_is_not_empty
        refute_empty MagicBall::ANSWERS
      end
    end
    
    class MagicBall
      # ANSWERS omitted...
      def ask question
        "Whatever"
      end
    end

    Our first test now states that the answer returned from the MagicBall#ask method must be included in the MagicBall::ANSWERS array.

    We’ll omit the ANSWERS array in the code examples below in order to save space.

    Let’s run the tests now:

    Run options: --seed 62725
    
    # Running:
    
    ..F
    
    Finished in 0.001448s, 2071.9978 runs/s, 3453.3296 assertions/s.
    
      1) Failure:
    MagicBallTest#test_ask_returns_an_answer [magic_ball.rb:7]:
    Expected ["It is certain", "It is decidedly so", "Without a doubt", "Yes, definitely", "You may rely on it", "As I see it, yes", "Most likely", "Outlook good", "Yes", "Signs point to yes", "Reply hazy try again", "Ask again later", "Better not tell you now", "Cannot predict now", "Concentrate and ask again", "Don't count on it", "My reply is no", "My sources say no", "Outlook not so good", "Very doubtful"] to include "Whatever".
    
    3 runs, 5 assertions, 1 failures, 0 errors, 0 skips

    As you can see, our test now actually works. It states that it was expected for the array of answers to contain “Whatever”. However, that’s impossible since we hard-coded “Whatever” as an answer. Let’s change our code so it actually returns a random item from the array:

    class MagicBall
      def ask question
        ANSWERS.sample
      end
    end

    The Array#sample method does just that – it takes a sample item from the array. If we run the tests, they will all pass:

    Run options: --seed 54975
    
    # Running:
    
    ...
    
    Finished in 0.001436s, 2088.9750 runs/s, 3481.6250 assertions/s.
    
    3 runs, 5 assertions, 0 failures, 0 errors, 0 skips

    We have now completed one part of the library, but our magic_ball.rb file got quite big. Let’s split it into two different files. Create a magic_ball_test.rb file and move the MagicBallTest class to the newly created file.

    Next, we need to require the magic_ball.rb file at the top of the file, so that the test can use the MagicBall class.

    This is what your two files should look like:

    # magic_ball_test.rb
    require "minitest/autorun"
    require_relative "magic_ball"
    
    class MagicBallTest < Minitest::Test
      def test_ask_returns_an_answer
        magic_ball = MagicBall.new
        assert_includes MagicBall::ANSWERS, magic_ball.ask("Whatever")
      end
    
      def test_predefined_answers_is_array
        assert_kind_of Array, MagicBall::ANSWERS
      end
    
      def test_predefined_answers_is_not_empty
        refute_empty MagicBall::ANSWERS
      end
    end
    # magic_ball.rb
    class MagicBall
      def ask question
        ANSWERS.sample
      end
    end

    As you can see, we required the magic_ball.rb file at the beginning of the magic_ball_test.rb file by using the require_relative method, since the location of the file in the filesystem is relative to the file that requires it.

    We Must Ask a Question

    When calling the MagicBall#ask method, we should be asking a question. A question is a string that ends with a question mark. However, our current implementation of the MagicBall#ask method does not validate the question. Let’s add this functionality.

    First, we need a test case. Open magic_ball_test.rb and add a new test case:

    # magic_ball_test.rb
    require "minitest/autorun"
    require_relative "magic_ball"
    
    class MagicBallTest < Minitest::Test
      def test_ask_returns_an_answer
        magic_ball = MagicBall.new
        assert_includes MagicBall::ANSWERS, magic_ball.ask("Whatever")
      end
    
      def test_predefined_answers_is_array
        assert_kind_of Array, MagicBall::ANSWERS
      end
    
      def test_predefined_answers_is_not_empty
        refute_empty MagicBall::ANSWERS
      end
    
      def test_raises_error_when_question_is_number
        assert_raises "Question has invalid format." do
          magic_ball = MagicBall.new
          magic_ball.ask(1)
        end
      end
    end

    The test_raises_error_when_question_is_number test case uses a new assertion that we haven’t used before. The assert_raises assertion expects that the code in the block it receives will raise an error. In our case, it expects that the message of the error will be “Question has invalid format.”.

    Let’s run the test and see the error we get back:

    Run options: --seed 9870
    
    # Running:
    
    ...F
    
    Finished in 0.001582s, 2528.0487 runs/s, 3792.0731 assertions/s.
    
      1) Failure:
    MagicBallTest#test_raises_error_when_question_is_number [magic_ball_test.rb:20]:
    Question has invalid format..
    StandardError expected but nothing was raised.
    
    4 runs, 6 assertions, 1 failures, 0 errors, 0 skips

    The failure states that it expected a StandardError with a message, but nothing was raised. Let’s add the production code to our MagicBall class.

    # magic_ball.rb
    class MagicBall
      def ask question
        raise "Question has invalid format." unless question.is_a?(String) && question[-1] == "?"
        ANSWERS.sample
      end
    end

    Our MagicBall#ask method validates the question by checking if it’s a String and if its last character is a question mark.

    Let’s run the tests again:

    Run options: --seed 3383
    
    # Running:
    
    ...E
    
    Finished in 0.002405s, 1662.8774 runs/s, 1662.8774 assertions/s.
    
      1) Error:
    MagicBallTest#test_ask_returns_an_answer:
    RuntimeError: Question has invalid format.
        /Users/ie/dev/community-ile-eftimov-getting-started-with-minitest/project/magic_ball.rb:27:in `ask'
        magic_ball_test.rb:8:in `test_ask_returns_an_answer'
    
    4 runs, 4 assertions, 0 failures, 1 errors, 0 skips

    We got another error. If you look closely, the test case that fails is the very first one we wrote – MagicBallTest#test_ask_returns_an_answer.

    Let’s take a look at the test case again:

    def test_ask_returns_an_answer
      magic_ball = MagicBall.new
      assert_includes MagicBall::ANSWERS, magic_ball.ask("Whatever")
    end

    The test case looks fine, but there’s a small problem with it. Instead of asking the MagicBall#ask method a question, we are just sending a string – “Whatever”. Let’s change this to an actual question:

    # magic_ball_test.rb
    require "minitest/autorun"
    require_relative "magic_ball"
    
    class MagicBallTest < Minitest::Test
      def test_ask_returns_an_answer
        magic_ball = MagicBall.new
        assert_includes MagicBall::ANSWERS, magic_ball.ask("Is Minitest awesome?")
      end
    
      def test_predefined_answers_is_array
        assert_kind_of Array, MagicBall::ANSWERS
      end
    
      def test_predefined_answers_is_not_empty
        refute_empty MagicBall::ANSWERS
      end
    
      def test_raises_error_when_question_is_number
        assert_raises "Question has invalid format." do
          magic_ball = MagicBall.new
          magic_ball.ask(1)
        end
      end
    end

    When we run the tests, they will all pass. According to the TDD cycle (red-green-refactor), we can refactor our code now that we got the tests to pass. One thing that stands out in the code is the unless predicate in the MagicBall#ask method. Let’s extract that logic in a private method:

    # magic_ball.rb
    class MagicBall
      def ask question
        raise "Question has invalid format." unless is_question_valid?(question)
        ANSWERS.sample
      end
    
      private
    
      def is_question_valid?(question)
        question.is_a?(String) && question[-1] == "?"
      end
    end

    The intention of the raise line in the ask method is now quite clear. The naming of the private method shows that an error will be raised if the format of the question is invalid. Now that we’ve completed the refactor, let’s run the tests:

    Run options: --seed 46746
    
    # Running:
    
    ....
    
    Finished in 0.001417s, 2822.6063 runs/s, 4233.9094 assertions/s.
    
    4 runs, 6 assertions, 0 failures, 0 errors, 0 skips

    Our code works great. We’ve managed to successfully build a small project using test-driven development with Minitest. Let’s take a quick tour of another great feature of Minitest.

    Minitest::Spec

    For all of you who are used to using RSpec, Minitest has a useful feature called Minitest::Spec. It’s a complete spec engine that works on top of Minitest::Unit, and seamlessly makes test assertions look and feel like spec expectations. It’s basically a wrapper around the unit testing engine of Minitest.

    Let’s see how we can convert the unit tests assertions that we wrote in magic_ball_test.rb into spec expectations.

    Create a new file called magic_ball_spec.rb:

    # magic_ball_spec.rb
    require "minitest/autorun"
    require "minitest/spec"
    require_relative "magic_ball"
    
    describe MagicBall do
      describe "#ask" do
        it "returns an answer" do
          magic_ball = MagicBall.new
          assert_includes MagicBall::ANSWERS, magic_ball.ask("Is Minitest awesome?")
        end
    
        it "predefined answers is array" do
          assert_kind_of Array, MagicBall::ANSWERS
        end
    
        it "predefined answers is not empty" do
          refute_empty MagicBall::ANSWERS
        end
    
        it "raises error when question is number" do
          assert_raises "Question has invalid format." do
            magic_ball = MagicBall.new
            magic_ball.ask(1)
          end
        end
      end
    end

    As you can see, although the assertions syntax is different from RSpec’s, it definitely feels like home for RSpec users.

    Conclusion

    Although simple and minimal, Minitest is a very powerful testing tool for Ruby. In this tutorial, we saw how to develop a small project with Minitest using test-driven development combined with the test-first approach. This tutorial just scratched the surface — there’s plenty more to Minitest, so head over to Minitest’s documentation for more fun.

    The full source code for this tutorial is available on Github.

    References

    Here are some links to Minitest’s documentation and API used while writing this tutorial:

    P.S. Would you like to learn how to build sustainable Rails apps and ship more often? We’ve recently published an ebook covering just that — “Rails Testing Handbook”. Learn more and download a free copy.

    Leave a Reply

    Your email address will not be published. Required fields are marked *

    Avatar
    Writen by:
    Full-stack Ruby on Rails developer. Blogs regularly at eftimov.net.