Skip to Content
Course content

30: Writing Tests with RSpec

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've spent a lot of my career fixing bugs that "looked" fine in the editor but exploded the moment they hit production. Usually, it's because the developer tested the "happy path"—the scenario where everything goes right—and completely ignored the edge cases. This is exactly why we use RSpec.

Take a look at this simple BankAccount class. At a glance, it looks perfectly reasonable. It has a balance, you can deposit, and you can withdraw.

class BankAccount
  attr_reader :balance

  def initialize(starting_balance)
    @balance = starting_balance
  end

  def deposit(amount)
    @balance += amount
  end

  def withdraw(amount)
    @balance -= amount
  end
end

The Silent Balance Drain

If you're just running this in a REPL, you might try account.withdraw(50) and see it work. But there's a massive logic hole here: I can withdraw a million dollars from an account that only has ten. Ruby will happily let the balance go negative, which is a disaster for a bank.

Instead of just adding an if statement and hoping for the best, let's write a test to prove this failure. This is the "Red" part of the Red-Green-Refactor cycle. I'll create a spec file that describes exactly how I expect the withdrawal to behave when funds are low.

# bank_account_spec.rb
require_relative 'bank_account'

RSpec.describe BankAccount do
  it 'does not allow withdrawing more than the current balance' do
    account = BankAccount.new(100)
    
    # We expect this to raise an error, not just let the balance drop
    expect { account.withdraw(150) }.to raise_error(RuntimeError, "Insufficient funds")
  end
end

When you run this with rspec bank_account_spec.rb, it fails miserably. RSpec tells us that it expected a RuntimeError, but instead, the code just executed and left the balance at -50. The test has successfully exposed the bug.

Adding Guard Clauses and Expectations

Now we fix it. I'm going to add a guard clause to the withdraw method. A guard clause is a pattern where we handle the "error" condition early and exit the method immediately, keeping the rest of the logic clean.

class BankAccount
  attr_reader :balance

  def initialize(starting_balance)
    @balance = starting_balance
  end

  def deposit(amount)
    @balance += amount
  end

  def withdraw(amount)
    raise "Insufficient funds" if amount > @balance
    @balance -= amount
  end
end

Run the test again. It passes. But we aren't done. A professional test suite doesn't just test the failures; it ensures the successful paths still work. I'll add a few more specs to cover the basics.

RSpec.describe BankAccount do
  let(:account) { BankAccount.new(100) }

  it 'increases balance after a deposit' do
    account.deposit(50)
    expect(account.balance).to eq(150)
  end

  it 'decreases balance after a valid withdrawal' do
    account.withdraw(40)
    expect(account.balance).to eq(60)
  end

  it 'raises an error for insufficient funds' do
    expect { account.withdraw(150) }.to raise_error(RuntimeError, "Insufficient funds")
  end
end

Notice I used let(:account) { ... } here. In RSpec, let is a way to define a memoized helper method. It's cleaner than defining the account inside every single it block, and it ensures each test starts with a fresh account object so they don't interfere with one another.

Choosing the Right Matcher

One thing that trips people up is choosing the right "matcher"—the part after expect(...).to. While eq is the workhorse for checking equality, you'll often need others. If you're checking if a list contains an item, use include. If you're checking if a value is true or false, use be true or be_truthy. If you're testing that a block of code crashes (like our withdrawal error), you must wrap the call in curly braces { } rather than parentheses ( ), otherwise Ruby will execute the code before RSpec can "catch" the error.




📋 Practical Task

Building a Tax Calculator with RSpec

Your task is to create a TaxCalculator class and a corresponding RSpec test suite to ensure it handles various tax brackets and edge cases correctly.

Requirements:

  • Create a TaxCalculator class with a method calculate_tax(amount).
  • The logic should be:
    • Amounts $0 or less should raise a ArgumentError with the message "Amount must be positive".
    • Amounts up to $1,000 are taxed at 10%.
    • Amounts over $1,000 are taxed at 20%.
  • Write an RSpec file that includes:
    • A let block to initialize the calculator.
    • A test case for the 10% bracket (e.g., $500 should result in $50).
    • A test case for the 20% bracket (e.g., $2,000 should result in $400).
    • A test case verifying that negative numbers raise the ArgumentError.

Goal: Run your specs and ensure all tests pass. Focus on using the correct matchers (eq for the calculations and raise_error for the validation).

Rating
0 0

There are no comments for now.

to be the first to leave a comment.