Test doubles: Testing at the boundaries of your Ruby application
One essential tool that we as software developers rely on is known as “test doubles.” These versatile components come in various forms, including dummies, fakes, stubs, spies, and mocks . However, like other power tools, they require careful handling to prevent unintended consequences.
In this post, we’ll explore the strategic use of test doubles at the boundaries of our application, harnessing their full potential while minimizing associated risks.
Intro
Imagine you’re a car mechanic responsible for ensuring the safety and reliability of vehicles. One of your critical tasks is testing the effectiveness of airbags in different car models. However, deploying real airbags every time you run a test is impractical, expensive, and potentially dangerous.
In software development, test doubles serve a similar purpose. Test doubles help validate software functionality, interactions, and behavior efficiently and safely, like using alternatives to real airbags for safety testing in cars.
It’s advisable to minimize the use of test doubles. While they offer advantages, over-reliance on test doubles can lead to passing tests that do not accurately reflect the behavior of your code in a production environment. It’s crucial to strike a balance between their utility and the risk of creating misleading tests.
One key principle to keep in mind is to avoid using test doubles as a tool to simplify tests that would otherwise require complicated setup steps. Let the complexity of test setup serve as a signal, or code smell, indicating areas that might benefit from refactoring. By addressing the root causes of complexity, you can create tests that are more representative of real-world scenarios.
Only when we are fully aware of the advantages and disadvantages that test doubles offer can we utilize them effectively.
Advantages:
- Improved Test Performance: Test doubles shine in scenarios where you need to eliminate expensive operations from your tests. By substituting real implementations with test doubles, you can significantly boost test performance, making your testing process more efficient.
- Dependency Management: Test doubles come to the rescue when dealing with dependencies like network requests that are expensive or have the potential to introduce flakiness into your tests. They allow you to isolate your code under test from these external factors, promoting more controlled and reliable testing.
Disadvantages:
- Lack of Adaptability: One notable drawback of test doubles is that they don’t adapt when your code changes. This means that even if you modify your code, tests using test doubles may continue to pass, potentially masking bugs or unexpected outcomes. It’s essential to carefully maintain and update your test doubles as your code evolves to ensure their accuracy.
The Boundaries
To limit the use of test doubles effectively, it’s beneficial to apply them primarily at the boundaries of your system. In this post, we focus on the following application boundaries:
- Network requests.
- Third-party software clients.
- Environment variables.
- Situations where time affects your application, such as billing periods.
These boundaries are critical areas where using test doubles can enhance the reliability and maintainability of our tests.
Faking network requests
When we add tests, our goal is to validate our application’s correctness. A crucial part of this validation is ensuring our app interacts correctly with the external world, often through API network requests.
However, making real API calls during testing isn’t ideal. Here’s why:
- Speed and Reliability: Real API calls can be slow and unreliable, slowing down testing and causing frustrating flaky tests.
- Data Privacy and Security: Real requests may involve sensitive data, posing security risks and potential privacy breaches.
- Cost Considerations: Incurring expenses, such as charges per API request, can be impractical, especially for frequent testing.
But here’s a potential gotcha: API endpoints might change, and our tests could keep passing, failing to warn us of the API endpoint change. When this happens, we can easily have errors that only show up in the production environment.
To mitigate this risk, it’s important that we:
- Stay Informed: Keep yourself and your team informed about API changes from external services you interact with. API providers often document updates and deprecations.
- Add Endpoint Verification: Consider adding a script or workflow as part of your release cycle to verify that the endpoints your application uses are still active and functioning correctly. This proactive approach can prevent surprises and issues in production.
Example 1
In this example, we’ll explore how to use a test double to replace an actual API response during our tests. For this purpose, we’ll introduce Webmock , a valuable tool that allows us to stub network responses effectively.
To get started, we can add the Webmock gem to our Gemfile and require it in our tests. Webmock empowers us to intercept network requests, preventing accidental real-world requests from occurring during our test runs. Here’s how you can set it up:
# add to Gemfile
group :test do
gem "webmock"
end
Require Webmock in your spec_helper.rb
or equivalent configuration file.
# spec_helper.rb
require "webmock/rspec"
WebMock.disable_net_connect!(allow_localhost: true)
With Webmock in place, we can create a test double by using the stub_request
method to
provide a predefined response for a specific API endpoint. Here’s a simplified example
that listens for requests to a specific URL path and responds with a predefined status
and body:
# Stubs a GET request to "https://example.com/api/request"
WebMock.stub_request(:get, "https://example.com/api/request")
.to_return(status: 200, body: "result body")
In this scenario, any HTTP GET request to https://example.com/api/request
within your test will receive a simulated response with a 200 status code and the body
result body
. This approach allows you to control the behavior of the API response
during testing without relying on actual external services.
Even though Webmock provides extensive options for customizing responses, we should aim to keep our stub requests as general as possible. Don’t require specific headers or parameters if they are not essential.
Example 2
There are times when we need to make authorized API requests. Authorization comes in many forms, and using an API token is one of the common way to make authorized requests.
The modification for this pattern is to add an authorization header with the API token to our stubbed request.
Though adding an authorization header isn’t mandatory, it can provide extra confidence in our tests and prevent us from forgetting to add the token in our requests. Here’s how you can set up a test double with an authorization header using Webmock:
WebMock.stub_request(:get, "https://example.com/api/request")
.with(headers: { Authorization: "Token #{ENV["API_TOKEN"]}".strip })
.to_return(status: 200, body: "result body")
Faking third-party clients
In our exploration of test doubles, we’ve seen how they can effectively stand in for network requests, allowing us to control and verify our application’s behavior during testing. However, there are scenarios where we integrate third-party SDKs or service clients into our applications. These integrations may involve underlying network requests, but in our tests, our primary concern is often the return values from these third-party clients.
When dealing with such integrations, our testing goals shift. We aim to confirm that we’re sending the correct request arguments and that we take the appropriate actions based on expected responses. While test doubles are valuable in achieving this, there’s a potential gotcha to be aware of.
Imagine you update the service client to a newer version that introduces breaking changes. However, your test double remains unaware of these updates. As a result, your tests may continue to pass, even though your application no longer correctly integrates with the service client.
For integration such as this we want to verify that we are sending the right request arguments and we take the correct action based on an expected response.
To address this challenge, it’s prudent to consider adding a manual quality assurance (QA) step to your release cycle. This step involves testing your application with the actual service client, ensuring that it still functions as expected after updates or changes.
Additionaly, we can enhance the reliability of our test doubles by adding tests that use
Ruby’s respond_to?
method.
This way we can ensure that the service client implements the methods we rely on. For instance:
it "implements receive_message" do
client = Slack::Web::Client
expect(client.respond_to?(:receive_messages)).to be_truthy
end
Example 1
Imagine our application is tightly integrated with Slack and relies on the ruby slack client gem to facilitate this integration. Our primary objective is to confirm that we are correctly invoking the Slack functions and providing the required request parameters. This type of testing often involves creating an instance double with specific expectations and return values set.
In this example, we’ll use an instance double for the Slack web client. We expect to call
the conversations_invite
method on the instance double, and it should respond with a
true
boolean value. Here’s how the test is structured:
it "sends the correct message to the correct channel" do
# Create an instance double for the Slack web client
client = instance_double(Slack::Web::Client)
# Stub the constructor of Slack::Web::Client to return our instance double
allow(Slack::Web::Client).to receive(:new).and_return(client)
# Stub the method conversations_invite on the instance double to return true
allow(client).to receive_messages(conversations_invite: true)
# Invoke the method in your application that interacts with Slack
SlackWrapper.send_notification()
# Expect that conversations_invite was called with specific arguments
expect(client).to have_received(:conversations_invite).with({channel: "455", users: anything})
end
Example 2
In certain cases, third-party clients may utilize JSON request bodies, often represented as Ruby hashes. However, including expectations in our tests to validate the entire content of a substantial hash object can lead to unnecessarily complex and lengthy tests.
Rather than burdening our tests with a single expectation covering the entire request hash, a more efficient approach involves setting specific expectations to confirm the presence of essential hash keys and values. This streamlined method simplifies our tests while still ensuring critical aspects are thoroughly checked.
it "sends the correct message to the correct channel" do
# Stub the method conversations_invite on the instance double to return true
client = instance_double(Slack::Web::Client)
allow(Slack::Web::Client).to receive(:new).and_return(client)
allow(client).to receive_messages(conversations_invite: true)
# Invoke the method in your application that interacts with Slack
SlackWrapper.send_notification()
# Expect chat_postMessage to be called with specific arguments
expect(client).to have_received(:chat_postMessage) do |args|
# Ensure we provide the correct channel
expect(args[:channel]).to eq("channel_id")
# Ensure we provide the correct message blocks
expect(args[:blocks].length).to eq(1)
expect(args[:blocks][0]).to eq({type: "section", text: {type: "mrkdwn", text: "Notification message!"}})
end
end
Faking environment variables
If your application follows the 12 factor guidelines , you likely
rely on environment variables to configure and alter its behavior. For instance, imagine
your application sends Slack notifications, and you want these notifications to be
directed to a specific channel only when the SLACK_CHANNEL
environment variable is set.
However, setting the SLACK_CHANNEL
variable globally isn’t a suitable approach when you
need to test both scenarios—when the variable is set and when it isn’t. Ideally, you’d
want to temporarily modify the environment variables relevant to your tests.
By ensuring that these modifications are temporary, you prevent unintended side effects that could impact other parts of your codebase or other tests.
Example 1
The Climate Control gem provides a convenient way to temporarily modify environment variables, akin to stubbing your environment. Within the test block, you can adjust the environment variables as needed, ensuring they remain untouched outside of the test block.
To incorporate Climate Control into your project, add it to your Gemfile. If you do want
to make use of groups, make sure it’s included in the test
group:
gem 'climate_control', group: :test
To modify the environment variables for a specific group of tests, you can utilize the
RSpec around
block:
context "with ENV['SLACK_CHANNEL']" do
around do |example|
ClimateControl.modify SLACK_CHANNEL: "test-channel" do
example.run
end
end
it "makes use of ENV['SLACK_CHANNEL']" do
# example internals omitted
end
end
Utilizing RSpec’s context
block in this manner communicates clearly that this group of
specs operates within an environment with modified variables.
Faking time
Imagine your application relies on time for critical operations, such as polling external services or generating time-sensitive messages. In production, certain actions may depend on the passage of time, like waiting for a response during polling. However, in a testing environment, waiting for long periods is impractical.
To effectively control time within your tests, you can employ the Timecop gem . This gem offers a convenient way to manipulate time and ensure that your tests behave predictably.
If you have a Rails application you could make use of the built in time helpers .
The Rails time helpers does offer many of the same features that timecop provides, but timecop
might be better option if you would like to speed time, using Timecop.scale
, up in tests.
To get started with Timecop, follow these steps:
Step 1: Add Timecop to Your Gemfile
To include Timecop in your project, add it to the test group in your Gemfile:
# Gemfile
group :test do
gem "timecop"
end
This ensures that Timecop is available for your testing environment.
Step 2: Configure Timecop in spec_helper.rb
In your spec_helper.rb
file, configure Timecop and enable
safe mode to ensure safer
usage within your tests. Safe mode enforces the use of Timecop with the block syntax,
preventing unintended time alterations outside the code being tested:
# spec_helper.rb
require "timecop"
Timecop.safe_mode = true # turn on safe mode
With these setup steps in place, you can now use Timecop to manipulate time within your tests, making it an essential tool for controlling time-dependent behaviors and ensuring your tests remain reliable.
Example 1
One useful feature is the scale
method, allowing you to control the speed at which time
passes from the perspective of your application.
For instance, we could scale (speed up) time by 3600 so that every second inside of the test is equal to one real-time hour:
context "when the poller times out" do
it "returns an empty array" do
# Speed up time for the duration of the block
Timecop.scale(3600) do
expect(MyPoller.poll).to eq([])
end
end
end
Example 2
In some cases, your test assertions might rely on the current time, such as verifying that a message includes a timestamp. However, making these assertions dependent on real-time can lead to flaky tests. To overcome this, you can use Timecop to freeze time during the test:
it "includes the current time in the message" do
# Freezing the current time for the duration of the block
Timecop.freeze do
now = Time.now
result = MyNotifier.send_message
expect(result).to include(now.to_s)
end
end
By freezing time within the test block, you can make assertions that involve the current time without introducing test instability. This approach ensures your tests remain reliable, regardless of how long they take to execute.
Faking time with tools like Timecop is a valuable technique for controlling time-dependent behaviors in your tests and verifying your application’s behavior with precision.
Conclusion
In the world of software development, the ability to craft effective and reliable tests is a skill that elevates code quality and project maintainability. Test doubles, such as dummies, fakes, stubs, spies, and mocks, serve as invaluable allies in achieving this goal. By strategically applying these techniques at the boundaries of our application, we harness their full potential while minimizing risks.
As you continue on your journey in software testing, remember that test doubles are powerful tools in your arsenal. With a nuanced understanding of when and how to employ them, you’ll pave the way for robust, predictable, and efficient software development. So, go forth, write tests that stand the test of time, and build software that excels in the real world.
Need help getting your test suite in shape? Our Bonsai service can help you gradually increase your coverage every month Send us a message!
Happy testing!