DEV Community

Brandon Weaver
Brandon Weaver

Posted on

Let's Read – Eloquent Ruby – Ch 9

Perhaps my personal favorite recommendation for learning to program Ruby like a Rubyist, Eloquent Ruby is a book I recommend frequently to this day. That said, it was released in 2011 and things have changed a bit since then.

This series will focus on reading over Eloquent Ruby, noting things that may have changed or been updated since 2011 (around Ruby 1.9.2) to today (2024 — Ruby 3.3.x).

Chapter 9. Write Specs!

Warning: This chapter has had a significant amount of changes since the original writing as the syntax for RSpec especially has evolved dramatically since then, so be sure to compare how things have evolved but be careful not to use some of the older syntax as it will break.

Ah specs. One of the things I really appreciated about Ruby early on is that nothing was considered done until it was also tested, a sentiment the book reflects. Now there are many different ways to test in Ruby, all of them correct in their own ways, but my correct is RSpec and that's what I'll be focusing on when going through this chapter.

That also means that I'm going to be skipping the sections on Test::Unit and apologizing to my Seattle friends about the omission of MiniTest.

Why RSpec? Because every major Rails shop I have worked at uses it all the way up to north of 5 million lines of code. Some say its slow, personally I say folks use FactoryBot.create too liberally and create hundreds of thousands of database records instead of decoupling from persistence layers, but that's an argument for a much longer and much different article than this one.

Anyways, we'll be starting with "Don't Test It, Spec It!"

Don't Test It, Spec It!

As the book mentions RSpec focuses more on testing the behaviors of our code, and currently it might look something like this (with some improvements throughout the chapter as per usual):

# What we're testing
class Document
  attr_reader :title, :author, :content

  # Yes, I'm going to use keyword arguments here too
  def initialize(author:, title:, content:)
    @author = author
    @title = title
    @content = content
  end

  def words
    content.split
  end

  def word_count
    words.size
  end
end

# The first difference is the first `describe` is always prefixed with
# `RSpec` now while everything inside of the block is evaluated in its
# context.
RSpec.describe Document do
  # The next is that it tends to segment tests based on the method name
  # for Unit level tests (individual units of code) using that Smalltalk
  # .class_method versus #instance_method idiom.
  describe ".new" do
    it "can create an instance of a Document" do
      # While we could use `Document` here we can also use
      # `described_class` just in case we happen to
      # move things around later.
      document = described_class.new(
        author: "Author",
        title: "Title",
        content: "A bunch of words"
      )

      # The third is that assertions are now no longer monkeypatched into
      # every object, they're wrappers now.
      #
      # In this case it's a fairly simple check that we can even
      # initialize a Document and it doesn't break.
      expect(subject).to be_a(described_class)
    end
  end

  describe "#words" do
    it "contains all the words in the document" do
      document = described_class.new(
        author: "Author",
        title: "Title",
        content: "A bunch of words"
      )

      expect(subject.words).to include("A", "bunch", "of", "words")
    end
  end

  describe "#word_count" do
    it "can get a count of the words in a document" do
      document = described_class.new(
        author: "Author",
        title: "Title",
        content: "A bunch of words"
      )

      expect(subject.word_count).to eq(4)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

The key difference is that something.should == something_else is now deprecated syntax which has been gone for years. Why? Because the prior relied on monkey patching which could have adverse effects on the underlying code being tested.

The other difference is that I tend towards wrapping each distinct method in its own describe block. For much larger spec files this becomes very common for unit tests, though integration tests which describe less behavior and exercise wider ranges of code may still follow a more behavioral syntax. In the next sections we'll stick slightly closer to the book.

A Tidy Spec is a Readable Spec

The book here mentions using before :each with instance variables like so:

RSpec.describe Document do
    before :each do
    @text = "A bunch of words"
    @doc = Document.new(title: "Test", author: "Nobody", text: @text)
  end

  # Personal preference: "should" versus active voice
  it "holds the contents" do
    expect(@doc.content).to eq(@text)
  end

  it "knows which words it has" do
    expect(@doc.words).to include("A", "bunch", "of", "words")
  end

  it "knows how many words it contain" do
    expect(@doc.word_count).to eq(4)
  end
