justin․searls․co

A real-world example of a Mocktail test

A few years ago, I wrote this test double library for Ruby called Mocktail. Its README provides a choose-your-own-adventure interface as well as full API documentation, but it doesn't really offer a way to see a test at a glance—and certainly not a realistic one.

Since I just wrote my first test with Mocktail in a while, I figured I'd share it here for anyone who might have bounced off Mocktail's overly cute README or would otherwise be interested in seeing what an isolated unit test with Mocktail looks like.

The goal

Today I'm writing a class that fetches an Atom feed. It has three jobs:

  1. Respect caching and bail out if the feed hasn't been updated
  2. Parse the feed
  3. Persist the feed entries (and updated caching headers)

There is an unspoken fourth job here: coordinate these three tasks. The first class I write will be the orchestrator of these other three, which means its only job is to identify and invoke the right dependencies the right way under a given set of conditions.

The code

So we can focus on the tests, I'll spare you the test-driven development play-by-play and just show you the code that will pass the tests we're going to write:

class FetchesFeed
  def initialize
    @gets_http_url = GetsHttpUrl.new
    @parses_feed = ParsesFeed.new
    @persists_feed = PersistsFeed.new
  end

  def fetch(feed)
    response = @gets_http_url.get(feed.url, headers: {
      "If-None-Match" => feed.etag_header,
      "If-Modified-Since" => feed.last_modified_header
    }.compact)
    return if response.code == 304 # Unchanged

    parsed_feed = @parses_feed.parse(response.body)
    @persists_feed.persist(
      feed,
      parsed_feed,
      etag_header: response.headers["etag"],
      last_modified_header: response.headers["last-modified"]
    )
  end
end

As you can see, this fits a certain idiosyncratic style that I've been practicing in Ruby for a long-ass time at this point:

  • Lots of classes who hold their dependencies as instance variables, but zero "unit of work" state. Constructors only set up the object to do the work, and public methods perform that work on as many unrelated objects as needed
  • Names that put the verb before the noun. By giving the verb primacy, as the system grows and opportunities for reuse arise, this encourages me to generalize the direct object of the class via polymorphism as opposed to generalizing the core function of the class, violating the single-responsibility principle (i.e. PetsDog may evolve into PetsAnimal, whereas DogPetter is more likely to evolve into a catch-all DogManager)
  • Pretty much every class I write has a single public method whose name is the same as the class's verb
  • I write wrapper classes around third-party dependencies that establish a concrete contract so as to make them eminently replaceable. For starters, GetsHttpUrl and ParsesFeed will probably just delegate to httparty and Feedjira, but those wrappers will inevitably encapsulate customizations in the future

If you write code like this, it's really easy to write tests of the interaction without worrying about actual HTTP requests, actual XML feeds, and actual database records by using Mocktail.

The first test

Here's my first test, which assumes no caching headers are known or returned by a feed. I happen to be extending Rails' ActiveSupport::TestCase here, but that could just as well be Minitest::Test or TLDR:

require "test_helper"

class FetchesFeedTest < ActiveSupport::TestCase
  setup do
    @gets_http_url = Mocktail.of_next(GetsHttpUrl)
    @parses_feed = Mocktail.of_next(ParsesFeed)
    @persists_feed = Mocktail.of_next(PersistsFeed)

    @subject = FetchesFeed.new

    @feed = Feed.new(
      url: "http://example.com/feed.xml"
    )
  end

  def test_fetch_no_caching
    stubs {
      @gets_http_url.get(@feed.url, headers: {})
    }.with { GetsHttpUrl::Response.new(200, {}, "an body") }
    stubs { @parses_feed.parse("an body") }.with { "an parsed feed" }

    @subject.fetch(@feed)

    verify {
      @persists_feed.persist(
        @feed, "an parsed feed",
        etag_header: nil, last_modified_header: nil
      )
    }
  end
end

Here's the Mocktail-specific API stuff going on above:

  • Mocktail.of_next(SomeClass) - pass a class to this and you'll get a fake instance of it AND the next time that class is instantiated with new, it will return the same fake instance. This way, the doubles we're configuring in our test are the same as the ones the subjects' instance variables are set to in its constructor
  • stubs {…}.with {…} - call the dependency exactly as you expect the subject to in the first block and, if it's called in a way that satisfies that stubbing, return the result of the second block
  • verify {…} - call a dependency exactly as you expect it to be invoked by the subject, raising an assertion failure if it doesn't happen

