Skip to main content
Test Double company logo
Services
Services Overview
Holistic software investment consulting
Software Delivery
Accelerate quality software development
Product Management
Launch modern product orgs
Legacy Modernization
Renovate legacy software systems
DevOps
Scale infrastructure smoothly
Upgrade Rails
Update Rails versions seamlessly
Technical Recruitment
Build tech & product teams
Technical Assessments
Uncover root causes & improvements
Case Studies
Solutions
Accelerate Quality Software
Software Delivery, DevOps, & Product Delivery
Maximize Software Investments
Product Performance, Product Scaling, & Technical Assessments
Future-Proof Innovative Software
Legacy Modernization, Product Transformation, Upgrade Rails, Technical Recruitment
About
About
What's a test double?
Approach
Meeting you where you are
Founder's Story
The origin of our mission
Culture
Culture & Careers
Double Agents decoded
Great Causes
Great code for great causes
EDI
Equity, diversity & inclusion
Insights
All Insights
Hot takes and tips for all things software
Leadership
Bold opinions and insights for tech leaders
Developer
Essential coding tutorials and tools
Product Manager
Practical advice for real-world challenges
Say Hello
Test Double logo
Menu
Services
BackGrid of dots icon
Services Overview
Holistic software investment consulting
Software Delivery
Accelerate quality software development
Product Management
Launch modern product orgs
Legacy Modernization
Renovate legacy software systems
Cycle icon
DevOps
Scale infrastructure smoothly
Upgrade Rails
Update Rails versions seamlessly
Technical Recruitment
Build tech & product teams
Technical Assessments
Uncover root causes & improvements
Case Studies
Solutions
Solutions
Accelerate Quality Software
Software Delivery, DevOps, & Product Delivery
Maximize Software Investments
Product Performance, Product Scaling, & Technical Assessments
Future-Proof Innovative Software
Legacy Modernization, Product Transformation, Upgrade Rails, Technical Recruitment
About
About
About
What's a test double?
Approach
Meeting you where you are
Founder's Story
The origin of our mission
Culture
Culture
Culture & Careers
Double Agents decoded
Great Causes
Great code for great causes
EDI
Equity, diversity & inclusion
Insights
Insights
All Insights
Hot takes and tips for all things software
Leadership
Bold opinions and insights for tech leaders
Developer
Essential coding tutorials and tools
Product Manager
Practical advice for real-world challenges
Say hello
Developers
Developers
Developers
Testing

Mocking callbacks: Speed up your TDD and test driven development

Avoid test delays by using mocking callbacks to achieve isolation in tests. Learn how to enhance your TDD process.
Caleb Hearth
|
May 31, 2022
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Mocking is one way to achieve isolation in tests. It is the practice of using a fake object in place of a collaborator object. In the simplest case, a mock will accept a method call, do nothing, and return a value (usually nil in Ruby).

Mocks can also stub methods to return realistic values. They can provide static responses or use arguments to calculate a return value. It’s often as simple as specifying the expected method arguments and a response.

A simple stubbing might look like this:

stubs { mailer.deliver("a message") }.with { "canned response" }
mailer.deliver("a message") # => "canned response"

But stubbing methods isn’t always so straightforward. When a Ruby method takes a block to call later, it’s harder to identify whether, when, and how to call that block. It’s not as easy as the method mock above that can discard or do minimal work with arguments. Blocks often have side effects that may be important and that we need to assert against.

Let’s look at HeyYou, a Postgres LISTEN/NOTIFY client for Ruby. It has a method #listen that takes a block. It listens for notifications and executes the passed block when they trigger:

def collate_notifications
  HeyYou.new.listen do |notification|
    @notifications << notification
  end
end

We can use mocking as a tool to test this method’s behavior in isolation. If we were to test collate_notifications without faking HeyYou, it would need a real Postgres connection, which would register a real listener, which would only call our block if our test also triggered a real notification in the database.

That complexity might all be appropriate in an integration test, but would represent an awful lot of distracting hassle to an isolated unit test’s attempt to specify the method’s logical behavior. HeyYou and the Postgres database are collaborators; their behavior isn’t part of the system under test.

An isolated unit test of this method would be deterministic, specific, and performant.

Excluding dependencies such as the database makes our test is more deterministic. Fewer outside elements can cause failures due to inputs beyond our control. We eliminate things like LISTEN/NOTIFY calls and other database clients issuing their own statements.

The test is specific in that it lays out the behavior of the collaborator. We care that it calls the return block with the notification, so we call the block with the notification.

Finally, the test is more performant because it no longer needs to make network calls to the database. Even local networks are slower than Ruby calls. Further, the Ruby call stack is shorter and follows a path optimized by the mocking libraries to be fast.

