Test doubles: Testing at the boundaries of your Ruby application

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 opens a new window . 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:

  1. 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.
  2. 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:

  1. 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:

  1. Speed and Reliability: Real API calls can be slow and unreliable, slowing down testing and causing frustrating flaky tests.
  2. Data Privacy and Security: Real requests may involve sensitive data, posing security risks and potential privacy breaches.
  3. 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 opens a new window , 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? opens a new window 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 opens a new window 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 opens a new window , 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 opens a new window . 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 opens a new window . 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 opens a new window 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! opens a new window

Happy testing!

Get the book