You'll note that this test adheres to the arrange-act-assert pattern, in which first setup is performed, then the behavior being tested is invoked, and then the assertion is made. (Sounds obvious, but most mocking libraries violate this!)

The second test

Kicking the complexity up a notch, next I added a test wherein caching headers were known but they were out of date:

def test_fetch_cache_miss
  @feed.etag_header = "an etag"
  @feed.last_modified_header = "an last modified"
  stubs {
    @gets_http_url.get(@feed.url, headers: {
      "If-None-Match" => "an etag",
      "If-Modified-Since" => "an last modified"
    })
  }.with {
    GetsHttpUrl::Response.new(200, {
      "etag" => "newer etag",
      "last-modified" => "laster modified"
    }, "an body")
  }
  stubs { @parses_feed.parse("an body") }.with { "an parsed feed" }

  @subject.fetch(@feed)

  verify { @persists_feed.persist(@feed, "an parsed feed", etag_header: "newer etag", last_modified_header: "laster modified") }
end

This is longer, but mostly just because there's more sludge to pass through all the tubes from the inbound argument to each dependency. You might also notice I'm just using nonsense strings here instead of something that looks like a real etag or modification date. This is intentional. Realistic test data looks meaningful, but these strings are not meaningful. Meaningless test data should look meaningless (hence the grammar mistakes). If I see an error, I'd like to know which string I'm looking at, but I want the test to make clear that I'm just using the value as a baton in a relay race: as long as it passes an equality test, "an etag" could be literally anything.

The third test

The last test is easiest, because when there's a cache hit, there won't be a feed to parse or persist, so we can just bail out. In fact, all we really assert here is that no persistence call happens:

def test_fetch_cache_hit
  @feed.etag_header = "an etag"
  @feed.last_modified_header = "an last modified"
  stubs {
    @gets_http_url.get(@feed.url, headers: {
      "If-None-Match" => "an etag",
      "If-Modified-Since" => "an last modified"
    })
  }.with { GetsHttpUrl::Response.new(304, {}, nil) }

  assert_nil @subject.fetch(@feed)

  verify_never_called { @persists_feed.persist }
end

Note that verify_never_called doesn't ship with Mocktail, but is rather something I threw in my test helper this morning for my own convenience. Regardless, it does what it says on the tin.

Why make sure PersistsFeed#persist is not called, but avoid making any such assertion of ParsesFeed? Because, in general, asserting that something didn't happen is a waste of time. If you genuinely wanted to assert every single thing a system didn't do, no test would ever be complete. The only time I bother to test an invocation didn't happen is when an errant call would have the potential to waste resources or corrupt data, both of which would be risks if we persisted an empty feed on a cache hit.

The setup

To setup Mocktail in the context of Rails the way I did, once you've tossed gem "mocktail" in your Gemfile, then sprinkle this into your test/test_helper.rb file:

module ActiveSupport
  class TestCase
    include Mocktail::DSL

    teardown do
      Mocktail.reset
    end

    def verify(...)
      assert true
      Mocktail.verify(...)
    end

    def verify_never_called(&blk)
      verify(times: 0, ignore_extra_args: true, ignore_arity: true, &blk)
    end
  end
end

Here's what each of those do:

  • include Mocktail::DSL simply lets you call stubs and verify without prepending Mocktail.
  • Mocktail.reset will reset any state held by Mocktail between tests, which is relevant if you fake any singletons or class methods
  • verify is overridden because Rails 7.2 started warning when tests lacked assertions, and it doesn't recognize Mocktail.verify as an assertion (even though it semantically is). Since Rails removed the configuration to disable it, we just wrap the method ourselves with a dummy assert true to clear the error
  • verify_never_called is just shorthand for this particular convoluted-looking configuration of the verify method (times asserts the exact number times something is called, ignore_extra_args will apply to any invocations with more args than specified in the verify block, and ignore_arity will suppress any argument errors raised for not matching the genuine method signature)

The best mocking library

I am biased about a lot of things, but I'm especially biased when it comes to test doubles, so I'm unusually cocksure when I say that Mocktail is the best mocking library available for Ruby. There are lots of features not covered here that you might find useful. And there are a lot of ways to write code that aren't conducive to using Mocktail (but those code styles aren't conducive to writing isolation tests at all, and therefore shouldn't be using any mocking library IMNSHO).

