How to mock an external service locally.

•

•

Working Offline From Somebody Else Api

I was sitting in my kitchen at 3:15 AM, staring at a terminal window that refused to cooperate, wondering why I had ever thought hitting a live staging API during local development was a “good enough” shortcut. We’ve all been there: your tests are passing, but the second you actually try to run the code on your machine, the whole thing falls apart because a third-party sandbox is down or, even worse, it’s returning unexpectedly slow responses. Most tutorials online try to sell you on complex, heavy-duty service virtualization platforms that take longer to configure than the actual feature you’re building. But honestly, if you’re just looking for how to mock an external service locally without losing your mind or your entire weekend, you don’t need a massive enterprise suite; you just need a predictable way to simulate failure.

I’m not here to pitch you a specific framework or tell you that one tool is the “industry standard.” Instead, I want to walk through the practical, slightly battle-scarred ways I’ve handled this across different teams—from simple HTTP interceptors to running lightweight containers. My goal is to show you how to build a setup that actually prevents those middle-of-the-night outages by forcing you to confront edge cases before they hit production.

Table of Contents

Simulating Third Party Api Responses Before They Break Production

Simulating Third Party Api Responses Before They Break Production

The problem with relying on a live sandbox or a staging environment from a vendor is that those environments are rarely as stable as their marketing materials claim. I once spent an entire Tuesday afternoon chasing a “bug” in my own logic, only to realize the third-party payment gateway was undergoing unscheduled maintenance. When you’re simulating third-party API responses, you aren’t just trying to save on network latency; you’re trying to create a deterministic world where your tests actually mean something. If the external service is down, your CI/CD pipeline shouldn’t be down with it.

To do this properly, I’ve moved away from just hitting a real endpoint and hoping for the best. Instead, I lean heavily on test doubles in software development to represent those external boundaries. For simple cases, I’ll use unit testing with dependency injection to swap out a real client for a lightweight stub. But when the logic gets complex—like needing to test how my system handles a 503 error or a malformed JSON payload—I reach for more robust service virtualization techniques. It’s about building a controlled environment where I can intentionally break things, because if I don’t break them on my machine, they’ll definitely break in production.

The High Cost of Failing to Use Test Doubles in Software Development

The High Cost of Failing to Use Test Doubles in Software Development

I’ve learned the hard way that skipping test doubles in software development isn’t just a technical shortcut; it’s a debt that carries a massive interest rate. When you rely on live sandboxes or staging environments for your daily development, you aren’t just testing your code—you’re testing the entire internet. I once spent an entire afternoon staring at a failing build, only to realize the external payment gateway was undergoing scheduled maintenance. My code was fine, but my productivity was zero. When your local environment is tethered to a third party’s uptime, you’ve essentially handed over control of your sprint velocity to someone else’s DevOps team.

The real cost, however, shows up during the debugging phase. Without proper service virtualization techniques, you end up chasing ghosts. If a bug only triggers when an external API returns a 503 or a malformed JSON payload, you can’t easily replicate that on your machine without a mock. You end up writing “defensive” code that is actually just guesswork. By failing to invest in improving developer productivity with mocks, you’re choosing to spend your time troubleshooting network latency and API rate limits instead of actually building features. It’s a slow, expensive way to learn how to write resilient software.

A few things I’ve learned the hard way

  • Don’t just mock the “happy path.” I’ve wasted far too many hours debugging code that worked perfectly in staging only to have it choke on a 503 or a malformed JSON payload in production. Your mocks need to be intentionally annoying sometimes.
  • Avoid the temptation to write complex logic inside your mocks. If your mock starts having its own `if/else` statements to simulate business logic, you aren’t testing your code anymore; you’re just debugging a second, poorly written version of the service you’re trying to replace.
  • Use tools that actually sit on the wire. I used to rely heavily on library-level mocking (like mocking a specific class or function), but that’s brittle. If you change a dependency, your tests still pass even though the real network call would fail. I prefer using something like Prism or WireMock that actually intercepts HTTP traffic.
  • Keep your mock definitions close to the code. I once inherited a repository where the mocks were buried in some massive, centralized “test-utils” folder that nobody dared to touch. Now, I try to keep the contract definitions near the client code so they don’t drift apart over six months of refactoring.
  • Treat your mocks as a contract, not a suggestion. The moment your external provider changes their API schema and your local mocks stay the same, your tests become a lie. It’s worth setting up a periodic “contract test” that runs against the real sandbox environment just to make sure your mocks haven’t become obsolete.

