Skip to content

Instantly share code, notes, and snippets.

@rpearce
Last active May 10, 2022 02:25
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save rpearce/d4920d7a66828b555ad2 to your computer and use it in GitHub Desktop.
Save rpearce/d4920d7a66828b555ad2 to your computer and use it in GitHub Desktop.
Ruby Fundamentals - Testing with MiniTest

Testing with MiniTest

MiniTest is a Ruby testing library that comes built in to Ruby, itself. While there are a number of other testing libraries in the Ruby ecosystem, we will focus on MiniTest because of its inclusion in Ruby by default. In this lesson, we will explore the different functionality provided by MiniTest and how to use it to solidify our code.

Our First Test

Before we can write the world's best test suite, we must first understand how to structure a test and run the tests. First, we create a file called tests.rb, and we place the following inside of it:

require "minitest/autorun"

class TestReality < MiniTest::Test
  def test_the_universe_still_works
    assert true == true
  end
end

The first line tells Ruby to load MiniTest's autorun code that will automatically run the test suite.

We then define a class, TestReality, that inherits from MiniTest's Test class. What is wonderful about this testing library is that it's just plain ol' Ruby with some helpers and not a full domain-specific language (DSL) that you need to learn.

Finally, all MiniTest test definitions must begin with test_ if you want them to be executed as tests. Thus, our test_the_universe_still_works test will be recognized. Here, we use the assert method to tell MiniTest that we would like to make an assertion of some sort (in this case, two things are equal via ==), and the resulting value should be truthy. The values true and true are indeed equal, so we should receive a passing test.

Once we save the file, we can then open our Terminal, navigate to the test's directory, and then run the file:

$ ruby test.rb
Run options: --seed 63069

# Running:

.

Finished in 0.000895s, 1117.8318 runs/s, 1117.8318 assertions/s.

1 runs, 1 assertions, 0 failures, 0 errors, 0 skips

After we execute the file, the resulting output gives us details about how many tests were executed, failed, errored, and skipped, as well as the time it took to complete. The . you see in the middle is what is printed to the Terminal when a test successfully passes.

A Failing Test

If we change our assertion in the test from true == true to true != true in order to trigger a failing test, we will see the following output:

$ ruby test.rb
Run options: --seed 27431

# Running:

F

Finished in 0.001395s, 716.9178 runs/s, 716.9178 assertions/s.

  1) Failure:
TestOurFirst#test_the_universe_still_works [test.rb:5]:
Failed assertion, no message given

1 runs, 1 assertions, 1 failures, 0 errors, 0 skips

Let's break down the failure message here, for this will tell us exactly where our failing test lies. The TestOurFirst#test_the_universe_still_works text means that the error occurred in the TestOurFirst class within the test_the_universe_still_works test. The next part, [test.rb:5], tells us that the file in question is test.rb, and the line in question is line 5.

Last but not least, what about Failed assertion, no message given? This is the generic response for a failed assertion. If you would like to provide your own error messaging, the assert method (and methods similar to it) accept a second argument that is a string to output when the test fails. If we change the line

assert true != true

to

assert true != true, "true should be true, and the universe should not come undone"

then the next time we run $ ruby test.rb, instead of the Failed assertion... messaging, we will see

  1) Failure:
TestReality#test_the_universe_still_works [test.rb:5]:
true should be true, and the universe should not come undone

Testing a Method

It's time to test a real method! Since everyone likes to talk about the weather, we have decided to create a Celsius to Fahrenheit converter method named c_to_f. Thanks to science, there is already a conversion formula out there for us to take advantage of. However, we're going to go about implementing this method backwards by writing a test first.

Test-First

As we did before, let's create a file for our tests named conversions_test.rb, and let's add the following to it:

require "minitest/autorun"

class TestConversions < MiniTest::Test
  def test_freezing_conversion
    assert c_to_f(0) == 32
  end
  
  def test_boiling_conversion
    assert c_to_f(100) == 212
  end
end

The freezing and boiling temperatures of water are a good place to start, for these are common, and our as-yet unwritten c_to_f method should convert these Celsius values to their respective Fahrenheit values listed above.