Anyway, have fun playing with your phony code. 🥃

$ rails new syndicate --database=postgresql --css=tailwind --skip-devcontainer --skip-docker --skip-kamal --skip-rubocop --skip-thruster --skip-action-mailbox

Copied!

We shipped a fun feature at Better with Becky industries last week that offers a new way to follow Becky's work: getting each Beckygram delivered via e-mail!

From Becky's announcement:

That's why we built Beckygram—a space outside the noise of social media, where I can share real fitness insights, mindset shifts, and everyday wins without the distractions of ads, comparison traps, or influencer gimmicks. Frankly, I know that if I mentally benefit from being off the app, others will too!

You can sign up by clicking the Follow button on her Beckygram bio and entering your e-mail address at the bottom of any page. I hope you'll consider it, because Instagram does indeed suck.

So that brings us to 4 ways people can enjoy the world of Beckygram multimedia:

Because I am a nerd, I just follow via RSS. But apparently, some of Becky's fans "don't know or care what a feed reader even is," so I made this for them. As we grow our POSSE, hopefully we'll eventually offer whatever distribution channels we need for all 7 billion people on earth to subscribe, but for now maybe there's one that works for you (or someone else in your life who might be into working out, feeling motivated, or eating food).

It was probably easy to miss this one, but v27 of my Breaking Change podcast was a holiday special that featured our friend Aaron "tenderlove" Patterson and which we released as a video on YouTube.

In this video, we sweat the small stuff to review this year in puns, re-ranking all 26 that he'd written this year for the show and ultimately arriving at something of a Grand Unifying Theory of what makes for a good pun.