What I’ve learned the hard way

Stop relying on “live” sandbox environments for your local development; they are too slow, too flaky, and they will eventually fail you right when you’re trying to push a hotfix.

A good mock isn’t just about returning a 200 OK; it’s about being able to force a 503 or a timeout so you can actually see if your error handling works before a real provider goes down.

Documentation for your mocks is just as important as the code itself—if I can’t understand what a mocked response is supposed to represent six months from now, the mock is useless.

The Trade-off is the Point

The Trade-off is the Point in testing.

At the end of the day, mocking isn’t about achieving some theoretical state of perfect test coverage; it’s about control. We’ve talked about why relying on live third-party sandboxes is a recipe for flaky CI pipelines and why failing to simulate edge cases eventually leads to that 2 AM pager duty alert. You can go the route of heavy integration tests if you want to prove the whole world works together, but if you want to actually build features without your local environment constantly tripping over a rate-limited API, you need reliable test doubles. It’s a trade-off between the absolute fidelity of a real network call and the predictable, boring stability of a mock, and in a production environment, I will choose boring every single time.

I’ve spent enough time chasing ghosts in the machine to know that most “unexplainable” bugs are just the result of an unhandled state we assumed would never happen. Use mocks to force those states. Don’t just test the happy path where the API returns a 200 OK; test the timeout, the malformed JSON, and the 503 error. If you write your tests to expect the worst from your dependencies, you won’t be surprised by the reality when they actually fail. Build the safety net now, so you can actually sleep through the night later.

Frequently Asked Questions

How do I stop my mocks from becoming "liars" that pass locally but fail the moment they hit the real API?

The problem is that mocks are static snapshots of what we think the API does, not what it actually does. I’ve been burned by this: a mock returns a 200 OK with a perfect JSON payload, but the real service starts returning 429s when rate-limited or changes a field from an integer to a string. To stop the lying, you have to move toward contract testing. Use something like Pact or even just a shared schema definition. If the real API drifts from your mock, your tests should fail immediately. Don’t trust your own assumptions; trust the schema.

At what point does mocking a service become more work than just spinning up a lightweight container?

It’s a tipping point I hit a lot. If you’re just mocking a simple JSON response, a stub is fine. But the moment you need to test complex state transitions—like a payment moving from `pending` to `captured`—maintaining that logic in a mock becomes a second, buggy implementation of the actual service. At that point, I stop fighting the mock and just spin up a lightweight container. It’s better to have a real, containerized dependency than a “perfect” mock that lies to you.

Is there a way to record actual production traffic and replay it for my local tests without leaking sensitive data?

I’ve actually tried this. It’s a tempting way to get “real” data, but unless you’re extremely careful, you’ll end up with PII sitting in your git history or a local JSON file. I usually use a tool like GoReplay to capture the traffic, but you have to pipe it through a scrubbing script immediately. It’s a lot of overhead to set up, so honestly, unless your API logic is incredibly non-deterministic, I’d stick to well-defined mocks.

How do I handle edge cases like rate limiting or 503 errors in a mock without writing a massive, unmaintainable mess of test code?

The mistake I see most often is trying to bake every error scenario into a single, massive mock function. That’s a recipe for a maintenance nightmare. Instead, I use a simple stateful mock or a small middleware layer. I set up a few predefined “scenarios”—like `SUCCESS`, `RATE_LIMITED`, or `SERVER_ERROR`—and just tell the mock which state to inhabit for that specific test. It keeps the test code clean and the failure logic isolated.

Tomasz Wierzbicki

About Tomasz Wierzbicki

I am not interested in arguing about frameworks. I am interested in why the thing broke at two in the morning and what would have prevented it. Most of what I know came from being wrong in production and writing it down afterwards.