Our contrived block that we passed to HeyYou#listen is very simple, only adding the message it receives to some collection. Imagine if that block had a dozen if/else conditions for our test to cover? And what if it took 5 seconds of wall time every time it was called through to the database? A naive integration test could take a minute at runtime whereas an isolated one would probably be under half a second.

An integration test of the method is flaky, declarative, and slower. It would also give us confidence that the interactions between our units behave correctly under realistic conditions.

The exposure to the database opens us up to an integration test failing unexpectedly. Even when run against the same code, the IO from the database exposes us to flakes. The integration test doesn’t control the database and shouldn’t. The database isn’t what we are testing.

A benefit to integration tests is that they are more declarative. We tell the code what to do rather than how to do it. The result is a test that may have fewer lines of code. It is more focused on the interaction between units. It has less setup mocking or faking collaborators.

Including the database in the test also leads to the slower test runs.

The testing pyramid suggests using many unit tests, fewer integration tests, and still fewer end-to-end tests. The latter is variously referred to as “feature”, “acceptance”, “UI”, or “system” tests. Following that, we can search for a compromise between the speed of unit tests and assurance of integration tests.

Mocking #listen is difficult. The test needs to execute the block to assert against the side effects of it being called. The method usually calls the block in reaction to an external stimulus. The block isn’t executed at the same point it is passed to #listen, but rather in reaction to a NOTIFY event. Mocked methods do not do anything with arguments or blocks passed to them by default. However, our test can’t exercise the behavior we want to verify unless the block is called. Because of this, it’s not as simple as mocking the method to return some useful value—we need to invoke the block just like HeyYou would.

Here’s how we can do it in Mocktail, Test Double’s mocking gem:

notification = "A useful notification"
hey_you = Mocktail.of_next(HeyYou)
stubs { hey_you.listen do |block|
  block.call(notification)
end }

sut = Collator.new
sut.collate_notifications

assert_equal [notification], sut.notifications

Or with rspec-mocks’ more verbose, indirect syntax, the mocking would look like:

notification = "A useful notification"
hey_you = instance_double(HeyYou)
allow(HeyYou).to receive(:listen) do |&block|
  block.call(notification)
end
allow(HeyYou).to receive(:new).and_return(hey_you)

Mocking—with or without callbacks—gives us confidence that our units are correct. It also provides speedier test runs to cover the numerous edge cases and code paths we want to test. To give us confidence that the pieces work together correctly when we combine them, we can also write integration tests. One covering the “happy path” and one or two for “sad paths” would give us confidence that the units work together as a system.

Mocking allows us to isolate what we are testing from objects that it happens to interact with. Isolation not only provides deterministic results but also leads to speedier tests. Callbacks require special handling when the methods they are passed to are mocked, but going that extra step reaps benefits.

Related Insights

🔗
How to stop hating your tests
🔗
The failures of introducing test driven development
🔗
Why TLDR is the Ruby testing framework you need for faster feedback

Explore our insights

See all insights
Leadership
Leadership
Leadership
The business of AI: Solve real problems for real people

After participating in the Perplexity AI Business Fellowship, one thing became clear: the AI hype cycle is missing the business fundamentals. Here are 3 evidence-based insights from practitioners actually building or investing in AI solutions that solve real problems.

by
Cathy Colliver
Leadership
Leadership
Leadership
Pragmatic approaches to agentic coding for engineering leaders

Discover essential practices for AI agentic coding to enhance your team’s AI development learning and adoption, while avoiding common pitfalls of vibe coding.

by
A.J. Hekman
by
Aaron Gough
by
Alex Martin
by
Dave Mosher
by
David Lewis
Developers
Developers
Developers
16 things software developers believe, per a Justin Searls survey

Ruby on Rails developer Justin Searls made a personality quiz, and more than 7,000 software developers filled it out. Here's what it revealed.

by
Justin Searls
Letter art spelling out NEAT

Join the conversation

Technology is a means to an end: answers to very human questions. That’s why we created a community for developers and product managers.

Explore the community
Test Double Executive Leadership Team

Learn about our team

Like what we have to say about building great software and great teams?

Get to know us
Test Double company logo
Improving the way the world builds software.
What we do
Services OverviewSoftware DeliveryProduct ManagementLegacy ModernizationDevOpsUpgrade RailsTechnical RecruitmentTechnical Assessments
Who WE ARE
About UsCulture & CareersGreat CausesEDIOur TeamContact UsNews & AwardsN.E.A.T.
Resources
Case StudiesAll InsightsLeadership InsightsDeveloper InsightsProduct InsightsPairing & Office Hours
NEWSLETTER
Sign up hear about our latest innovations.
Your email has been added!
Oops! Something went wrong while submitting the form.
Standard Ruby badge
614.349.4279hello@testdouble.com
Privacy Policy
© 2020 Test Double. All Rights Reserved.