Testing is a software practice that, at first glance, can seem contradictory. Why write more code just to verify that our code works? Isn’t that duplicating the work?
Imagine you’re building an e-commerce application. You’ve implemented a discount system that applies different percentages based on the purchase amount and customer type. It works perfectly. Weeks later, your boss asks you to add a special holiday promotion. You modify the code, deploy to production, and surprise: now VIP customers are receiving incorrect discounts, some customers are getting double discounts, and others aren’t receiving any discount when they should. Your change broke something that worked before, and you didn’t realize it until the complaints started rolling in.
This is where testing shines. Tests are additional code whose sole purpose is to exercise your main code and verify that it behaves exactly as you expect. If you had tests for the discount system, when you made the change for the promotions, those tests would have failed immediately, alerting you to the problem before it reached your customers. Tests give you confidence. They allow you to change code without fear because you know that if you break something, the tests will tell you.
In modern web application development, especially in the Ruby world, testing isn’t optional. It’s a fundamental practice that separates amateur code from professional code. But even among experienced programmers, if you ask ten different people how to write good tests, you’ll probably get ten different answers. Fortunately, there are fundamental concepts that everyone shares, and those are what we’ll explore in this article.
We’ll start from the very basics: writing tests manually without any external tools. This might seem unnecessary when testing libraries exist, but understanding the fundamentals will help you grasp what those libraries do for you and why they do it that way. We’ll then progress to the most widely used libraries in the Ruby community: Minitest and RSpec. Finally, we’ll also look at how to test web applications built with Rack.
Our First Test: Verifying with Output
Let’s start with something concrete and practical. Suppose you need to implement a discount system for an online store. The business rules are specific but not trivially simple:
- Purchases under 100€ receive no discount
 - Purchases of 100€ or more receive a 10% discount
 - Purchases of 500€ or more receive a 20% discount
 - VIP customers receive an additional 5% on top of any discount they already have
 
These rules with exceptions and special cases make it easy to make mistakes. Let’s write the implementation in a Ruby module:
module DiscountCalculator  def calculate_discount(amount, vip: false)    discount = 0
    discount = 10 if amount >= 100    discount = 20 if amount >= 500    discount += 5 if vip
    discount  endendThis implementation looks correct, but how can you be sure? You need to verify it with concrete cases. The most direct way is to simply add code to the end of the file that runs the function with different amounts and displays the results:
module DiscountCalculator  module_function  # ...end
puts "50€: #{DiscountCalculator.calculate_discount(50)}%"puts "100€: #{DiscountCalculator.calculate_discount(100)}%"puts "500€: #{DiscountCalculator.calculate_discount(500)}%"puts "100€ VIP: #{DiscountCalculator.calculate_discount(100, vip: true)}%"You save the changes and run the file with ruby discount_calculator.rb. You’ll see something like this in your terminal:
50€: 0%100€: 10%500€: 20%100€ VIP: 15%Now you have to use your knowledge of the business rules to mentally verify that these results are correct:
- A 50€ purchase doesn’t meet the 100€ threshold, so no discount. ✅
 - A 100€ purchase gets exactly 10%. ✅
 - A 500€ purchase gets 20%. ✅
 - And a VIP customer with a 100€ purchase gets the 10% base plus 5% additional, totaling 15%. ✅
 
This approach works and is likely one you’ve used many times while learning to program. For small exercises and code you’ll only write once, it’s perfectly adequate. But it has significant limitations that become apparent when working on real projects.
The first limitation is that this verification code will run every time someone requires this file. Imagine you’re building a larger application and need to use the calculate_discount function elsewhere. You’d create another file, say order_processor.rb, that would require_relative 'discount_calculator' to get access to the function. But when you run it, you’ll see those four lines of output again. This is not only annoying but can be confusing. If your application requires the file in multiple places, you’d see the output multiple times. In a real application with dozens of files, your terminal would fill up with junk you’re not interested in.
The second limitation is that you have to visually inspect the output and mentally verify that it’s correct. This works with four test cases, but what if you need to test twenty cases? Or a hundred? In a real store, you might have tiered discounts, temporary promotions, discounts by product category, combinations of offers… Manually inspecting every result would be impossible.
We need a better solution, and that brings us to the next step in our testing evolution.
Separating the Test Code
The fundamental problem we have is that our test code is mixed with our production code. What we want is a way to say: “Only run this test code when I explicitly ask for it, not when someone just wants to use my function”.
Ruby provides an elegant way to achieve this using two special variables that are not well-known but are extremely useful. These variables are $PROGRAM_NAME and __FILE__.
- The 
$PROGRAM_NAMEvariable is a global variable in Ruby, as you can see from the dollar sign at the beginning. This variable contains the name of the file that Ruby is executing directly, i.e., the file you specified on the command line. For example, if you runruby discount_calculator.rb, then$PROGRAM_NAMEwill contain the string"discount_calculator.rb". - The 
__FILE__variable is available in every Ruby file and always contains the name of the current file. What’s interesting is that this variable is local to each file. If you have a filediscount_calculator.rbthat defines__FILE__, and that file is required by another fileorder_processor.rb, then insidediscount_calculator.rb,__FILE__will still be"discount_calculator.rb", but$PROGRAM_NAMEwill be"order_processor.rb"because that’s the file you’re running. 
This gives us a way to detect if our file is being run directly or is being required by another file. If $PROGRAM_NAME and __FILE__ are equal, it means we are running this file directly. If they are different, it means someone else is requiring our code. We can use this information to run our tests only in the first case:
module DiscountCalculator  # ...end
if $PROGRAM_NAME == __FILE__  puts "50€: #{DiscountCalculator.calculate_discount(50)}%"  puts "100€: #{DiscountCalculator.calculate_discount(100)}%"  puts "500€: #{DiscountCalculator.calculate_discount(500)}%"  puts "100€ VIP: #{DiscountCalculator.calculate_discount(100, vip: true)}%"endThis is a significant improvement. Now, when you run ruby discount_calculator.rb directly, you see your test output. But when another file does require_relative 'discount_calculator', it only loads the module definition and nothing else. The tests don’t run and don’t clutter your output.
You can test this easily from the command line without having to create additional files. Ruby allows you to execute code directly with the -e option:
ruby -I . -r discount_calculator.rb -e "puts DiscountCalculator.calculate_discount(250)"Wait, what do all those options mean? Let’s break them down because it’s important to understand what’s happening here. The -I . option tells Ruby to include the current directory in its file search path. The -r discount_calculator.rb option tells Ruby to require the discount_calculator.rb file. And finally, -e "..." tells Ruby to execute that code directly.
If you try it, you’ll notice you don’t see the four lines of test output. You only see the result of your code (in this case, 10 because 250€ qualifies for the 10% discount). We’ve succeeded in separating our production code from our test code.
This pattern using $PROGRAM_NAME == __FILE__ was very common in the early days of Ruby. In fact, if you explore the Ruby source code itself or older libraries, you’ll find this pattern in many places.
Although we have better tools today, understanding this pattern helps you understand how testing in Ruby evolved.
But we still have the second problem: we’re manually inspecting the output. Let’s solve that next.
Computed Tests: Letting the Computer Do the Work
Think about what we’re doing when we inspect the output manually. We are comparing each value we see with the value we expected in our head. For 50€, we see 0% and think “yes, that’s correct because it doesn’t meet the 100€ threshold”. For 100€ VIP, we see 15% and think “yes, correct: 10% base plus 5% VIP”. And so on.
This mental process of comparing the actual value with the expected value is mechanical and repetitive, which should be the computer’s job, so let’s get to it.
The idea is simple: for each test case, we know beforehand what result we expect. We can encode these expectations into our program and have the program itself check if the results match. Let’s restructure our code to do exactly that:
module DiscountCalculator  # ...end
if $PROGRAM_NAME == __FILE__  test_cases = {    [50, false] => 0,    [100, false] => 10,    [500, false] => 20,    [100, true] => 15,    [500, true] => 25  }
  test_cases.each do |(amount, vip), expected|    actual = DiscountCalculator.calculate_discount(amount, vip: vip)    vip_label = vip ? ' (VIP)' : ''
    if expected == actual      puts "✓ #{amount}€#{vip_label}: #{actual}% as expected."    else      puts "✗ FAILED! #{amount}€#{vip_label} should return #{expected}%, but returned #{actual}%."    end  endend- We’ve created a hash called 