When we run the file, we expect to see some errors, for we know c_to_f doesn't exist.

$ ruby conversions_test.rb
Run options: --seed 47959

# Running:

EE

Finished in 0.000969s, 2064.7271 runs/s, 0.0000 assertions/s.

  1) Error:
TestConversions#test_boiling_conversion:
NoMethodError: undefined method `c_to_f' for #<TestConversions:0x007ffeb42d1e18>
    conversions_test.rb:9:in `test_boiling_conversion'


  2) Error:
TestConversions#test_freezing_conversion:
NoMethodError: undefined method `c_to_f' for #<TestConversions:0x007ffeb42d1990>
    conversions_test.rb:5:in `test_freezing_conversion'

2 runs, 0 assertions, 0 failures, 2 errors, 0 skips

Errors! Naturally, our c_to_f method does not exist, so let's change that by defining the method:

def c_to_f()
end

require "minitest/autorun"

class TestConversions < MiniTest::Test
  def test_freezing_conversion
    assert c_to_f(0) == 32
  end

  def test_boiling_conversion
    assert c_to_f(100) == 212
  end
end

If we run the tests again, we will receive different errors:

  1) Error:
TestConversions#test_boiling_conversion:
ArgumentError: wrong number of arguments (given 1, expected 0)
    conversions_test.rb:1:in `c_to_f'
    conversions_test.rb:12:in `test_boiling_conversion'

  2) Error:
TestConversions#test_freezing_conversion:
ArgumentError: wrong number of arguments (given 1, expected 0)
    conversions_test.rb:1:in `c_to_f'
    conversions_test.rb:8:in `test_freezing_conversion'

As you may have guessed by now, the test's error results are actually telling us how we should be implementing our method, based on what is expected to happen! The test failures tell us c_to_f needs to accept an argument, so let's do that:

def c_to_f(temp)
end

Once we run the code again, the errors change to failures and look like this:

  1) Failure:
TestConversions#test_boiling_conversion [conversions_test.rb:12]:
Failed assertion, no message given.

  2) Failure:
TestConversions#test_freezing_conversion [conversions_test.rb:8]:
Failed assertion, no message given.

Now that our tests have instructed us on what was required to run our tests, let's implement the actual conversion functionality:

def c_to_f(temp)
  temp * (9 / 5) + 32
end

If we run the tests again, we will receive some perplexing results:

F.

Finished in 0.000936s, 2136.4782 runs/s, 2136.4782 assertions/s.

  1) Failure:
TestConversions#test_boiling_conversion [conversions_test.rb:13]:
Failed assertion, no message given.

Wait — what? Our freezing temperature test works, but our boiling one doesn't? This is why we write tests. One thing you may have easily overlooked is that the (9 / 5) part of the formula, in Ruby, will return 1 instead of 1.8, for Ruby thinks you want a whole integer back; this could cause you heaps of trouble! Thank goodness we wrote tests, right? To fix the formula, we need to tell Ruby that we are dealing with floating point values:

def c_to_f(temp)
  temp * (9.0 / 5.0) + 32
end

Your test suite will now pass!

Red => Green => Refactor

It will often be to your advantage to write your test expectations first. This allows you to watch the tests fail (red), then make them work (green), and then go back and alter the code for efficiency and legibility (refactor).

Different Assertion Methods

refute

Thus far, assert has been our go-to testing method to test for a truthy value. Sometimes, though, we want to test for negative values. Here is an example using assert:

assert 2 + 2 != 5

This can read, "Assert that two plus two does not equal five." However, many folks like to keep their comparisons positive, and MiniTest provides the refute method to allow for this:

refute 2 + 2 == 5

assert_*

While assert is all you really need, MiniTest has a few more methods for your convenience to test for certain things:

  • assert_equal 2 + 2, 4
  • assert_raises(NameError) { this_method_doesnt_exist }
  • assert_match /\.html/, "index.html"
  • assert_respond_to 2, :even?

You can view a full list of them on the MiniTest Assertions ruby-doc page.