(One programming note that's kind of interesting: while we recorded locally, we conducted the actual session via a FaceTime Video call and… I'll be damned if it doesn't seem extremely low latency compared to most remote-cohost podcasts and videos you see out there. We were able to talk over each other pretty frequently without it becoming unintelligible in post. Neat!)

(Another programming note that's marginally less interesting: YouTube flagged this video as potential climate change misinformation, which it most definitely is.)

Have an old app that I don't use and which generates Bugsnag notifications almost daily, and I've been stressing about a few todos to fix these long-known problems once and for all.

But then I realized I could just unsubscribe from Bugsnag email notifications and that will take way less time than debugging Postgres deadlocks. Problem solved!

Copied!

Sitting on hold with a doctor and every 30 seconds it says "Due to unusually long hold times, you can press 1 to leave a voicemail and we will return your call within 24 business hours."

24 business hours? So if it's 1 PM Monday, that means they'll get back to me on 10 AM Thursday?

Copied!

The catastrophic failure of Peloton as a business is very funny in its own right, but the fact that they'll either go bankrupt or be merged into oblivion without ever once owning peloton dot com is sort of hilarious.

Copied!
Breaking Change artwork

v28 - Do you regret it yet?

Breaking Change

I don't normally do this, but content warning, this episode talks at length about death and funerals and, while I continue to approach everything with an inappropriate degree of levity, if that's something you're not game to listen to right now, go ahead and skip the first hour of this one.

Recommend me your favorite show or video game at podcast@searls.co and I will either play/watch it or lie and say I did. Thanks!

Now: links and transcript:

The year is 2025 and, astoundingly, a blog post is advocating for the lost art of Extreme Programming ("XP"). From Benji Weber:

In my experience teams following Extreme Programming (XP) values and practices have had some of the most joy in their work: fulfilment from meaningful results, continually discovering better ways of working, and having fun while doing so. As a manager, I wish everyone could experience joy in their work.

I've had the privilege to work in, build, and support many teams; some have used XP from the get go, some have re-discovered XP from first principles, and some have been wholly opposed to XP practices.

XP Teams are not only fun, they take control of how they work, and discover better ways of working.

Who wouldn't want that? Where does resistance come from? Don't get me wrong; XP practices are not for everyone, and aren't relevant in all contexts. However, it saddens me when people who would otherwise benefit from XP are implicitly or accidentally deterred.

For what it's worth, I wrote about my favorite experience on a team striving to practice (and even iterate on) Extreme Programming in the August edition of Searls of Wisdom, for anyone wanting my take on it.

Benji's comment about GitHub—whose rise coincided with the corporatization of "Agile" and the petering out of methodologies like XP—jumped out at me as something I hadn't thought about in a while:

Similarly Github's UX nudges towards work in isolation, and asynchronous blocking review flows. Building porcelain scripts around the tooling that represent your team's workflow rather than falling into line with the assumed workflows of the tools designers can change the direction of travel.

One of the things that really turned me off about the idea of working at GitHub when visiting their HQ 2.0 in early 2012 was how individualistic everything was. Not the glorification of certain programmers (I'm all about the opportunity to be glorified!), but rather the total lack of any sense of team. Team was a Campfire chatroom everyone hung out in.

This is gonna sound weird to many of you, but I remember walking into their office and being taken aback by how quiet the GitHub office was. It was also mostly empty, sure, but everyone had headphones on and was sitting far apart from one another. My current client at the time (and the one before that, and the one before that) were raucous by comparison: cross-functional teams of developers, designers, product managers, and subject matter experts, all forced to work together as if they were manning the bridge of a too-nerdy-for-TV ship in Star Trek.

My first reaction to seeing the "GitHub Way" made manifest in their real life office was shock, followed by the sudden realization that this—being able to work together without working together—was the product. And it was taking the world by storm. I'd only been there a few hours when I realized, "this product is going to teach the next generation of programmers how to code, and everything about it is going to be hyper-individualized." Looking back, I allowed GitHub to unteach me the best parts of Extreme Programming—before long I was on stage telling people, "screw teams! Just do everything yourself!"

Anyway, here we are. Maybe it's time for a hipster revival of organizing backlogs on physical index cards and pair programming with in-person mouth words. Not going to hold my breath on that one.

In case you're wondering how wide the new Ultra Wide (32:9 @ 5120x1440px) setting is for Mac Virtual Display on Vision Pro, it amounts to:

  • 730 characters per line of text at Terminal's default font size
  • 42 cells of data at Numbers' default sizing
  • 24 slides per row in Keynotes' Light Table view

It's wide.

Copied!

This month's issue of Searls of Wisdom is out and includes the eulogy I recently gave for my father. If you sign up now, you'll receive it.

Already heard back from a young father who read it from a totally different perspective than I had anticipated. It's easy to assume that something that feels so personal can only apply to you, but never underestimate the impact of sharing your authentic self with others. justin.searls.co/newsletter/

Subscribe to Searls of Wisdom | justin․searls․co
Copied!

Thanks, Fred

This is a copy of the Searls of Wisdom newsletter delivered to subscribers on January 1, 2025.

Welp, we're finally done with 2024.

It was a busy year for Searls family of brands. A few highlights of what I'll remember this year for:

That said, give it a few decades and if 2024 ever comes up in conversation, the first thing to come to mind will almost certainly be December's passing of my father, Fred.

The first time I met my dad

Rather than wandering around the house and cooking up one of my trademark insightful-but-totally-overwrought essays about life, I thought it'd be more appropriate to print the remarks I shared about Fred at his funeral. The pastor told me to keep it to 5-7 minutes. I did my best, but ultimately found it impossible compress his impact on me and the other people in his life within such a tight time constraint. (Says the guy with the 3-hour podcast, I know.)

The service's program called what follows a remembrance. Does that make it a eulogy? That word feels too strong, to be honest (I've been driving without a valid eulogy license for years). Anyway, here are the words I said.

And then what happened?…

Breaking Change artwork

v27 - The Punsort Algorithm

Breaking Change

HEADS UP: This one has a video version in case you'd prefer to watch that!

HEADS UP SEVEN UP: Here's a spoiler-free link to this year's puns as they existed prior to this recording.

Welcome to a very special Holiday Edition of Breaking Change: our first annual Breaking Change Punsort! Today, we're joined by a surprise guest! Want to know who it is? You'll just have to listen and find out. Yes, it's Aaron.

As always, you can e-mail the show at podcast@searls.co. If you enjoyed this episode and want to see a second annual edition next year, let me know! If you don't write in, I'll stop—because editing multiple speakers plus video is a massive pain in the ass.

No links today, because we didn't actually talk about anything worth citing.

Review of the Bullstrap Leather NavSafe Wallet

A few years ago, I bought a leather Apple MagSafe Wallet with its hobbled Find My integration (wherein your phone merely tracks the location at which it was last disconnected from the wallet, as opposed to tracking the wallet itself). And that was a couple years before they made the product even worse by switching to the vegan FineWoven MagSafe Wallet.

Well, this wallet I never really liked is falling apart, and so I went searching for something better. All I want is a leather wallet that has a strong magnet and can reliably fit 3 or 4 cards without eventually stretching to the point that a mild shake will cause your cards to slide out.

After hearing the hosts of ATP talk up the company Bullstrap as a great iPhone case maker that continues to have the #courage to use real cow-murdererin' leather, I figured I'd try out their Leather NavSafe Wallet in the burnt sienna color. I was extremely excited to switch to this wallet, because it promised genuine leather, real Find My integration, a really strong magnet, and a rechargeable battery. Finally, a MagSafe wallet with no compromises!

It sat in my mailbox for about a week because my dad died, and between receiving the delivery notification and returning home, I thought about how excited I was for this wallet every single time I had to pay for something. That's why I tore open the bag and set it up right in the little community mailbox parking lot, instead of waiting the 30 seconds it would take for me to drive home first.

First impressions? Well, dad always read everything I posted to this website and he hated it when I swore gratuitously, so I guess the gloves are finally off.

This motherfucking Bullstrap Leather NavSafe Wallet is a goddamn piece of shit.

To be clear, I am recommending you not purchase the Bullstrap Leather NavSafe Wallet. You probably don't want to buy their Leather Magnetic Wallet either, and—given that they're charging $79.99 for this trash—I plan on avoiding all their bullshit until one of them contacts me to explain why their wallet isn't as bad as my hyperbolic ass is making it out to be. Despite wanting a leather wallet, I believe the life of every cow has value, so it's a goddamn shame to see this one's wasted on piss-poor products like this.

Key points follow, in descending order of positivity:

  1. The Find My setup works, that much I know. I decided to return it within 30 seconds, so I can't attest to its actually-finding-it functionality—all I can say is that I would feel profound disappointment upon successfully locating a Bullstrap Leather NavSafe Wallet
  2. The Qi charging of that wallet might work too, beats me. I won't be holding onto this thing long enough to drain the battery
  3. The wallet purports to fit 3 cards, but it's obscenely, ridiculously tight. It's so tight that I barely got the second card in. It was only due to my journalistic commitment to fully evaluating the product that I attempted to wedge in a third—a decision I immediately regretted. One presumes this rich Corinthian leather will stretch with time, but it won't be on my watch. Perhaps this wallet was designed for a simpler time, back when nice credit cards were made out of plastic and not bulletproof steel
  4. Speaking of the leather, it is extremely genuine, because it already had a scratch along the entire bottom edge before I'd even removed it from the insufficiently-protective plastic bag it was shipped in. Since their return policy requires products to be returned in "unused, re-sellable condition", perhaps this one had been sold, unused, and returned a few times already
  5. Instead of a thumbhole on the back side through which to slide your cards upwards and eject them from the wallet, you're left with only this weird little cloaca at the bottom that no earthly finger could ever squeeze into. Maybe they imagine customers taking a tiny flathead screwdriver and shoving it up the glory hole in order to get their cards out? Because that's what I had to do
  6. The magnet is so fucking weak I thought that I might have forgotten a step in the setup instructions. I literally double-checked the box to make sure there wasn't some kind of adhesive magnet I was supposed to affix on my own. Whatever this magnet is, I would not call it load bearing—two metallic cards and a driver's license left it so precariously attached to the back of my buck-naked iPhone 16 Pro that a heavy breath and a generous jiggle was all it took to dislodge it. To make sure the weight of my cards wasn't to blame, I tested the wallet empty and the magnet is quite a lot weaker than the already-way-too-weak Apple MagSafe wallet and absolutely no match for any of the sub-$20 junk you can find on Amazon in this category

I ordered it directly from their store, which means I also apparently have to pay $7.99 to return it, which feels like bullshit. Come to think of it, the fact I have to wait to hear back from their customer support to get a shipping label is actually why I'm writing this review. I just needed someone to talk to, apparently.

Anyway, this is your regular reminder of why we all ought to just keep ordering garbage products on Amazon and making liberal use of their generous free return policy while we let independent resellers and the resiliency of the US economy rot on the vine. Fuck's sake.

I love eggs but I'm lactose intolerant

Becky and I circled the Costco three fucking times looking for eggs before independently realizing that OF COURSE they're in the room labeled "Dairy".

Why, American people?