Skip to main content
Mocking and Stubbing

Mocking & Stubbing with Real-World Craft Tools: A Zencraft Guide

Mocking and stubbing sound like theoretical concepts until you're staring at a test that fails because your code called a real database in CI. If you've ever spent hours debugging a test that breaks only on the build server, or if you're new to unit testing and find the terminology confusing, this guide is for you. We'll explain mocking and stubbing with everyday analogies, show you a repeatable workflow, and point out the common traps that make tests fragile. By the end, you'll be able to decide when to mock, what to stub, and how to keep your tests focused on behavior rather than implementation. Why Mocking and Stubbing Matter — And What Goes Wrong Without Them Imagine you're testing a function that sends an email after a user signs up. Without mocking, your test would actually send an email every time you run it.

Mocking and stubbing sound like theoretical concepts until you're staring at a test that fails because your code called a real database in CI. If you've ever spent hours debugging a test that breaks only on the build server, or if you're new to unit testing and find the terminology confusing, this guide is for you. We'll explain mocking and stubbing with everyday analogies, show you a repeatable workflow, and point out the common traps that make tests fragile. By the end, you'll be able to decide when to mock, what to stub, and how to keep your tests focused on behavior rather than implementation.

Why Mocking and Stubbing Matter — And What Goes Wrong Without Them

Imagine you're testing a function that sends an email after a user signs up. Without mocking, your test would actually send an email every time you run it. That's slow, unreliable (what if the mail server is down?), and potentially dangerous if you accidentally spam real users. Mocking and stubbing let you replace the real email service with a pretend one that you control completely.

The core idea is simple: mocking creates a fake object that records how it was called, while stubbing sets up that fake object to return specific values. But in practice, teams often misuse these techniques. The most common mistake is mocking everything, which leads to tests that pass even when the real code is broken. Another pitfall is stubbing too much, making tests brittle — they break every time you refactor internal details that shouldn't matter.

Without a clear strategy, you might end up with tests that are harder to maintain than the code they test. That's why we need a structured approach: decide what to mock based on ownership and side effects, stub only the minimum necessary data, and verify interactions that truly matter to the behavior under test.

What Happens When You Don't Mock

Consider a function that calls a payment gateway. If you don't mock the gateway, each test run charges a real credit card. That's expensive, slow, and impossible to test edge cases like timeout errors. Your tests become integration tests by accident, and you can't run them reliably on a developer laptop without network access.

The Fragile Test Trap

On the flip side, teams that mock every single dependency often end up with tests that break when they rename a variable or change a private method. The tests are tightly coupled to implementation details. The goal is to find a middle ground: mock external systems you don't control, but use real objects for your own code's internal logic.

Prerequisites: What You Should Settle First

Before you start writing mocks, make sure your testing environment is set up properly. You need a test runner (like pytest or JUnit), a mocking library (like unittest.mock, Mockito, or Moq), and a clear understanding of what your code's boundaries are.

First, identify the seams in your code — places where you can substitute a real implementation with a test double. Common seams are constructor parameters, method arguments, and dependency injection containers. If your code doesn't have seams, you'll need to refactor it before you can mock effectively. For example, instead of calling requests.get directly inside a function, pass an HTTP client as a parameter.

Second, decide on a testing philosophy. Some teams prefer to mock only at the system boundaries (external APIs, databases, file systems), while others mock more liberally. Both approaches have trade-offs. The key is to be consistent within your project so that everyone understands what a mock means.

Setting Up Your Mocking Library

In Python, unittest.mock is built into the standard library. For Java, Mockito is the most popular choice. For .NET, Moq is widely used. Install the library and learn its basic syntax: how to create a mock, set up return values, and verify calls. Most libraries follow a similar pattern: create, configure, use, verify.

Understanding Test Doubles

It helps to distinguish between stubs, mocks, fakes, and spies. A stub returns canned answers. A mock also records expectations about how it was called. A fake is a lightweight implementation that works like the real thing but is simpler (like an in-memory database). A spy wraps a real object and records calls. For most unit tests, you'll use stubs for data and mocks for verifying interactions.

Core Workflow: A Step-by-Step Process for Mocking and Stubbing

Here's a repeatable workflow that works for most scenarios. We'll illustrate it with a concrete example: testing a function that fetches user data from an API and caches it in a database.

First, identify the system under test (SUT). In our example, the SUT is the function get_user(user_id) that calls an HTTP API and then stores the result in a cache. Next, list the dependencies — the SUT depends on an HTTP client and a cache client, both external to the logic we want to test. Decide which dependencies to mock: we'll mock the HTTP client because it makes network calls, and we might also mock the cache client if we want to verify that data is stored correctly. If the cache is an in-memory object we control, we could use a real instance instead. Then create the mocks: in Python, mock_http = MagicMock() and mock_cache = MagicMock(); in Java with Mockito, HttpClient httpClient = mock(HttpClient.class). Stub the necessary calls — for the HTTP client: mock_http.get.return_value = {'name': 'Alice', 'email': '[email protected]'}. For the cache, stub a get to return None to simulate a cache miss. Inject the mocks into the SUT, call the SUT with test inputs, assert on the output, and finally verify interactions on the mocks — for example, assert that the HTTP client was called with the correct URL, or that the cache's set method was called with the right data. This workflow keeps the test focused on behavior: you're testing that get_user correctly fetches data and caches it, not that the HTTP library works.

Example: Testing a Caching Layer

Let's say the SUT first checks the cache. If the cache has the data, it returns it without calling the API. To test this, stub the cache to return a value, and verify that the HTTP client is never called. That's a clean way to test caching behavior without hitting the network.

