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.