end
Enter fullscreen mode Exit fullscreen mode

Note: Instance variables are generally not a good idea in tests, prefer to use let and subject instead to prevent state from being shared across tests and potentially getting test pollution. They're recreated between each test (it block):

RSpec.describe Document do
  let(:text) { "A bunch of words" }
  subject { described_class.new(title: "Test", author: "Nobody", text: text) }

  it "holds the contents" do
    expect(subject.content).to eq(text)
  end

  it "knows which words it has" do
    expect(subject.words).to include("A", "bunch", "of", "words")
  end

  it "knows how many words it contain" do
    expect(subject.word_count).to eq(4)
  end
end
Enter fullscreen mode Exit fullscreen mode

There are still cases for before and its sibling after for set-up, mocking, and tear-down logic. The book also mentions the presence of before :all and after :all for running code before and after all of your specs. RSpec configuration even has a :suite level for things you want for every spec, but use it sparingly.

Easy Stubs

Stub syntax has certainly changed a lot since the book as well, so do be careful here. In some cases you have network calls, expensive operations, or things you just do not want to run inline along with a test. If you want to test something in isolation you're likely to need at least a few stubs unless you're writing things in an extremely functional style.

The example the book uses is of a PrintableDocument that uses a physical printer:

class PrintableDocument
  def print(printer)
    return "Printer unavailable" unless printer.available?

    printer.render("#{title}\n")
    printer.render("By #{author}\n")
    printer.render(content)

    "Done"
  end
end
Enter fullscreen mode Exit fullscreen mode

So how do we test it? In the book's era we used a stub, but now they tend to be called doubles:

class Printer
  def available?
    true
  end

  # Assume this is an actual printer spool call of some sort
  def render(message)
    print message
  end
end

describe PrintableDocument do
  let(:text) { "A bunch of words" }
  subject { described_class.new(title: "Test", author: "Nobody", text: text) }

  # Not only mock it, but _require_ it to behave like an actual Printer
  let(:printer_double) { instance_double(Printer) }
  let(:printer_status) { true }

  before do
    allow(printer_double).to receive(:available).and_return(printer_status)
    allow(printer_double).to receive(:render).and_return(nil)
  end

  it "knows how to print itself" do
    expect(subject.print(printer_double)).to eq("Done")
  end

  # More modern variants would use a `context` to wrap when a variable
  # or detail has changed
  context "When the printer is off" do
    let(:printer_status) { false }

    it "shows that the printer is unavailable" do
      expect(subject.print(printer_double)).to eq("Printer unavailable")
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

One of the major benefits of instance_double here is that if I forgot to add an allow here it would complain that it's not allowed to receive certain messages, failing the test. This forces us to account for all of its behaviors, not just the ones under the current test.

You could even put it inline as an expect(X).to receive(Y).at_least_once.and_return(5), there's a lot of flexibility here. In fact it's so powerful it accidentally skipped the next section of this chapter.

Staying Out of Trouble

As the book mentions tests will prevent a lot of bugs and catch a lot of cases you might not have ever considered, but the law of diminishing returns is real.

If you were to start from zero, for instance, I would focus on describing your most important functionality in end to end tests first. Start from the outside and work inside. When you're focusing on your user first you want to make sure their expectations are reflected. They don't care if adds will add two numbers somewhere deep in your code, they care whether or not they can run Payroll. Perhaps adds is part of that code, but they care about end results, and that's always a good thing to keep in mind.

The Opposite

One thing a lot of folks forget is for every positive assertion you should have a negative one. If you only test one half of the code it should come as no surprise when the other half isn't actually working like you think it is.

Not Catch Fire Spec

Amusingly Russ also mentions the simple "make a new instance" test similar to the .new test above. Perhaps I'm also heretical here.

Wrapping Up

The big takeaway here, and I agree with the book, is to write tests. However they look, doesn't matter, just write some. Take it from me who spent years rerunning console applications in school just to figure out the computer could have done the entire thing for me and far more accurately. Be lazy, write tests.

If you want to read more on testing I would highly recommend Effective RSpec.

Top comments (0)