Fourth article in the series about building Ruby gems. We already have a working wrapper with the Client → Resource → Object pattern. Now we need to make sure it works. And that it keeps working tomorrow.
The elephant in the room
Your gem makes HTTP calls to an external API. How do you test it?
You’re not going to make real requests in your tests. You don’t want to depend on the API being available, you don’t want to burn through your API quota, and you don’t want tests that take 30 seconds waiting for server responses.
The solution is to simulate HTTP responses. You tell your test: “when someone makes a GET to this URL, return this JSON.” And your code has no idea the API isn’t actually there.
For this, two tools exist: Minitest and WebMock.
Minitest: what comes with Ruby
Minitest ships with Ruby. You don’t need to install anything to have a complete testing framework. It fits the same philosophy we’ve been following: use what Ruby gives you before looking elsewhere.
Could you use RSpec? Sure. But for a gem with clear, straightforward tests, Minitest is more than enough. Less magic, less configuration, same results.
A test in Minitest looks like this:
require "test_helper"
class EventsResourceTest < Minitest::Test
def test_retrieve
event = client.events.retrieve(uuid: "abc123")
assert_equal Calendlyr::Event, event.class
assert_equal "15 Minute Meeting", event.name
end
end
If you’ve written tests before, this won’t surprise you. A method that starts with test_, a couple of assertions, and done. No mystery.
WebMock: mocking the API
WebMock intercepts all HTTP calls and lets you define what responses to return. When your gem tries to make a real GET, WebMock captures it and returns whatever you’ve told it to.
The setup is minimal. In your gemspec:
spec.add_development_dependency "webmock", "~> 3.23"
And in your test_helper.rb:
require "webmock/minitest"
With that, WebMock automatically blocks any real HTTP request in your tests. If any test tries to make a request you haven’t stubbed, it fails. This is good: it forces you to be explicit about what you expect.
Fixtures: your predefined responses
Instead of writing inline JSON in each test, you can save responses in files. These are fixtures: JSON files that simulate what the API would return.
test/fixtures/
├── events/
│ ├── list.json
│ └── retrieve.json
├── users/
│ └── retrieve.json
└── ...
A fixture file looks exactly like the real API response:
{
"resource": {
"uri": "https://api.calendly.com/scheduled_events/GBGBDCAADAEDCRZ2",
"name": "15 Minute Meeting",
"status": "active",
"start_time": "2019-08-24T14:15:22Z",
"end_time": "2019-08-24T14:15:22Z"
}
}
You copy it from the API documentation or from a real response, and save it. This way your tests work with realistic data without making anything up.
The test_helper: the glue
The test_helper.rb is where everything connects. Here you set up Minitest, WebMock, and the helpers you’ll use across all your tests:
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
require "simplecov"
SimpleCov.start
require "my_gem"
require "minitest/autorun"
require "webmock/minitest"
class Minitest::Test
def client
@client ||= MyGem::Client.new(token: "fake")
end
def fixture_file(fixture)
File.read("test/fixtures/#{fixture}.json")
end
def stub(path:, method: :get, body: {}, response: {})
stub_req = stub_request(method, "#{MyGem::Client::BASE_URL}/#{path}")
stub_req.with(body: body) if %i[post put patch].include?(method)
stub_req.to_return(**response)
stub_req
end
end
Three helpers and you have everything you need:
client: a client with a fake token. It never makes real requests.fixture_file: reads a JSON file from fixtures.stub: configures WebMock to intercept a specific URL and return a predefined response.
The token is "fake" because it never reaches the real API. WebMock intercepts it first. You don’t need real credentials to test.
Anatomy of a real test
Let’s see how it all connects. This is a real test from Calendlyr:
class EventsResourceTest < Minitest::Test
def setup
@event_uuid = "abc123"
stub(
path: "scheduled_events/#{@event_uuid}",
response: { body: fixture_file("events/retrieve"), status: 200 }
)
end
def test_retrieve
event = client.events.retrieve(uuid: @event_uuid)
assert_equal Calendlyr::Event, event.class
assert_equal "https://api.calendly.com/scheduled_events/GBGBDCAADAEDCRZ2", event.uri
assert_equal "15 Minute Meeting", event.name
end
def test_list
stub(
path: "scheduled_events?user=https://api.calendly.com/users/abc123",
response: { body: fixture_file("events/list"), status: 200 }
)
events = client.events.list(user: "https://api.calendly.com/users/abc123")
assert_equal Calendlyr::Collection, events.class
assert_equal Calendlyr::Event, events.data.first.class
assert_equal 1, events.data.count
end
end
Step by step:
- In
setup, we stub the endpoint with the corresponding fixture. - In the test, we call the gem method just like a user would.
- WebMock intercepts the request and returns our fixture.
- We verify the gem parses the response correctly.
We never touch the real API. The test runs in milliseconds. And if it fails, we know exactly what broke.
Testing errors: the sad path
Don’t just test the happy path. Your gem needs to handle errors correctly:
def test_handle_response_error
stub(
path: "users/me",
response: { body: '{"title":"Not Found","message":"Resource does not exist"}', status: 404 }
)
assert_raises Calendlyr::NotFound do
client.users.retrieve(uuid: "me")
end
end
We stub a 404, call the method, and verify it raises the correct exception. That simple.
In Calendlyr I test every API error code: 400, 401, 403, 404, 429, 500. Each has its own error class. If the API returns a 429 (rate limit), the gem retries automatically. And that’s tested too.
Coverage: knowing how much you covered
SimpleCov shows you what percentage of your code is covered by tests. It’s not a perfect metric — 100% coverage doesn’t mean zero bugs — but it’s a good indicator that you haven’t left anything important untested.
The setup is two lines at the top of your test_helper.rb:
require "simplecov"
SimpleCov.start
After running the tests, it generates an HTML report in coverage/. Open it and you’ll see line by line which code ran during the tests and which didn’t.
CI: let the tests run themselves
GitHub Actions lets you run tests automatically on every push and every PR. The minimal setup:
name: Tests
on:
push:
branches: [master]
pull_request:
jobs:
tests:
runs-on: ubuntu-latest
strategy:
matrix:
ruby: ["3.2", "3.3", "3.4"]
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
- run: bundle exec rake test
This runs your tests across three Ruby versions. If something fails, you know before merging. In Calendlyr I also run StandardRb (linter) and upload coverage to Codecov. All in the same workflow.
Is CI necessary from day one? No. Is it recommended? Absolutely. It takes 5 minutes to set up and gives you confidence in every change.
What I learned testing a gem
Testing a gem is no different from testing anything else. It’s Ruby. They’re classes. They’re methods that take something in and return something out.
The only difference is that your gem speaks HTTP, and that’s what WebMock is for. The rest is what you already know: prepare data, execute code, verify results.
In Calendlyr I have tests for every Resource, for the Object, for the Client, for error handling, for pagination. The entire test suite runs in under a second. There’s no excuse not to test.
Look at what we needed:
- Minitest — ships with Ruby
- WebMock — one development dependency
- SimpleCov — one development dependency
- Fixtures — JSON files you copy from the API
No heavy frameworks. No complicated mocks. No endless configuration.
In the final article of the series we close the loop: publishing your gem to RubyGems, semantic versioning, and the reality of long-term maintenance. Spoiler: it’s easier than you think.