test_caseswhere the keys are arrays with the input parameters (amount and whether it’s VIP) and the values are the expected discounts. This is an explicit representation of our knowledge of the business rules. - Then we iterate over each test case. For each combination of parameters, we call our 
calculate_discountfunction and save the result in a variable calledactual. This name is important and is a convention you’ll see time and time again in testing code:actualrepresents the value your code actually produces, whileexpectedrepresents the value it should produce. - Afterward, we compare these two values. If they match, everything is fine, and we show a success message with a checkmark. If they don’t match, something is wrong, and we show a detailed error message indicating what we expected and what we actually got.
 
When you run this code with the correct implementation, you’ll see:
✓ 50€: 0% as expected.✓ 100€: 10% as expected.✓ 500€: 20% as expected.✓ 100€ (VIP): 15% as expected.✓ 500€ (VIP): 25% as expected.With a quick glance, we can see that all tests pass. But the real value shows up when something goes wrong. Let’s intentionally introduce an error into our function to see what happens. Comment out the line that adds the VIP discount:
module DiscountCalculator  module_function
  def calculate_discount(amount, vip: false)    discount = 0
    discount = 10 if amount >= 100    discount = 20 if amount >= 500    # discount += 5 if vip
    discount  endendNow when you run the code, you’ll see:
✓ 50€: 0% as expected.✓ 100€: 10% as expected.✓ 500€: 20% as expected.✗ FAILED! 100€ (VIP) should return 15%, but returned 10%.✗ FAILED! 500€ (VIP) should return 25%, but returned 20%.You can immediately see that two tests fail, and exactly which ones they are. You don’t have to inspect each line carefully or remember what each case was supposed to return. The code tells you explicitly. Even with a thousand tests, if one fails, you’ll see it immediately among all the checkmarks.
We’ve taken an important step here. We’ve automated the verification of our code. But we can still improve the organization of our test code. Let’s do that in the next step.
Assertions: Building Reusable Blocks
If you look at the code we just wrote, you’ll notice that the pattern of comparing an expected value with an actual value is something that will be repeated a lot. In fact, this pattern is so fundamental to testing that it has a special name: an assertion.
When you write a test, you are making assertions about your code. You are saying “I assert that when I call this function with these parameters, I will get this result”. The comparison between the expected value and the actual value is the way to verify whether your assertion is correct or not.
Since this pattern is so common, it makes sense to extract it into a reusable method. Let’s create a method called assert_equal that encapsulates this logic:
def assert_equal(expected, actual, description)  if expected == actual    puts "✓ #{description}"  else    puts "✗ FAILED! #{description}"    puts "  Expected: #{expected}"    puts "  Got: #{actual}"  endendThis method takes three parameters. The first is the expected value, the second is the actual value, and the third is a description of what we’re testing. The description is important because when a test fails, you need to know exactly what that test was trying to verify.
Notice that I’ve structured the error message over multiple lines. When a test fails, seeing exactly what you expected and what you got is crucial for understanding what went wrong. I’ve indented the “Expected” and “Got” lines to make it visually clear that they are details of the failure.
Now we can rewrite our tests to use this method:
module DiscountCalculator  # ...end
def assert_equal(expected, actual, description)  # ...end
if $PROGRAM_NAME == __FILE__  test_cases = {    # ...  }
  test_cases.each do |(amount, vip), expected|    actual = DiscountCalculator.calculate_discount(amount, vip: vip)    vip_label = vip ? ' (VIP)' : ''
    assert_equal(expected, actual, "Discount for #{amount}€#{vip_label}")  endendThis code is cleaner and more expressive. The intent of each line is clearer. When you read the assert, you immediately understand that we are asserting that the actual value must be equal to the expected value.
Furthermore, now that we’ve extracted this logic into a method, we could easily move this method to a separate file and reuse it in different test suites. For example, you could create a test_helpers.rb file that contains assert_equal and other utility methods, and then require it in all your test files.
This is exactly the type of abstraction that testing libraries provide. In fact, we are building the fundamental blocks of a real testing framework. But before we continue improving our testing infrastructure, let’s take a step back and understand an important pattern you’ll see in almost every test you write.
The Three Stages of a Test
There’s a pattern that emerges naturally when you write tests, and it’s so common that it’s worth naming explicitly. Most tests, regardless of what they’re testing, follow a three-stage structure.
To illustrate this pattern, let’s create a more interesting class than just a function. Imagine you have an Order class that represents an order in your online store. Each order has an amount and can have an associated VIP customer. We want to add methods that calculate the discount and the final price:
require_relative 'discount_calculator'
class Order  include DiscountCalculator
  attr_reader :amount, :customer_vip
  def initialize(amount, customer_vip: false)    @amount = amount    @customer_vip = customer_vip  end
  def discount_percentage    calculate_discount(amount, vip: customer_vip)  end
  def final_amount    amount * (1 - discount_percentage / 100.0)  endendNote that we’ve included our DiscountCalculator module in the class to have access to the calculate_discount function. The discount_percentage method simply delegates to the calculator with the appropriate parameters. The final_amount method applies the discount to the original amount to get the final price the customer will pay.
Now, how do we test these methods? A test for this class would naturally follow these three stages:
First stage: Setup
In this stage, we prepare everything we need to run the test. In our case, we need to create an instance of Order with a specific amount and possibly mark it as VIP:
order = Order.new(150, customer_vip: false)The setup stage is like setting the stage before a play. You are putting all the elements in their place before the action begins.
Second stage: Execution
In this stage, we execute the code we actually want to test. We call the method we’re interested in and capture its result:
result = order.discount_percentageThis is the main action of the test. It’s the moment where your code does its job.
Third stage: Assertion
Finally, we verify that the result is what we expected:
assert_equal(10, result, "Order of 150€ should get 10% discount")You are comparing what you got with what you expected to get.
Let’s see what a complete test would look like following these three stages:
if $PROGRAM_NAME == __FILE__  # Setup  order = Order.new(150, customer_vip: false)
  # Execution  result = order.discount_percentage
  # Assertion  assert_equal(10, result, "Order of 150€ should get 10% discount")endOften these stages aren’t marked so explicitly and the code is more compact, but the structure is there:
if $PROGRAM_NAME == __FILE__  order = Order.new(150, customer_vip: false)  # Setup  assert_equal(10, order.discount_percentage, "Order of 150€")  # Execution + AssertionendRecognizing these three stages is useful because it helps you structure your tests clearly. In fact, when they are clearly defined, the test is easier to understand and maintain.
In a real web application, the setup stage might involve creating several products in the shopping cart, applying discount coupons, and setting up the customer’s account. The execution stage would be processing the order. And the assertion stage would verify that the discounts were applied correctly, that the inventory was updated, that a confirmation email was sent, etc.
Keep this pattern in mind as we continue to build our testing library. You’ll see it appear time and time again.
Building Our Own Testing Library
So far we’ve been writing our tests as loose code inside an if $PROGRAM_NAME == __FILE__ block. This works, but as your project grows and you have more files with more tests, you need a better way to organize everything.
The idea is to create a base class that provides all the testing infrastructure, and then create subclasses of it for each set of tests we want to write. This will give us several benefits: clear organization, code reuse, and the ability to run all our tests automatically.
Let’s build this step-by-step to understand exactly what’s happening. First, let’s think about what we’d want the code to look like when it’s finished. We want to be able to write something like this:
class OrderTest < Test  def test_no_discount_for_small_purchase    order = Order.new(50, customer_vip: false)    assert_equal(0, order.discount_percentage)  end
  def test_ten_percent_discount_for_medium_purchase    order = Order.new(150, customer_vip: false)    assert_equal(10, order.discount_percentage)  end
  def test_twenty_percent_discount_for_large_purchase    order = Order.new(600, customer_vip: false)    assert_equal(20, order.discount_percentage)  end
  def test_vip_gets_additional_discount    order = Order.new(150, customer_vip: true)    assert_equal(15, order.discount_percentage)  endendNotice several important aspects of this design:
- We are using a class called 
OrderTestthat inherits from a base class calledTest. - Each individual test is a method in the class, and by convention, each test method starts with 
test_. This allows us to easily distinguish test methods from other utility methods we might define. - The method names are descriptive and expressive, clearly communicating what each test is testing.
 
Now we need to implement the Test class that makes all this work. This class needs to do several things:
- Find all methods that are tests.
 - Run each of those methods.
 - Provide the 
assert_equalassertion method. 
Let’s start with the basics:
class Test  def run    # Get all methods defined in this instance    test_methods = methods.select { |method| method.to_s.start_with?('test_') }
    # Run each test method    test_methods.each { |test| send(test) }  end
  def assert_equal(expected, actual)    if expected == actual      puts "✓ #{actual} is #{expected} as expected."    else      puts "✗ FAILED! Expected #{expected}, but got #{actual}."    end  endendFirst, let’s look at the run method. This method is the heart of our library. When you call run on a test instance, the first thing it does is get a list of all available methods in that instance using the methods method that Ruby provides automatically to all objects. This method returns an array with the names of all methods, including those inherited from parent classes.
Then we filter that array to keep only the methods whose names start with test_. We use select for this. Then, for each method, we convert its name to a string (because methods returns symbols) and check if it starts with test_.
Once we have the list of test methods, we iterate over them and run each one using send.
To use our new library, we would write:
require_relative 'test'require_relative 'order'
class OrderTest < Test  # ...end
# We still need to instantiate and run manuallytest = OrderTest.newtest.runYou can now run your new test suite this way: ruby order_test.rb
The output will be:
✓ 20 is 20 as expected.✓ 15 is 15 as expected.✓ 0 is 0 as expected.✓ 10 is 10 as expected.Identifying Failing Tests
There’s a problem we need to solve. When you run the tests above, the output doesn’t say what exactly is being tested. It just says things like ”✓ 0 is 0 as expected”, which isn’t very informative. If you have dozens of tests and one fails, how do you know which one it was?
We need our messages to include the name of the test method that is running. But how can the assert_equal method know which test method called it? This is where Ruby gives us a powerful tool: the caller method.
The caller method returns what’s called a backtrace or stack trace: a list of all method calls that led to the current point in the code. It’s the same type of information you see when there’s an error in your program. Each line in the backtrace tells you which method called which method, in which file, and on which line.
We can use caller to search that list of calls until we find the line that corresponds to our test method. We know our test methods always start with test_, so we can look for that in our assert_equal method:
def assert_equal(expected, actual)  # Search the backtrace for the line containing 'test_'  line = caller.find { |line| line.include?('test_') }
  # Extract the method name using a regular expression  method = line =~ /(test_[^']+)'/ && Regexp.last_match(1)
  if expected == actual    puts "#{method}: #{actual} is #{expected} as expected."  else    puts "FAILED! #{method}: Expected #{expected}, but got #{actual}."  endendNotice that the line line =~ /(test_[^']+)'/ && Regexp.last_match(1) uses the =~ operator to match a regular expression. The regular expression /(test_[^']+)'/ means “find a part of the string that starts with test_, then capture any character that is not a single quote, until you find a single quote”. The parentheses in the regular expression create what’s called a “capture group”, which saves that part of the string that matched.
When a regular expression matches and has capture groups, Ruby automatically populates some special variables. Regexp.last_match(1) returns the first capture group, which in our case will be the full method name, like test_no_discount_for_small_purchase.
The && operator in this case returns the value of its right-hand side if the left-hand side is truthy. Therefore, if the regular expression matches, line =~ /(test_[^']+)' returns a number (the position of the match), which is truthy, and then we evaluate and return Regexp.last_match(1). If there’s no match, we return nil.
With this change, now when a test fails, you’ll see exactly which test method caused the failure:
FAILED! test_ten_percent_discount_for_medium_purchase: Expected 10, but got 0.This is much more useful. Now you can go directly to that test method to see what’s wrong.
Automatic Test Execution
Currently, after defining our OrderTest class, we need to manually write:
test = OrderTest.newtest.runThis isn’t terrible, but if you have many test classes, you’d have to repeat these lines for each one. It would be much more convenient if Ruby simply ran all the tests automatically when it finishes loading all the files.
To achieve this, we need two pieces:
- A way to track all classes that inherit from 
Test - A way to execute code just before the program exits.
 
Let’s start with tracking subclasses. Ruby has a special hook called inherited that is automatically called every time a class inherits from another. This hook receives the child class as a parameter. We can use this to maintain a list of all our test classes:
class Test  class << self    def inherited(subclass)      subclasses << subclass      super    end
    def subclasses      @subclasses ||= []    end  end
  # ...endclass << self allows us to define class methods instead of instance methods. For example, Test.subclasses instead of test.subclasses.
The inherited method is one of these class methods. Ruby calls it automatically when a class inherits from Test, passing the subclass as an argument. All we do is add that subclass to our array. The super at the end ensures that if the Test class itself inherited from another class, that class’s inherited hook would also be called.
The subclasses method is a simple getter that returns our array of subclasses. The line @subclasses ||= [] is a common Ruby pattern for lazy initialization: if @subclasses is nil, initialize it to an empty array; otherwise, return its current value. This is known as “memoization”.
After tracking all the subclasses, we need to run them automatically. Ruby provides a special method for this called at_exit. This method allows you to register a block of code that will be executed just before the program exits:
at_exit do  Test.subclasses.each do |subclass|    test = subclass.new    test.run  endendThis code iterates over all the Test subclasses we’ve registered, creates an instance of each one, and calls run on it. Because this block was registered with at_exit, it will run automatically at the end, without us having to call it explicitly.
With these changes, we’ve created a fully functional testing library. Here is the complete code for our test.rb library:
class Test  class << self    def inherited(subclass)      subclasses << subclass      super    end
    def subclasses      @subclasses ||= []    end  end
  def run    methods.select { |method| method.to_s.start_with?('test_') }.each { |test| send(test) }  end
  def assert_equal(expected, actual)    line = caller.find { |line| line.include?('test_') }    method = line =~ /(test_[^']+)'/ && Regexp.last_match(1)
    if expected == actual      puts "#{method}: #{actual} is #{expected} as expected."    else      puts "FAILED! #{method}: Expected #{expected}, but got #{actual}."    end  endend
at_exit do  Test.subclasses.each do |subclass|    test = subclass.new    test.run  endendAnd now our order_test.rb test file is clean and simple:
require_relative 'test'require_relative 'order'
class OrderTest < Test  def test_no_discount_for_small_purchase    order = Order.new(50, customer_vip: false)    assert_equal(0, order.discount_percentage)  end
  def test_ten_percent_discount_at_threshold    order = Order.new(100, customer_vip: false)    assert_equal(10, order.discount_percentage)  end
  def test_twenty_percent_discount_for_large_purchase    order = Order.new(500, customer_vip: false)    assert_equal(20, order.discount_percentage)  end
  def test_vip_gets_additional_five_percent    order = Order.new(100, customer_vip: true)    assert_equal(15, order.discount_percentage)  endendNow run ruby order_test.rb and you’ll see:
test_no_discount_for_small_purchase: 0 is 0 as expected.test_ten_percent_discount_at_threshold: 10 is 10 as expected.test_twenty_percent_discount_for_large_purchase: 20 is 20 as expected.test_vip_gets_additional_five_percent: 15 is 15 as expected.We’ve built a functional testing library from scratch. This isn’t just an academic exercise. Understanding how these pieces work internally will help you enormously when you use popular libraries like Minitest and RSpec, because you’ll see that in essence, they are doing the same thing, just with many more features and polish.
Minitest: A Library Included with Ruby
Now that we understand the fundamentals of testing and have built our own library, it’s time to see how to use a real testing library. Minitest comes included with Ruby, so you don’t need to install anything extra. It is the successor to test/unit, which was the standard testing library in early versions of Ruby.
Minitest was developed by the Seattle Ruby community, and is known for its minimalist philosophy. The goal is to provide all the necessary tools to write effective tests without adding unnecessary complexity. It’s also very fast.
Minitest’s structure is very similar to that of the library we just built, which is no coincidence. Many testing libraries follow the same fundamental patterns. Minitest adds many features our simple library doesn’t have: better error formatting, more assertion types, exception handling, the ability to run tests in random order to detect dependencies between tests, and much more.
Let’s convert our tests to use Minitest. The transition will be natural because you already understand the underlying concepts. Here is the same set of tests rewritten for Minitest:
require 'minitest/autorun'require_relative 'order'
class OrderTest < Minitest::Test  def test_no_discount_for_small_purchase    order = Order.new(50, customer_vip: false)    assert_equal 0, order.discount_percentage  end
  def test_ten_percent_discount_at_threshold    order = Order.new(100, customer_vip: false)    assert_equal 10, order.discount_percentage  end
  def test_twenty_percent_discount_for_large_purchase    order = Order.new(500, customer_vip: false)    assert_equal 20, order.discount_percentage  end
  def test_vip_gets_additional_five_percent    order = Order.new(100, customer_vip: true)    assert_equal 15, order.discount_percentage  end
  def test_final_amount_applies_discount_correctly    order = Order.new(100, customer_vip: false)    assert_equal 90.0, order.final_amount  endendComparing this with our homemade library, there are some subtle but important differences:
- Instead of requiring a local file, we require 
minitest/autorun. Theautorunsuffix tells Minitest to automatically run the tests, similar to how we usedat_exitin our library. - Our class inherits from 
Minitest::Testinstead of justTest. - The description in 
assert_equalis optional. Minitest generates one automatically if you don’t provide it. 
When you run this file with ruby test_with_minitest.rb, you’ll see a completely different output from our homemade library:
Run options: --seed 42315
# Running:
.....
Finished in 0.000333s, 15015.0162 runs/s, 15015.0162 assertions/s.
5 runs, 5 assertions, 0 failures, 0 errors, 0 skipsThe Run options line tells you what options were used to run the tests. The --seed 42315 is particularly interesting: Minitest runs tests in random order by default, and the seed allows you to reproduce the exact same order if you need to debug a problem. That is, if a test fails only when run in a particular order, you can use the same seed to reproduce the problem.
The five dots represent the five tests you ran. Each dot means a test passed successfully. If a test fails, you’ll see an F instead. If there’s an unexpected error (like an exception you didn’t expect), you’ll see an E.
The statistics at the end are very useful. They tell you how many tests you ran, how many assertions you made, how many failed or had errors, and how many were skipped. There’s also performance information, like how many tests per second your computer can run. This last metric might seem curious, but when you have thousands of tests, performance matters.
Minitest comes with many more assertions besides assert_equal. Let’s see some useful ones for our discount system:
def test_discount_calculations  order = Order.new(100, customer_vip: false)
  # Verify that the discount is greater than zero  assert_operator order.discount_percentage, :>, 0
  # Verify that the final amount is less than the original  assert_operator order.final_amount, :<, order.amount
  # Verify the final amount is within an expected range  assert_in_delta 90.0, order.final_amount, 0.01end
def test_vip_status  vip_order = Order.new(100, customer_vip: true)  regular_order = Order.new(100, customer_vip: false)
  # Verify that an order is VIP  assert vip_order.customer_vip
  # Verify that an order is not VIP  refute regular_order.customer_vip
  # Verify that VIP receives a larger discount  assert_operator vip_order.discount_percentage, :>, regular_order.discount_percentageendMinitest provides assert_operator for comparisons with operators, assert_in_delta for floating-point number comparisons with a margin of error, and many more.
There are other very useful assertions as well:
assert_nilto check that something is nil.assert_includesto check that an array or hash contains an element.assert_raisesto check that a block of code raises a particular exception.
Each of these assertions has a refute_ counterpart. So you have refute_equal, refute_nil, refute_includes, etc. This consistency makes Minitest easy to learn: once you know a few assertions, you can guess the others.
Minitest also supports a setup method that runs before each test, similar to how you might want to initialize variables. For example:
class OrderTest < Minitest::Test  def setup    @regular_order = Order.new(150, customer_vip: false)    @vip_order = Order.new(150, customer_vip: true)  end
  def test_regular_customer_discount    assert_equal 10, @regular_order.discount_percentage  end
  def test_vip_customer_discount    assert_equal 15, @vip_order.discount_percentage  end
  def test_vip_pays_less_than_regular    assert_operator @vip_order.final_amount, :<, @regular_order.final_amount  endendThe setup method runs before each test, ensuring that each test starts with a clean state. This is crucial to prevent tests from affecting each other. So, if one test modifies @regular_order, that modification won’t affect other tests because each test gets a fresh instance. There is also a teardown method that runs after each test, useful for cleaning up resources like temporary files or database connections.
Minitest is an excellent choice for projects that value simplicity and performance. It’s powerful enough for large projects (Rails itself uses it in its test suite) but simple enough that you won’t feel overwhelmed. However, there’s another popular library in the Ruby world that adopts a completely different philosophy: RSpec.
RSpec: Testing as Specification
While Minitest focuses on being simple and direct, RSpec takes a different path. RSpec was created with the idea that tests are not just technical checks, but specifications of your application’s behavior. This philosophical difference is reflected in every aspect of RSpec’s design.
The central idea of RSpec is that you should write tests that read like natural language documentation. Instead of writing methods called test_something, you write blocks that say “it does something”. Instead of using assertions like assert_equal, you use expectations that say “expect this to equal that”. The goal is for anyone, even someone who doesn’t program, to be able to read your tests and understand what your code is supposed to do.
RSpec also introduces the concept of a DSL (Domain Specific Language). A DSL is a mini-language designed for a specific purpose, so RSpec is a DSL for writing tests, providing methods like describe, context, it, expect, etc.
This means that, although you are writing Ruby code, RSpec adds its own layer of abstraction. You’ll need to learn this new syntax and understand how it works.
Installing RSpec
Unlike Minitest, RSpec doesn’t come with Ruby, and you need to install it explicitly.
Create a Gemfile and add RSpec with these commands:
bundle initbundle add rspecBundler will download RSpec and all its dependencies. Now you’re ready to write your first test with RSpec.
First Tests with RSpec
RSpec has an important convention: your test files must end in _spec.rb. This isn’t just a stylistic suggestion; many tools related to RSpec specifically look for files with this suffix. The word “spec” comes from “specification”, reflecting RSpec’s philosophy that you are specifying your code’s behavior, not just testing it.
Let’s create a file named order_spec.rb with a simple test for our Order class:
require_relative 'order'
describe Order do  it 'applies no discount for purchases under 100 euros' do    order = Order.new(75, customer_vip: false)    expect(order.discount_percentage).to eq 0  endendBefore running this code, let’s dissect it line by line.
On the first line, we’re loading the Order class we want to test.
The second line, describe Order do, is where the RSpec syntax begins. You’re telling RSpec: “I’m going to describe the behavior of the Order class”. The do...end block contains the entire specification for that class. You can think of describe as RSpec’s way of defining a context or a group of related tests.
Inside the describe block, we use it to define an individual test (or “example” in RSpec terminology). The string that follows it is a description of what you’re verifying. Read the full line out loud: “describe Order, it applies no discount for purchases under 100 euros”. It sounds like an English sentence, right? That’s exactly the point.
Inside the it block, we have the actual test. Note that we follow the three stages we discussed earlier: setup (create the order), execution (call the method), and assertion. But RSpec doesn’t use the term “assertion”, it uses “expectation”. The line expect(order.discount_percentage).to eq 0 reads almost like English: “expect order.discount_percentage to equal 0”.
To run this test, you use the rspec command:
rspec order_spec.rbYou’ll see something like this:
.
Finished in 0.00355 seconds (files took 0.07036 seconds to load)1 example, 0 failuresThe dot represents a test that passed.
Now, let’s intentionally introduce an error to see what happens when a test fails. Temporarily modify the Order class so the discount_percentage method always returns 5:
def discount_percentage  5  # Deliberate errorendRun RSpec again and you’ll see:
F
Failures:
  1) Order applies no discount for purchases under 100 euros     Failure/Error: expect(order.discount_percentage).to eq 0
       expected: 0            got: 5
       (compared using ==)     # ./order_spec.rb:5:in `block (2 levels) in <top (required)>'
Finished in 0.00655 seconds (files took 0.04388 seconds to load)1 example, 1 failure
Failed examples:
rspec ./order_spec.rb:3 # Order applies no discount for purchases under 100 eurosThis output is considerably more detailed and useful than what we saw in Minitest. RSpec shows you exactly which expectation failed, what you expected to get, what you actually got, and even gives you the exact command to re-run only that failing test. This attention to detail in error messages is one of the reasons many developers love RSpec.
You can now restore the discount_percentage method.
Structure with describe and context
RSpec allows you to organize your tests into a clear hierarchy using describe and context. This is particularly useful when you have many tests for one class and want to group them logically. Let’s expand our tests to cover different discount scenarios:
require_relative 'order'
describe Order do  context 'when amount is below 100 euros' do    it 'applies no discount' do      order = Order.new(75, customer_vip: false)      expect(order.discount_percentage).to eq 0    end
    it 'calculates final amount without discount' do      order = Order.new(75, customer_vip: false)      expect(order.final_amount).to eq 75.0    end  end
  context 'when amount is 100 euros or more' do    it 'applies 10% discount' do      order = Order.new(150, customer_vip: false)      expect(order.discount_percentage).to eq 10    end
    it 'calculates final amount with discount' do      order = Order.new(100, customer_vip: false)      expect(order.final_amount).to eq 90.0    end  end
  context 'when customer is VIP' do    it 'adds 5% to the base discount' do      order = Order.new(150, customer_vip: true)      expect(order.discount_percentage).to eq 15    end  endendNow we have a clearer structure. Each context describes a specific situation or condition, and within that context, the it blocks specify what behavior you expect in that situation.
Technically, describe and context are interchangeable. They both do the same thing under the hood. But there is a strong convention in the RSpec community: use describe to denote the object or method you’re testing, and use context to denote specific situations or states. This convention makes your tests easier to read and understand.
You can nest context within describe, and describe within context, as deeply as makes sense. For example:
describe Order do  describe '#discount_percentage' do    context 'for regular customers' do      context 'with small purchases' do        it 'returns 0' do          # test here        end      end
      context 'with medium purchases' do        it 'returns 10' do          # test here        end      end    end
    context 'for VIP customers' do      # more tests    end  endendThis hierarchy is reflected in the output when you run the tests with the documentation format. Run:
rspec --format doc order_spec.rbYou’ll see:
Order  when amount is below 100 euros    applies no discount    calculates final amount without discount  when amount is 100 euros or more    applies 10% discount    calculates final amount with discount  when customer is VIP    adds 5% to the base discount
Finished in 0.00231 seconds (files took 0.07806 seconds to load)5 examples, 0 failuresYour tests now read like structured documentation that explains your class’s behavior. Someone new to your project could read this and immediately understand how the discount system works without even looking at the implementation code.
Matchers: The Heart of RSpec
We’ve been using expect(...).to eq ... in our tests, but we haven’t really explained what’s happening here. This is one of RSpec’s core features, so it’s worth understanding in depth.
In RSpec, you make assertions about your code using “expectations”. The basic structure is:
expect(actual_value).to matcher(expected_value)This reads naturally: “expect actual_value to match expected_value using this particular matcher”. A matcher is simply an object that knows how to compare values in a specific way.
The most basic matcher is eq, which checks for simple equality. When you write:
expect(10).to eq 10RSpec takes the value 10 (the actual value), passes it to expect, which returns a special object. You then call to on that object, passing the eq(10) matcher. The eq(10) matcher checks if the actual value is equal to 10 using Ruby’s == operator.
But RSpec provides many more matchers beyond eq, each designed for a specific type of comparison. Let’s explore the most useful ones for our discount system:
Numeric Comparisons
order = Order.new(150, customer_vip: false)
expect(order.discount_percentage).to be > 0expect(order.discount_percentage).to be < 20expect(order.discount_percentage).to be >= 10expect(order.discount_percentage).to be <= 10
# Verify the final price is less than the originalexpect(order.final_amount).to be < order.amountThese matchers use Ruby’s comparison operators. They are particularly useful when the exact value doesn’t matter as much as being in a range.
Inclusion and Ranges
# Verify the discount is within expected valuesexpect(order.discount_percentage).to be_between(0, 25).inclusive
# If we had a list of applied discountsdiscounts = [10, 5]expect(discounts).to include(10)expect(discounts).to include(10, 5)The include matcher is very versatile. It works with arrays, hashes, and strings. You can pass multiple values and it will check that all are present.
Truthiness and Nil
order = Order.new(100, customer_vip: true)
expect(order.customer_vip).to be trueexpect(order.customer_vip).to be_truthy  # any value except false or nil
regular_order = Order.new(100, customer_vip: false)
expect(regular_order.customer_vip).to be falseexpect(regular_order.customer_vip).to be_falseyIn Ruby, any value except false and nil is “truthy”. Sometimes you want to check for truthiness, other times you want to check for the exact value true or false. RSpec gives you matchers for both cases.
Types and Classes
order = Order.new(100, customer_vip: false)
expect(order).to be_a(Order)expect(order).to be_an_instance_of(Order)expect(order.discount_percentage).to be_a(Integer)expect(order.final_amount).to be_a(Float)These matchers check types. be_a and be_kind_of check if an object is an instance of a class or any of its subclasses. be_an_instance_of is stricter and only passes if it’s exactly that class.
State Changes
# If we had a method that modifies the orderexpect { order.apply_extra_discount(5) }.to change { order.discount_percentage }.by(5)
# Or verify changes from/to specific valuesexpect { order.make_vip }.to change { order.customer_vip }.from(false).to(true)These matchers are particularly useful for testing side effects. They verify that executing certain code changes some state in a specific way.
Numeric Approximations
# For comparisons with floating-point numbersorder = Order.new(333.33, customer_vip: false)
expect(order.final_amount).to be_within(0.01).of(300.0)This matcher is essential when working with floating-point numbers, which can have small inaccuracies due to how they are represented in the computer.
There are dozens more matchers in RSpec, and you can also create your own, as we’ll see later. The key is that matchers make your tests expressive and easy to read. Instead of remembering different assertion methods for different types of comparisons, you simply think about what kind of comparison you want to make and find the appropriate matcher.
Magic Matchers: Dynamic Predicates
One of RSpec’s cleverest and sometimes controversial features is “magic matchers”. These are matchers that RSpec generates dynamically based on the methods your objects define. It sounds complicated, but it’s simpler than it looks.
In Ruby, there’s a strong convention that methods that return a boolean should end with a question mark. For example, empty?, valid?, vip?, etc. These are called “predicate methods” because they predict or assert something about the object.
RSpec leverages this convention. If your object has a predicate method, RSpec can automatically create a matcher for it. The rule is simple: take the method name, remove the question mark, and prepend be_. So, if your object has a customer_vip? method, you could use the be_customer_vip matcher.
But wait, our Order doesn’t have a customer_vip? method; it has a customer_vip attribute. However, we could add a predicate method to make the code more expressive:
class Order  # ...
  def vip?    customer_vip  endendNow we can use the magic matcher:
order = Order.new(100, customer_vip: true)
expect(order).to be_vipThis is equivalent to:
expect(order.vip?).to eq trueBut it’s more concise and, to many, more expressive. Read it out loud: “expect order to be vip”. It’s almost natural English.
How does this work internally? Ruby has a feature called method_missing that allows objects to respond to method calls that technically don’t exist. When you call be_vip on the object that to returns, that object doesn’t have a method with that name. But it has a method_missing that intercepts the call, sees that it starts with be_, adds a ? to the rest of the name, and checks if the object under test responds to that predicate method.
This “magic” is an example of metaprogramming, a powerful Ruby technique where code can write or modify code at runtime. It’s one of the reasons RSpec can be so expressive.
For other common predicate methods:
# If we had more predicate methods in Orderdef discounted?  discount_percentage > 0end
def premium?  amount >= 500end
# You could use:expect(order).to be_discountedexpect(order).to be_premiumBe careful about one thing: if the predicate method doesn’t return exactly true or false, but a truthy or falsy value, the magic matcher might not work as you expect. In those cases, it’s better to be explicit:
# If the method can return nil instead of falseexpect(order.customer_vip).to eq false  # clearer thanexpect(order).not_to be_customer_vip    # which also passes if it returns nilNegating Matchers
Every matcher in RSpec has a way to negate it, to check the opposite. Instead of to, you use not_to:
order = Order.new(50, customer_vip: false)
expect(order.discount_percentage).to eq 0expect(order.discount_percentage).not_to be > 0expect(order).not_to be_vipto_not also exists as a synonym for not_to. They are completely interchangeable:
expect(order.discount_percentage).not_to eq 10expect(order.discount_percentage).to_not eq 10Negation works with all matchers:
small_order = Order.new(50, customer_vip: false)large_order = Order.new(600, customer_vip: false)
expect(small_order.discount_percentage).not_to be > 0expect(large_order.discount_percentage).not_to be < 10expect(small_order.final_amount).not_to be < small_order.amountA question that often comes up is: should you write tests for the negative case in addition to the positive one? For example, if you have a test that verifies a 150€ order gets a 10% discount, should you also have a test that verifies a 50€ order gets no discount? Generally yes, since verifying both cases gives you more confidence that your logic is correct. It’s easy to write code that always returns 10 or always returns 0. Only by testing multiple cases, including negative cases, can you be sure your code is really making correct decisions.
The Before Block: Setting the Stage
When we start writing multiple tests for the same class, we often find we’re duplicating setup code. Each test creates an Order object with certain parameters. This repetition is not only tedious to write, but it makes the tests harder to maintain. If you decide to change Order’s constructor to take different parameters, you’d have to update every single test.
RSpec provides the before method to solve this problem. A before block runs before each test in its context. This allows you to centralize the setup code:
describe Order do  before do    @order = Order.new(150, customer_vip: false)  end
  context 'for regular customers' do    it 'applies 10% discount' do      expect(@order.discount_percentage).to eq 10    end
    it 'calculates correct final amount' do      expect(@order.final_amount).to eq 135.0    end  endendNotice we use an instance variable @order instead of a local variable. This is necessary because the before block and the it blocks are different contexts. Local variables are not shared between them, but instance variables are.
Why does it run before each test instead of just once before all tests? This is an important design decision. If the before block ran only once and all tests shared the same Order instance, then one test could modify that object and affect subsequent tests. This would create dependencies between tests, which is something we absolutely want to avoid. Each test must be completely independent.
You can have multiple before blocks at different nesting levels, and they will all run in order from outermost to innermost:
describe Order do  before do    puts 'Outer setup'    @base_amount = 150  end
  context 'for regular customers' do    before do      puts 'Inner setup'      @order = Order.new(@base_amount, customer_vip: false)    end
    it 'has correct discount' do      puts 'Test execution'      expect(@order.discount_percentage).to eq 10    end  endendThis will print:
Outer setupInner setupTest executionThere is also after, which runs after each test, useful for cleanup:
describe Order do  before do    @temp_file = File.open('order_log.txt', 'w')  end
  after do    @temp_file.close    File.delete('order_log.txt')  end
  it 'logs order creation' do    order = Order.new(100, customer_vip: false)    @temp_file.write("Order created: #{order.amount}")    # The file will be closed and deleted automatically afterward  endendAlthough before is useful, RSpec provides an even better alternative that we’ll see next.
Let: Smart Lazy Evaluation
The before block works well, but it has one characteristic that is sometimes not ideal: it always runs, even if a particular test doesn’t use the variables it initializes. Also, using instance variables (@order) makes it less obvious where that data is coming from when you read a test.
RSpec provides an alternative called let that solves both problems. let defines a helper method you can call in your tests. The first time you call it, it runs the block and memoizes (saves) the result. Subsequent calls in the same test return the memoized value. But here’s the smart part: if a test never calls that helper, the block never runs. This is called “lazy evaluation”.
Let’s rewrite our example using let:
describe Order do  let(:order) { Order.new(150, customer_vip: false) }
  context 'for regular customers' do    it 'applies 10% discount' do      expect(order.discount_percentage).to eq 10    end
    it 'calculates correct final amount' do      expect(order.final_amount).to eq 135.0    end  endendNotice the syntax: let(:order) defines a method called order. The braces {} contain the block that defines what that method returns. Inside the tests, you simply call order as if it were a local variable, but technically it’s a method.
This has several advantages over before:
- It’s more explicit: When you read 
expect(order.discount_percentage).to eq 10, it’s clear thatorderis something special defined bylet. With instance variables, you have to scan upwards to find thebeforeblock. - It’s more efficient: If you define multiple things with 
letbut a test only uses some of them, only those are initialized. - It’s cleaner: You don’t need instance variables, which makes the code less verbose.
 - It’s memoized: Within the same test, multiple calls to 
orderreturn the same instance, which is usually what you want. 
A powerful feature of let is that you can have one let depend on another:
describe Order do  let(:amount) { 150 }  let(:vip) { false }  let(:order) { Order.new(amount, customer_vip: vip) }
  context 'for regular customers' do    it 'applies 10% discount' do      expect(order.discount_percentage).to eq 10    end  end
  context 'for VIP customers' do    let(:vip) { true }
    it 'applies 15% discount' do      expect(order.discount_percentage).to eq 15    end  endendThis is elegant. We define order once at the top level, but it uses amount and vip which can be redefined in inner contexts. When we’re in the first context, vip is false. When we’re in the second context, vip is redefined as true. The order is created with the appropriate value depending on the context.
This ability to redefine values in nested contexts makes let extremely flexible for setting up different test scenarios without repeating code.
There is a variant of let called let! (with an exclamation mark) that is not lazy. It behaves more like before, always running before each test, regardless of whether it’s used or not:
let!(:order) { Order.new(150, customer_vip: false) }This is useful in situations where you need something to run for its side effects, not for its return value. For example, inserting records into a test database that other objects need to exist.
Subject: The Object Under Test
When you write tests for a class, there’s usually one object that is the main focus of your tests. In our examples, that object is an instance of Order. This object is so important that RSpec has a special concept for it: the subject.
subject is like let, but it semantically indicates “this is the main object I’m testing”. Here’s what it looks like:
describe Order do  subject { Order.new(amount, customer_vip: vip) }  let(:amount) { 150 }  let(:vip) { false }
  context "for regular customers" do    it "applies 10% discount" do      expect(subject.discount_percentage).to eq 10    end  endendSo far, subject seems like just a different name for let. But subject unlocks some additional RSpec features that make tests even more concise. We’ll see that in the next section.
One-Line Tests: Extreme Conciseness
RSpec allows for a very compact syntax when you use subject. You can write single-line tests that are very expressive. But first, you need to install an additional (official RSpec) gem:
bundle add rspec-itsAnd now you can write the following:
require 'rspec/its'require_relative 'order'
describe Order do  subject { Order.new(amount, customer_vip: vip) }
  context 'with 50 euro purchase' do    let(:amount) { 50 }    let(:vip) { false }
    its(:discount_percentage) { should eq 0 }    its(:final_amount) { should eq 50.0 }  end
  context 'with 150 euro purchase' do    let(:amount) { 150 }    let(:vip) { false }
    its(:discount_percentage) { should eq 10 }    its(:final_amount) { should eq 135.0 }  end
  context 'for VIP with 150 euros' do    let(:amount) { 150 }    let(:vip) { true }
    its(:discount_percentage) { should eq 15 }    its(:final_amount) { should eq 127.5 }  endendThe its method is very convenient. its(:discount_percentage) is equivalent to expect(subject.discount_percentage).to. This syntax makes tests concise and easy to scan visually.
When you run this with --format doc, you’ll see:
Order  with 50 euro purchase    discount_percentage      should eq 0    final_amount      should eq 50.0  with 150 euro purchase    discount_percentage      should eq 10    final_amount      should eq 135.0  for VIP with 150 euros    discount_percentage      should eq 15    final_amount      should eq 127.5RSpec automatically generates readable descriptions based on the method names and matchers.
This one-line style is particularly popular for simple tests. It’s fast to write and easy to scan visually. However, there are situations where a one-line test is not appropriate:
- Complex tests: If your test needs multiple expectations or complex logic, a one-line test isn’t sufficient.
 - Complicated setup: If you need to prepare several objects or states before the verification.
 - Multiple assertions: Although 
itsallows writing multiple verifications for the same subject, sometimes it’s clearer to separate them into individual tests with explicit descriptions. 
Custom Matchers: Extending RSpec for Your Domain
One of RSpec’s most powerful features is the ability to define your own custom matchers. This allows you to create verifications specific to your application’s domain, making your tests even more expressive and reusable.
Imagine you frequently need to verify if an order has a valid discount applied. You could create a custom matcher for this:
require_relative '../order'
RSpec::Matchers.define(:have_valid_discount) do  match do |order|    order.discount_percentage >= 0 &&    order.discount_percentage <= 25 &&    order.final_amount < order.amount  end
  failure_message do |order|    "Expected order to have valid discount, but discount was #{order.discount_percentage}% " \    "and final amount (#{order.final_amount}) was not less than original (#{order.amount})"  endend
describe Order do  # ...endRSpec::Matchers.define is a method RSpec provides for defining new matchers. You pass it a symbol with the matcher’s name (in this case :have_valid_discount) and a block that defines how it works.
Inside the block, you call match and pass it another block. This inner block is the heart of the matcher. It receives the object under test (in our case, an Order) and must return true if the matcher should pass, or false if it should fail.
In our matcher, we check three conditions: that the discount is between 0% and 25% (our range of valid discounts), and that the final price is indeed less than the original.
The failure_message block is optional but very useful. A good error message can save a lot of debugging time.
Now in your tests, you can simply write:
it 'applies valid discount for medium purchase' do  order = Order.new(150, customer_vip: false)  expect(order).to have_valid_discountendThis is much more expressive than repeating all those verifications in every test.
Custom matchers really shine when you have complex verifications that are repeated. For example, you could create a matcher to check if an order meets the requirements for free shipping:
RSpec::Matchers.define(:qualify_for_free_shipping) do |minimum_amount|  match do |order|    order.final_amount >= minimum_amount  end
  failure_message do |order|    "Expected order with final amount #{order.final_amount} to qualify for free shipping " \    "(minimum: #{minimum_amount}), but it doesn't"  endendThis matcher accepts a parameter (the required minimum) and checks if the order qualifies:
it 'qualifies for free shipping' do  order = Order.new(500, customer_vip: false)
  expect(order).to qualify_for_free_shipping(400)endTo organize your custom matchers, it’s common to place them in separate files under a support/matchers/ directory. For example, you could create support/matchers/order_matchers.rb:
RSpec::Matchers.define(:have_valid_discount) do  match do |order|    order.discount_percentage >= 0 &&    order.discount_percentage <= 25 &&    order.final_amount < order.amount  end
  failure_message do |order|    "Expected order to have valid discount, but discount was #{order.discount_percentage}%"  endend
RSpec::Matchers.define(:qualify_for_free_shipping) do |minimum_amount|  match do |order|    order.final_amount >= minimum_amount  end
  failure_message do |order|    "Expected order with final amount #{order.final_amount} to qualify for free shipping " \      "(minimum: #{minimum_amount}), but it doesn't"  endendNext, create a spec_helper.rb file alongside your order_spec.rb file, and automatically load all support files:
Dir[File.join(__dir__, 'support', '**', '*.rb')].sort.each { |f| require f }This line finds all .rb files in the support directory and its subdirectories, and requires them. This means all your custom matchers, helpers, and other support code will be loaded automatically when you run your tests.
Now you must require the spec_helper.rb file:
require_relative '../order'require_relative 'spec_helper'
describe Order do  # ...endAnd now you can run your tests.
Custom matchers can make your tests incredibly expressive. Instead of:
it 'applies correct discount for VIP' do  order = Order.new(150, customer_vip: true)
  expect(order.discount_percentage).to eq 15  expect(order.final_amount).to eq 127.5endYou can add this custom matcher:
RSpec::Matchers.define(:have_discount) do |expected_percentage|  match do |order|    order.discount_percentage == expected_percentage  end
  failure_message do |order|    "Expected discount of #{expected_percentage}%, but got #{order.discount_percentage}%"  endendAnd rewrite the test like this:
it 'applies correct discount for VIP' do  order = Order.new(150, customer_vip: true)
  expect(order).to have_discount(15)endThis makes the test’s intention crystal clear and reduces visual noise.
Filtering Tests: Selective Execution
As your project grows, your test suite can take several minutes or even hours to run completely. During development, you usually don’t want to run all the tests every time you make a change. RSpec provides several ways to run only a subset of tests.
The most direct way is to specify a line number after the filename:
rspec order_spec.rb:15This will run only the test or group of tests that starts on line 15. If line 15 is an it block, it will run only that test. If line 15 is a context or describe block, it will run all tests within that block.
This feature is very useful during development. When you’re working on a specific feature, you can run only the relevant tests to get fast feedback. And when a test fails, RSpec shows you the exact command to re-run it:
Failures:
  1) Order with 150 euro purchase applies 10% discount     Failure/Error: expect(order.discount_percentage).to eq 10
       expected: 10            got: 0
     # ./order_spec.rb:18:in `block (3 levels) in <top (required)>'
Failed examples:
rspec ./order_spec.rb:16 # Order with 150 euro purchase applies 10% discountSimply copy the command rspec ./order_spec.rb:16 and paste it into your terminal to re-run that specific test.
You can also run multiple files or directories at once:
rspec spec/models/order_spec.rb spec/models/product_spec.rbrspec spec/models/  # runs all tests in the models directoryRSpec also supports filtering by tags. You can tag tests with metadata:
describe Order do  it "calculates discount quickly", :fast do    order = Order.new(100, customer_vip: false)    expect(order.discount_percentage).to eq 10  end
  it "processes complex discount rules", :slow do    # test that takes longer  end
  it "integrates with payment system", :integration do    # integration test  endendThen you can run only tests with certain tags:
rspec order_spec.rb --tag fast            # only fast testsrspec order_spec.rb --tag "~slow"         # all except slow onesrspec order_spec.rb --tag "~integration"  # exclude integration testsThis is useful for categorizing tests. During normal development, you might want to run only the fast tests to get immediate feedback. Or you might want to exclude integration tests and leave them for your continuous integration server, where you’d run them all.
You can also combine tags:
rspec order_spec.rb --tag fast --tag "~integration"  # fast tests but not integrationRSpec also offers more advanced features for iterative workflows, like --next-failure and --only-failures, which allow you to automatically re-run only the tests that failed in the previous run. These options are very useful when you’re working on fixing multiple failures one by one, but they require additional configuration in your spec_helper.rb to maintain a persistent record of the test status. If you’re interested in exploring these features, consult the official RSpec documentation on example status persistence.
Testing Rack Applications with Rack::Test
So far we’ve been testing pure Ruby code: classes, methods, and business logic. But in real web development, you also need to test your web applications themselves. This means making HTTP requests and verifying the responses. This is where Rack::Test comes into play.
Rack is the interface between Ruby web servers (like Puma, Unicorn, or Thin) and web frameworks (like Sinatra and Rails). A Rack application is simply an object that responds to the call method, which receives a hash called env with information about the HTTP request, and returns a three-element array: the status code, the headers, and the response body.
Rack::Test is a library that makes it easy to test Rack applications without needing a real web server. It provides methods to simulate HTTP requests and examine the responses.
A Simple Rack Application
Let’s start with a basic Rack application. This application responds to GET requests with a message indicating the requested path, and rejects other HTTP methods. Create the file rack/app.rb:
class Application  def call(env)    handle_request(env['REQUEST_METHOD'], env['PATH_INFO'])  end
  private
  def handle_request(method, path)    if method == 'GET'      get(path)    else      method_not_allowed(method)    end  end
  def get(path)    [200, { 'Content-Type' => 'text/html' }, ["You have requested the path #{path}, using GET"]]  end
  def method_not_allowed(method)    [405, {}, ["Method not allowed: #{method}"]]  endendLet’s understand this application. The call method is the entry point. Rack will call this method every time an HTTP request arrives. The env parameter is a hash containing all the information about the request: the HTTP method, the path, headers, query parameters, etc.
We extract two pieces of information from the env: the HTTP method (REQUEST_METHOD) and the path (PATH_INFO). Then we call handle_request with these values.
The handle_request method is a simple dispatcher. If the method is GET, we call get. Otherwise, we indicate that the method is not allowed.
The get method returns the three-element array that Rack expects. The first element is 200, the HTTP status code for success. The second element is a hash of headers; in this case, we indicate the content is HTML. The third element is an array (or any object that responds to each) containing the response body.
The method_not_allowed method is similar, but it returns a 405 status code (Method Not Allowed) and doesn’t include additional headers.
Manual Rack Testing
Before using Rack::Test, let’s see how we would test this application manually. We need to build the env hash ourselves and call the call method:
require_relative 'app'
describe Application do  context 'get to /some/path' do    let(:app) { Application.new }    let(:env) { { 'REQUEST_METHOD' => 'GET', 'PATH_INFO' => '/some/path' } }    let(:response) { app.call(env) }    let(:status) { response[0] }    let(:body) { response[2][0] }
    it 'returns status 200' do      expect(status).to eq 200    end
    it 'returns the correct body' do      expect(body).to eq 'You have requested the path /some/path, using GET'    end  endendInitialize Bundler in the directory and install the RSpec gem. Then you can call rspec to pass the tests:
bundle initbundle add rspecrspec app_spec.rbThe test passes correctly. We’re using let to organize our test. We create the env hash with the keys Rack expects, call app.call(env), and then inspect the response.
However, building the env hash correctly is tedious and error-prone. The env hash can have dozens of keys depending on what aspects of the HTTP request you want to simulate. Furthermore, the key names aren’t intuitive: REQUEST_METHOD instead of something like :method, PATH_INFO instead of :path, etc. This is where Rack::Test helps us enormously.
Using Rack::Test
First, we need to make sure Rack::Test is installed. Add the gem to the Gemfile:
bundle add rack-testNow create a spec_helper.rb to configure RSpec and Rack::Test:
require 'rack/test'
RSpec.configure do |config|  config.include Rack::Test::MethodsendThe line config.include Rack::Test::Methods makes Rack::Test’s methods available in all our tests. This means you can call methods like get, post, put, etc. directly in your tests.
Now we can rewrite our test using Rack::Test in app_spec.rb:
require_relative '../application'require_relative 'spec_helper'
describe Application do  let(:app) { Application.new }
  context 'get to /some/path' do    let(:response) { get '/some/path' }
    it { expect(response.status).to eq 200 }    it { expect(response.body).to eq 'You have requested the path /some/path, using GET' }  end
  context 'post to /' do    let(:response) { post '/' }
    it { expect(response.status).to eq 405 }    it { expect(response.body).to eq 'Method not allowed: POST' }  endendThis is much cleaner! Let’s see what’s happening here.
First, we define app with let. This is important: Rack::Test expects there to be an app method available that returns the Rack application you want to test. It’s a convention, and Rack::Test uses it to know which application to make requests to.
Then, instead of building the env hash manually, we simply call get '/some/path'. Rack::Test takes care of building the entire env hash correctly, calling the application’s call method with that env, and returning a response object.
The response object that get returns has methods like status and body that make it easy to inspect the response. You don’t have to remember that the status is response[0] and the body is response[2][0]. The methods have clear semantic names.
To test the POST case, we call post '/'.
Advanced Rack::Test Features
Rack::Test supports much more than just simple requests. You can send parameters:
get '/search', query: 'discount' # equivalent to GET /search?query=discount
post '/orders', amount: 100, vip: true # sends these parameters in the POST request bodyYou can set headers:
get '/api/orders', {}, { 'HTTP_AUTHORIZATION' => 'Bearer token123' }Note that header names in Rack are prefixed with HTTP_ and are uppercase. This is part of the Rack specification.
You can work with cookies. Rack::Test automatically maintains a cookie jar between requests, simulating a real browser’s behavior:
post '/login', username: 'alice', password: 'secret' # imagine a session cookie is set here
get '/dashboard' # this request automatically includes the session cookieYou can follow redirects:
post '/orders', amount: 500 # imagine this redirects to /orders/123
follow_redirect! # now last_response is the response from /orders/123The response object also has many useful methods:
response.successful?  # true if the status is in the 200-299 rangeresponse.redirect?    # true if the status is 301, 302, etc.response.headers      # hash of all response headersresponse.body         # the full body as a stringYou can also access the last request and response using last_request and last_response, which is useful when you need to make multiple requests in one test:
post '/orders', amount: 500, vip: trueorder_id = JSON.parse(last_response.body)['id']
get "/orders/#{order_id}"expect(last_response.body).to include('500')Testing a Complete Rack Application
Let’s write a more complete test suite for our application. Suppose we add more functionality to the application to support different routes:
class App  def call(env)    handle_request(env['REQUEST_METHOD'], env['PATH_INFO'])  end
  private
  def handle_request(method, path)    return method_not_allowed(method) unless method == 'GET'
    case path    when '/'      home    when '/about'      about    else      get(path)    end  end
  def home    [200, { 'Content-Type' => 'text/html' }, ['Welcome to our store']]  end
  def about    [200, { 'Content-Type' => 'text/html' }, ['About our discount system']]  end
  def get(path)    [200, { 'Content-Type' => 'text/html' }, ["You have requested the path #{path}, using GET"]]  end
  def method_not_allowed(method)    [405, {}, ["Method not allowed: #{method}"]]  endendNow we can write complete tests for each route:
require_relative 'app'require_relative 'spec_helper'
describe App do  let(:app) { App.new }
  describe 'GET requests' do    context 'to /' do      let(:response) { get '/' }
      it { expect(response).to be_successful }      it { expect(response.body).to eq 'Welcome to our store' }      it { expect(response.headers['Content-Type']).to eq 'text/html' }    end
    context 'to /about' do      let(:response) { get '/about' }
      it { expect(response).to be_successful }      it { expect(response.body).to eq 'About our discount system' }    end
    context 'to /custom/path' do      let(:response) { get '/custom/path' }
      it { expect(response).to be_successful }      it { expect(response.body).to include('/custom/path') }      it { expect(response.body).to include('GET') }    end  end
  describe 'non-GET requests' do    context 'POST to /' do      let(:response) { post '/' }
      it { expect(response.status).to eq 405 }      it { expect(response.body).to eq 'Method not allowed: POST' }    end
    context 'DELETE to /about' do      let(:response) { delete '/about' }
      it { expect(response.status).to eq 405 }      it { expect(response.body).to include 'Method not allowed' }    end  endendThis test suite is complete and well-organized. We use describe to group tests by request type, and context to specify particular routes. Each test verifies a specific aspect of the response.
Note how we use be_successful, which is a matcher that Rack::Test provides. It’s more expressive than checking that the status is exactly 200, especially since different successful endpoints might return different success codes (200, 201, 204, etc.).
When you run these tests with rspec --format doc app_spec.rb, you get very descriptive documentation:
App  GET requests    to /      should be successful      should eq "Welcome to our store"      should eq "text/html"    to /about      should be successful      should eq "About our discount system"    to /custom/path      should be successful      should include "/custom/path"      should include "GET"  non-GET requests    POST to /      should eq 405      should eq "Method not allowed: POST"    DELETE to /about      should eq 405      should include "Method not allowed"
Finished in 0.00416 seconds (files took 0.11258 seconds to load)11 examples, 0 failuresThis not only tests your application, but also documents its behavior in a way that any team member can understand.
Integrating with Sinatra
Although we’ve used a pure Rack application in our examples, Rack::Test works exactly the same with frameworks built on Rack, like Sinatra. Here’s a quick example of how you would test a Sinatra application that handles orders with discounts:
require 'sinatra/base'require_relative 'order'
class DiscountApp < Sinatra::Base  get '/' do    'Welcome to our discount calculator'  end
  post '/calculate' do    amount = params[:amount].to_f    vip = params[:vip] == 'true'
    order = Order.new(amount, customer_vip: vip)
    "Discount: #{order.discount_percentage}%, Final: €#{order.final_amount}"  endendThe tests would be almost identical:
require_relative 'app'require 'rack/test'
RSpec.describe DiscountApp do  include Rack::Test::Methods
  def app    DiscountApp.new  end
  describe 'GET /' do    it 'returns welcome message' do      get '/'      expect(last_response).to be_ok      expect(last_response.body).to include('discount calculator')    end  end
  describe 'POST /calculate' do    it 'calculates discount for regular customer' do      post '/calculate', amount: 150, vip: false      expect(last_response).to be_ok      expect(last_response.body).to include('Discount: 10%')      expect(last_response.body).to include('Final: €135.0')    end
    it 'calculates discount for VIP customer' do      post '/calculate', amount: 150, vip: true      expect(last_response).to be_ok      expect(last_response.body).to include('Discount: 15%')      expect(last_response.body).to include('Final: €127.5')    end  endendThe only real difference is that the app method now returns an instance of your Sinatra class instead of your pure Rack class. Everything else works exactly the same.
Conclusion: The Testing Journey
We’ve come a long way in this tutorial, from manually printing values to the console to writing test suites with frameworks.
We started with the simplest form: running code and observing the output. Then we learned to separate test code from production code using $PROGRAM_NAME == __FILE__. We then automated the verification of results with assertions. We built our own testing library to understand the underlying principles. And finally, we explored the industry-standard tools: Minitest with its straightforward simplicity, RSpec with its expressive DSL, and Rack::Test for Rack applications.
The fundamental concepts remain constant no matter which tool you use: the three stages (setup, execution, assertion), independence between tests, and the idea that tests are both verification and documentation.
The most valuable thing you can take away from this tutorial is the testing mindset. Testing allows you to change code with confidence, refactor without fear, and sleep peacefully knowing that if you break something, your tests will tell you. It transforms programming from an act of faith into a process with a safety net.
Test your knowledge
-  
What is the main purpose of writing tests for your code?
 
-  
What does the pattern
$0 == __FILE__accomplish in Ruby? 
-  
What are the three stages present in most tests?
 
-  
Why does Minitest run tests in random order by default?
 
-  
What does “lazy evaluation” mean in the context of RSpec’s
let?