DEV Community

Germán Alberto Gimenez Silva
Germán Alberto Gimenez Silva

Posted on • Originally published at rubystacknews.com on

Simulating External APIs in RSpec: A Clean Approach Using WebMock

June 6, 2025

Abstract

When testing systems that depend on external services, such as geolocation APIs, simulating HTTP responses becomes essential for building deterministic and isolated test suites. This article explores a disciplined approach to mocking HTTP calls in RSpec using WebMock—without relying on custom helpers or implicit abstractions. Instead, we emphasize clean code principles: clarity, locality, and test independence.


Let’s Talk Clean Code & Testing

If this article sparked your interest in writing expressive, reliable specs using WebMock, I’d love to connect. Whether you’re into code reviews, Ruby testing patterns, or API architecture, drop me a message!

Get in Touch


Introduction

In modern web applications, integration with third-party APIs is unavoidable. While these dependencies provide valuable services (e.g., geocoding, payment processing), they pose a challenge for testability: tests must not depend on the availability or behavior of external systems.

Article content

Tools like WebMock allow developers to stub HTTP requests and simulate API responses, enabling unit and integration tests to run reliably and quickly. Yet, the way we integrate WebMock into our test suite matters. Overuse of abstraction or indirection can make tests hard to follow or maintain.

This article presents a case study of using WebMock with RSpec to simulate HTTP responses clearly and effectively, following clean code principles without resorting to helper methods or external dependencies like VCR.


Core Concepts

Isolation: Tests should not reach out to external services. They must fail for changes in application logic, not network availability.

Determinism: A test should produce the same result each time it runs.

Clarity: Tests are not just for machines. They should act as documentation for future developers (and your future self).

Minimal Abstraction: Avoid extracting logic into helpers unless repeated across many examples. Indirection harms readability when overused.


Implementation

Let’s walk through a real-world scenario using a geocoding service.

Example: Geocoding in a Model

We want to test a method that fetches coordinates for a given location using the OpenCage Data API. Below is a self-contained example of how to stub those responses cleanly.


# spec/models/location_spec.rb
require "webmock/rspec"

RSpec.describe Location, type: :model do
  before(:each) { Location.delete_all }

  describe ".find_or_create_with_coordinates" do
    it "creates a location using latitude and longitude from the geocoding API" do
      lat = 41.38879
      lng = 2.15899
      address = "Barcelona, Catalonia, Spain"
      query = "Barcelona,Spain"

      stub_request(:get, "http://api.opencagedata.com/geocode/v1/json")
        .with(query: hash_including({ q: query }))
        .to_return(
          status: 200,
          headers: { "Content-Type" => "application/json" },
          body: {
            status: { code: 200 },
            results: [
              {
                geometry: { lat: lat, lng: lng },
                formatted_address: address
              }
            ]
          }.to_json
        )

      location = Location.find_or_create_with_coordinates(location_name: "Barcelona", country: "Spain")

      expect(location).to be_persisted
      expect(location.latitude).to eq(lat)
      expect(location.longitude).to eq(lng)
    end
  end
end

Enter fullscreen mode Exit fullscreen mode

Using Fixtures for Repeatability

Instead of writing JSON directly in each test, we can store the full response as a fixture for reuse and clarity. This maintains the test’s determinism and expressiveness without abstraction via helpers.

Example:


# spec/fixtures/opencage/barcelona.json
{
  "status": { "code": 200, "message": "OK" },
  "results": [
    {
      "geometry": { "lat": 41.38879, "lng": 2.15899 },
      "formatted_address": "Barcelona, Catalonia, Spain"
    }
  ]
}

Enter fullscreen mode Exit fullscreen mode

In the spec:


body = File.read(Rails.root.join("spec/fixtures/opencage/barcelona.json"))
stub_request(:get, /opencagedata.com/)
  .to_return(status: 200, body: body, headers: { "Content-Type" => "application/json" })

Enter fullscreen mode Exit fullscreen mode

Design Choices and Justification

Article content

  1. Avoiding Helpers: While helpers are useful when stubbing is repeated across multiple examples, they can obscure the cause-effect relationship between test setup and assertion. We opt to keep stubs close to the logic they support.
  2. Explicit Stubs: Each test shows exactly what external data the system is consuming and how it behaves. This aids in understanding both business logic and API assumptions.
  3. Optional Fixtures: For larger payloads, fixtures allow storing sample JSON responses while keeping the spec clean and readable.
  4. Clear Variable Naming: Variables like lat, lng, and query make expectations easy to follow, both for developers and reviewers.

Conclusion

Mocking external APIs in tests is not just about eliminating network calls—it’s an opportunity to write expressive, robust documentation for how your code interacts with third-party systems. By keeping stubs explicit and close to the tests they support, we increase maintainability without sacrificing readability.

This approach embraces simplicity and transparency over abstraction, resulting in tests that are both isolated and communicative.


Article content

References

Top comments (0)