Tools, Setup, and Environment Realities

Choosing the right mocking tool depends on your language and project. For Python, unittest.mock is sufficient for most needs. For Java, Mockito is feature-rich and integrates with JUnit. For JavaScript, Jest has built-in mocking. For .NET, Moq is a solid choice.

Environment realities often complicate testing. For example, if your code reads environment variables, you might need to mock os.environ or use a library like python-dotenv with a test-specific .env file. Similarly, if your code uses datetime.now(), you should mock the time to make tests deterministic.

One common challenge is mocking modules or classes that are imported inside a function. In Python, you can use patch as a context manager or decorator. In Java, you can use PowerMock for static methods, but it's better to refactor the code to use dependency injection instead. The more you rely on mocking frameworks to work around poor design, the harder your tests become to maintain.

Setting Up a Test Environment for CI

Make sure your CI pipeline runs tests in an isolated environment. Use containers or virtual environments to avoid dependency conflicts. If your tests mock external services, you don't need network access, so they can run quickly in parallel. This is a major advantage of good mocking: your tests become fast and reliable.

Variations for Different Constraints

Not all projects have the same constraints. Here are common variations and how to adjust your approach.

Legacy Code Without Dependency Injection

If you're working with legacy code that creates its own dependencies inside methods (using new or direct calls), you can't easily inject mocks. In this case, you can use a mocking framework that allows monkey-patching (like unittest.mock.patch in Python or PowerMock in Java). However, this is a temporary solution. The better long-term fix is to refactor the code to use dependency injection, even if it's a gradual process.

Testing Asynchronous Code

Async code adds complexity because you need to mock coroutines. In Python, you can use AsyncMock from unittest.mock. In JavaScript, Jest's mocking works with async functions. The key is to ensure that your mocks return awaitable objects. For example, mock_async_func.return_value = some_value won't work; you need mock_async_func.return_value = asyncio.Future() or use AsyncMock.

Microservices and External APIs

When your service depends on another microservice, mocking the HTTP client is standard. But if you want to test the contract between services, consider using a tool like WireMock to stub the actual HTTP server. This is more integration-test-like but still avoids calling the real service. It's useful for testing error handling and timeouts.

Pitfalls, Debugging, and What to Check When It Fails

Even with a solid workflow, mocks can cause confusing test failures. Here are the most common issues and how to debug them.

Mock Not Returning the Expected Value

Often, you set up a stub but the mock returns a different object or raises an error. Check that you're stubbing the correct method name and that the mock is actually being called. Use a debugger or add print statements to see what methods are invoked on the mock. Many mocking libraries have a call_args attribute that shows the last call.

Over-Mocking Leading to False Positives

If you mock too many dependencies, your test might pass even when the real code is broken. For example, if you mock a validation function, you might miss that the validation has a bug. The fix is to use real objects for your own code's logic and only mock external boundaries. A good rule of thumb: mock what you don't own.

Brittle Tests Due to Implementation Details

If your test verifies that a specific method was called with exact arguments, it will break if you refactor the internals. Instead, verify the outcome (the return value or state change) rather than the exact call sequence. Only verify interactions when they are part of the contract, such as ensuring a payment is processed.

Debugging Checklist

  • Is the mock correctly injected into the SUT?
  • Are you stubbing the right method with the right arguments?
  • Is the mock being called at all? Check assert_called_once or similar.
  • Are you using the correct mock type (MagicMock vs Mock vs AsyncMock)?
  • Are there side effects from other mocks interfering?

FAQ and Prose Checklist for Review

Before you finalize your test, run through this checklist in prose form to catch common oversights.

Are you testing behavior, not implementation? If your test breaks when you rename a private method, you're testing implementation. Refactor the test to focus on the observable behavior.

Are you mocking at the right level? Mock at the boundary of your system. For a function that calls an API, mock the HTTP client. Don't mock the JSON parser inside the function — that's part of your logic.

Are your stubs realistic? Stub return values that resemble real data. If your stub returns a string where the real API returns a dictionary, your test might pass but the code will fail in production.

Do you verify interactions that matter? Only verify that a method was called if it's essential to the behavior. For example, verify that a notification was sent, but don't verify that a logging method was called unless logging is critical.

Is your test deterministic? Remove randomness and time-based conditions. Mock datetime.now() and random number generators to ensure the test always produces the same result.

What to Do Next: Specific Actions to Improve Your Tests

Now that you have a solid understanding of mocking and stubbing, here are concrete next steps.

  1. Audit your current test suite. Look for tests that mock too much or too little. Identify tests that are slow because they hit real databases or APIs. These are candidates for refactoring.
  2. Refactor one module to use dependency injection. Pick a module that's hard to test. Add constructor parameters for its dependencies. Then write a test using mocks for those dependencies.
  3. Write a test for a function that calls an external API. Use a mock to simulate both success and error responses. Verify that your code handles timeouts and HTTP errors gracefully.
  4. Set up a CI pipeline that runs your mocked tests without network access. This ensures your tests are fast and reliable. If a test fails because of network issues, you'll know it's a mocking problem.
  5. Share this guide with your team. Agree on a consistent approach to mocking. Document your conventions in a testing style guide so that everyone writes tests that are easy to understand and maintain.

Mocking and stubbing are powerful tools, but they require discipline. Use them to isolate your code from external dependencies, but don't let them isolate your tests from reality. With the workflow and checklists in this guide, you'll write tests that are both fast and trustworthy.

Disclaimer: This article provides general guidance on software testing practices. For specific advice on your project's architecture or testing strategy, consult your team's senior developers or a qualified software engineering consultant.

Share this article:

Comments (0)

No comments yet. Be the first to comment!