Setting up Default Data and Cleaning up

There are many instances where we will want to define some test data one time and use them in multiple tests so that we can keep our test code DRY (don't repeat yourself). MiniTest provides the setup and teardown methods for performing actions before and after each test runs, respectively.

setup

We have a user hash of user information that we want to use in multiple places throughout our test. To do this, we can define a method named setup where we assign our user hash to an instance variable, @user.

class User
  attr_accessor :name, :age
  
  def age_description
    "#{name} is #{age} years old."
  end
end

require "minitest/autorun"

class TestUser < MiniTest::Test
  def setup
    @user = User.new
    @user.name = "Lucy"
    @user.age = 42
  end
  
  def test_instance_type
    assert_instance_of User, @user
  end

  def test_age_description
    assert_equal @user.age_description, "Lucy is 42 years old."
  end
  
  # more tests here...
end

teardown

If we would like to reset the value of @user after every test runs, then we can do so using a teardown method definition:

class TestUser < MiniTest::Test
  # tests here...

  def teardown
    @user = nil
  end
end

Nested Tests

There will be times where it makes sense to group certain tests together that have different contexts from other groups of tests. Our example will look at testing a simple Account class that has a balance and a method determining if we can or cannot withdraw an amount:

class Account
  attr_accessor :balance

  def can_withdraw?(amount)
    balance - amount > 0
  end
end

When it comes to testing, we want to be able to write out tests in at least two contexts: when there is a positive balance already in the account and when there is a negative balance. When using MiniTest, we can nest test classes that inherit from our original test class:

require "minitest/autorun"

class TestAccount < MiniTest::Test
  def setup
    @account = Account.new
  end

  class TestAccountPositiveBalance < TestAccount
    def setup
      super()
      @account.balance = 1000
    end

    def test_can_withdraw?
      assert @account.can_withdraw?(999)
    end
  end

  class TestAccountNegativeBalance < TestAccount
    def setup
      super()
      @account.balance = -100
    end

    def test_can_withdraw?
      refute @account.can_withdraw?(1)
    end
  end
end

Here, we first define a new account instance in the parent's setup, then our subclasses inherit from the main test class, override their parent's setup method (while still calling it via super()), set their own balances, and then run their tests.

You can nest as much as you like or you can place these nested classes outside of the parent class' code, if you prefer.

Providing Faked Values via stub

When testing, there are times where a method might be called in the source code, but you want to provide a faked — or stubbed — value for that method to return. Stubbing data is especially useful when you are interacting with databases or HTTP calls to external services where you only care about testing different scenarios and not about actually interacting with the service. Assume we have a Ticket class that asks about the ability to purchase a ticket that needs to check that a user has sufficient credit and then check with a third-party ticketing service to see if there is a ticket available:

class Ticket
  attr_accessor :user_has_credit?

  def ticket_available?
    # HTTP call to third-party ticketing service happens here. It returns true or false
  end
  
  def can_purchase?
    user_has_credit? and ticket_available?
  end
end

If we would like to test different scenarios without ever actually connecting to the third-party ticketing service, we can use the stub method on a Ticket instance to provide fake values:

require "minitest/autorun"

class TestTicket < MiniTest::Test
  def setup
    @ticket = Ticket.new
    @ticket.user_has_credit? = true
  end

  def test_can_purchase_when_ticket_available
    @ticket.stub :ticket_available, true do
      assert @ticket.can_purchase?
    end
  end

  def test_cannot_purchase_when_ticket_unavailable
    @ticket.stub :ticket_available, false do
      refute @ticket.can_purchase?
    end
  end
end

The stub method accepts two arguments and a block. The first is the method that you want to stub, and it is passed as a Symbol. The second argument is what the call to the method should return; this is your fake data. Finally, within the block is where your stubbed method data will be valid, so this is where we place our relevant assertions. After the block has finished executing, your faked data disappears, and the method is returned to its previous state.

Spec Syntax

  • class -> describe
  • def setup -> before do
  • def test* -> describe and it
  • nested nastyness -> nested describe
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment