Why Mocking Sucks
Mocking can be risky as it doesn't replicate real-world conditions, making real development versions of services preferable.
Join the DZone community and get the full member experience.
Join For FreeDevelopers love mocking. It’s been a go-to solution for years: Simulate external services, run tests faster, and avoid the overhead of real APIs. But here’s the truth — mocking is overused and often dangerous. It deceives you into thinking your system is stable, hiding critical failures that only appear when your code hits the real world. APIs change. Rate limits throttle you. Authentication flows break. Your tests will still pass with green colors while your app crashes in production.
Overusing mocks can turn your tests into a house of cards, giving you false confidence and creating hidden technical debt that slows teams down. In complex workflows — like payments or authentication — mocks fail to capture the true behavior of interconnected services.
In this post, we pull back the curtain on the dangers of mocking, showing you when it works, when it fails miserably, and why relying on mock services can build technical debt faster. With real-world examples like Stripe and Auth0, you’ll see how mocking can backfire and why using real dev versions of services often leads to more robust software.
Why Is Mocking Necessary?
Mocking solves problems that often arise in modern software development, mainly when dealing with multi-tenant SaaS platforms or distributed systems. If you’re working with downloadable or offline software, mocks may not be as critical since your dependencies are within your control. However, mocking can become necessary when your application relies on third-party services — especially APIs you don’t own.
Here’s why you might need to mock in specific scenarios:
- Testing failure scenarios. How can you simulate an API outage, rate limiting, or an error response like
500 Internal Server Error
? With mocking, you can control responses to have confidence in how your application behaves under failure conditions. - Resolving latency issues in tests. External services introduce latency. For example, if you’re testing customer registration through an external API, even a 500 ms response time can add up across hundreds of tests. Mocking replaces real service calls with near-instant simulated responses, allowing tests to run quickly.
- Simulating external services that aren’t ready. Backend APIs or third-party integrations may not be fully available during development in many projects. Mocking helps teams continue their work by simulating those services before they’re ready.
When Mocking Works Well: Simple Service Simulations
Mocking works best when simulating simple, isolated services with predictable behavior. For example, mocking is an excellent option if your app integrates with Stripe and you only need to test customer registration. You can simulate a successful customer registration call or even an API failure to verify your error-handling code — all without ever hitting Stripe’s servers.
from unittest.mock import patch
@patch('requests.post')
def test_successful_registration(mock_post):
# Mock the API response
mock_post.return_value.status_code = 201
mock_post.return_value.json.return_value = {
"id": "cus_12345",
"name": "Test User",
"email": "test@example.com"
}
# Call the function being tested
result = register_customer("Test User", "test@example.com")
# Verify the mock behavior and response
assert result["id"] == "cus_12345"
assert result["name"] == "Test User"
assert result["email"] == "test@example.com"
mock_post.assert_called_once_with(
"https://api.stripe.com/v1/customers",
data={"name": "Test User", "email": "test@example.com"}
)
However, this approach falls apart when your workflow spans multiple services. Imagine testing a full Stripe payment flow: Registering a customer, adding items to a cart, and processing a payment. Mocking each step might seem feasible, but once you combine them, inter-service dependencies, timing issues, and API quirks won’t surface in your tests.
Accurately testing complex workflows is especially critical for applications that use third-party services for authentication. For example, let’s say you are using Auth0 to manage authentication. Mocking here is risky because authentication is mission-critical, and updates can make your mocks obsolete, breaking your app in production. Worse, authentication failures can shatter user trust, leading to frustration, account lockouts, or even security vulnerabilities.
When Mocking Sucks and Why
Revisiting the Stripe example, maintaining mocks for the full simulated flow requires constant updates to match API changes, introduces inconsistencies, and fails to mimic the nuances of real-world interactions.
Here are the issues:
1. Mocking Creates A False Sense Of Security
Mocks only behave the way you program them to. They’ll never catch unexpected changes or errors that might occur with the real service, giving you the illusion that your system is working perfectly.
Even worse, mocks can accidentally break your product by masking breaking changes. Imagine a situation where a third-party API modifies a key response format. If your mocks aren’t updated to reflect this change, your tests will continue to pass while your product experiences hidden failures in production. This false confidence leads to missed bugs, broken functionality, and a potentially massive impact on your users and business.
2. Mocking Increases Maintenance Overhead
APIs evolve. New fields, endpoints, and even minor response tweaks can break your mocks. You’ll constantly need to update your test suite to keep up with these changes, which can result in technical debt that burdens your team.
3. Mocking Encourages Bad Testing Practices
Developers often become complacent with mocks, focusing more on matching expected inputs and outputs than handling real-world edge cases. This leads to over-reliance on happy-path tests that fail to account for errors, latency, or timeouts in real environments.
4. Mocking Decouples You from Reality
Mocks can’t reproduce the unpredictability of real services — rate limiting, version mismatches, or complex state changes in multi-tenant APIs. Tests that never hit real endpoints miss these critical factors, resulting in software unprepared for real-world conditions.
5. Mocking Is An Anti-Pattern For Complex Systems
The more interconnected your services are, the harder it becomes to maintain accurate mocks. In a distributed system, service interactions are dynamic and often undocumented, meaning mocks will never fully reflect actual behavior. Over time, this leads to tests that become brittle and unreliable.
6. Mocking Hinders Real Developer Experience
Developers often miss opportunities to work with real APIs early in development due to over-reliance on mocks. This delays the discovery of integration issues, ultimately shifting the pain to later stages, like QA or production.
So What Is The Solution?
In some cases, mocking may be your only option. When you do, use company-maintained mocks whenever possible, like Stripe’s stripe-mock
, which stays in sync with their API and minimizes maintenance overhead. However, even the best mocks can’t replace the benefits of using sandbox or dev environments provided by real services.
Use sandbox APIs to run realistic integration tests, but be prepared to face latency, rate limits, and downtime. These issues can disrupt your tests and waste time.
Local-First vs. Mock-Driven Development
This “local-first” development approach aligns with broader trends in modern software engineering. Developers are increasingly favoring real, self-contained environments over artificial mocks. Tools like Docker, Kubernetes, and local microservice setups have empowered teams to replicate production-like conditions at every stage of development. The idea is simple: The more your tests reflect reality, the fewer issues you’ll face when deploying to production.
Mocks can still be helpful for specific, isolated tests, but local-first is the future for complex, business-critical systems like authentication.
Here is a table summarizing the differences between mock-driven and local-first development:
Category | Mock-Driven Development | Local-First Development |
---|---|---|
Maintenance | Requires constant updates to stay in sync with evolving APIs. | Minimal maintenance; tested component up to date with production behavior. |
Reliability | Mocks can mask breaking changes and hidden errors. | Real services expose real-world issues at dev time. |
Developer Experience | Delays integration issue discovery until QA or production. | Developers catch and fix integration issues early in development. |
But Isn’t Prod Always Going To Be Different Than Dev?
Your production services will always be different from dev in an operational sense; they’ll have more load and more data if nothing else. But by using local-first development rather than mocks, you can keep developers closer to the reality of production, discovering issues sooner and reducing test maintenance.
Real-World Examples Of Local-First Development Tools
The trend is clear: It’s time to embrace local-first development to provide developers with reliable, production-grade environments they can run locally. By offering real services for development and testing, this approach empowers teams to build with greater confidence and fewer surprises in production.
Firebase Emulator Suite
Firebase, widely used for authentication, real-time databases, and cloud functions, offers a local emulator suite. The tool allows developers to simulate core Firebase services in their development environment, removing the need for fragile mocks.
- You can test real authentication flows, database queries, and cloud function triggers without depending on the live Firebase servers.
- The emulator provides feature parity with production, allowing reliable integration tests free from rate limits and connectivity issues.
Let’s see how mocking a Firebase service and using the Firebase Emulator Suite differ.
A common approach to testing Firebase authentication and Firestore interactions is to mock the Firebase Admin SDK. Below is an example of how developers typically do this in Python.
from unittest.mock import patch, MagicMock
import firebase_admin
from firebase_admin import auth, firestore, credentials
# Initialize Firebase app (mocked in tests)
cred = credentials.Certificate("./service_account_key_example.json")
firebase_admin.initialize_app(cred)
# Function to authenticate a user and retrieve an ID token
def authenticate_user(uid):
user = auth.get_user(uid)
token = auth.create_custom_token(uid)
return {"uid": user.uid, "token": token}
# Function to fetch a document from Firestore
def get_user_data(uid):
db = firestore.client()
doc_ref = db.collection("users").document(uid)
doc = doc_ref.get()
return doc.to_dict() if doc.exists else None
# Unit test with mocks
@patch("firebase_admin.auth.get_user")
@patch("firebase_admin.auth.create_custom_token")
@patch("firebase_admin.firestore.client")
def test_authenticate_user(mock_firestore, mock_create_token, mock_get_user):
# Mock user data
mock_get_user.return_value = MagicMock(uid="12345")
mock_create_token.return_value = "fake-token"
result = authenticate_user("12345")
assert result["uid"] == "12345"
assert result["token"] == "fake-token"
@patch("firebase_admin.firestore.client")
def test_get_user_data(mock_firestore):
# Mock Firestore response
mock_doc = MagicMock()
mock_doc.exists = True
mock_doc.to_dict.return_value = {"name": "John Doe", "email": "john@example.com"}
mock_firestore.return_value.collection.return_value.document.return_value.get.return_value = mock_doc
result = get_user_data("12345")
assert result["name"] == "John Doe"
assert result["email"] == "john@example.com"
While this approach allows for isolated testing, it introduces several problems:
- Mocks do not capture Firebase behavior changes, such as new API parameters or modified authentication flows.
- Firestore queries in production may behave differently from their mocked versions, especially when dealing with security rules and indexes.
- The real Firebase service enforces rate limits and authentication constraints, which mocks ignore.
So, instead of mocking Firebase, you can use the Firebase Emulator Suite to run tests against a fully functional local Firebase instance, which behaves identically to production.
Step 1: Install And Configure Firebase Emulator
Install the Firebase CLI.
npm install -g firebase-tools
Initialize the project.
firebase init
Initialize Firebase Emulators.
firebase init emulators
Start the emulator.
firebase emulators:start
Step 2: Modify Your Code To Use The Emulator
With Firebase running locally, you can modify the authentication and Firestore functions to connect to the emulator instead of mocking API calls.
import firebase_admin
from firebase_admin import auth, firestore, credentials
# Connect to Firebase Emulator
cred = credentials.Certificate("./service_account_key_example.json")
firebase_admin.initialize_app(cred, {
"projectId": "demo-project"
})
# Set Firebase Emulator URLs
import os
os.environ["FIRESTORE_EMULATOR_HOST"] = "localhost:8080"
os.environ["FIREBASE_AUTH_EMULATOR_HOST"] = "localhost:9099"
# Function to authenticate a user using the local Firebase Emulator
def authenticate_user_emulator(uid):
user = auth.get_user(uid)
token = auth.create_custom_token(uid)
return {"uid": user.uid, "token": token}
# Function to fetch user data from Firestore in the Emulator
def get_user_data_emulator(uid):
db = firestore.client()
doc_ref = db.collection("users").document(uid)
doc = doc_ref.get()
return doc.to_dict() if doc.exists else None
# Integration test with the Firebase Emulator
def test_firebase_emulator():
# Create test user
try:
user = auth.create_user(uid="test-user", email="test@example.com", password="password123")
assert user.uid == "test-user"
except firebase_admin._auth_utils.UidAlreadyExistsError:
pass
# Authenticate and get a token
result = authenticate_user_emulator("test-user")
assert "token" in result
# Write user data to Firestore
db = firestore.client()
db.collection("users").document("test-user").set({"name": "John Doe", "email": "test@example.com"})
# Retrieve user data
user_data = get_user_data_emulator("test-user")
assert user_data["name"] == "John Doe"
assert user_data["email"] == "test@example.com"
test_firebase_emulator()
The tests can reflect production-like conditions, catching issues like authentication changes, security rule enforcement, and API updates.
FusionAuth Kickstart
FusionAuth Kickstart allows you to build a template that replicates a development or production environment.
- Developers can spin up a local FusionAuth instance to test full authentication flows, including OAuth and SSO, ensuring tests align with production.
- Kickstart automates environment setup, account creation, and configuration of any resources. Then you have a FusionAuth instance with available data.
- Unlike mocks, this approach handles real-world complexities like security updates and multi-step flows, reducing the risk of surprises in production.
To see how effective a real dev server can be, let’s write a mock that simulates a login on the FusionAuth API.
Here’s how you might mock the login in Python:
import requests
from unittest.mock import patch
import unittest
def fusionauth_login(login_id, password, application_id, base_url="https://sandbox.fusionauth.io"):
url = f"{base_url}/api/login"
headers = {"Content-Type": "application/json"}
data = {
"loginId": login_id,
"password": password,
"applicationId": application_id
}
response = requests.post(url, json=data, headers=headers)
if response.status_code == 200:
return {"status": "success", "token": response.json().get("token")}
elif response.status_code == 404:
return {"status": "error", "message": "User not found or incorrect password"}
elif response.status_code == 423:
return {"status": "error", "message": "User account is locked"}
else:
return {"status": "error", "message": "Unknown error"}
class TestFusionAuthLogin(unittest.TestCase):
@patch("requests.post")
def test_successful_login(self, mock_post):
mock_post.return_value.status_code = 200
mock_post.return_value.json.return_value = {
"token": "fake-jwt-token",
"user": {"id": "12345", "email": "test@example.com"}
}
result = fusionauth_login("test@example.com", "correct-password", "app-123")
self.assertEqual(result["status"], "success")
self.assertIn("token", result)
@patch("requests.post")
def test_invalid_credentials(self, mock_post):
mock_post.return_value.status_code = 404
mock_post.return_value.json.return_value = {}
result = fusionauth_login("test@example.com", "wrong-password", "app-123")
self.assertEqual(result["status"], "error")
self.assertEqual(result["message"], "User not found or incorrect password")
@patch("requests.post")
def test_unknown_error(self, mock_post):
mock_post.return_value.status_code = 500
mock_post.return_value.json.return_value = {}
result = fusionauth_login("test@example.com", "password", "app-123")
self.assertEqual(result["status"], "error")
self.assertEqual(result["message"], "Unknown error")
if __name__ == "__main__":
unittest.main()
The fusionauth_login
function simulates a login request to FusionAuth’s /api/login
endpoint. It handles various responses — success, incorrect credentials, locked accounts, and unexpected errors. The unit tests use unittest.mock.patch
to replace real API calls, ensuring tests pass without needing a live FusionAuth server.
But this approach doesn’t scale. For every new scenario, another mock is needed. More tests mean more mocks, more maintenance, and more fragile tests. What starts as a simple test suite quickly turns into a tangled web of artificial responses, each one detached from reality.
And when FusionAuth updates? Your mocks stay frozen in time. New fields, changed response structures, evolving authentication flows — none of these are reflected in your tests. The mocks keep passing, but your application is broken in production.
The alternative? Run a real FusionAuth instance locally.
Tip: The easiest way to run FusionAuth is in a Docker container. Clone the fusionauth-example-docker-compose GitHub repository. Open a terminal in the
light
subdirectory, and rundocker compose up
in a terminal to start FusionAuth. Log in at http://localhost:9011 withadmin@example.com
andpassword
. The example repository will use the sample Kickstart file to configure the FusionAuth instance.
Below is an outline of the steps you can use to set up a FusionAuth instance with Kickstart and Docker Compose, as configured in the fusionauth-example-docker-compose
repository, to test authentication without mocks.
Step 1: Create The Kickstart File
A Kickstart file is a JSON file containing instructions for setting up an environment.
To create a test case that registers a user, the Kickstart file might look like the example below.
{
"variables": {
"apiKey": "33052c8a-c283-4e96-9d2a-eb1215c69f8f-not-for-prod",
"asymmetricKeyId": "#{UUID()}",
"applicationId": "e9fdb985-9173-4e01-9d73-ac2d60d1dc8e",
"clientSecret": "super-secret-secret-that-should-be-regenerated-for-production",
"defaultTenantId": "d7d09513-a3f5-401c-9685-34ab6c552453",
"adminEmail": "admin@example.com",
"adminPassword": "password",
"adminUserId": "00000000-0000-0000-0000-000000000001",
"userEmail": "richard@example.com",
"userPassword": "password",
"userUserId": "00000000-0000-0000-0000-111111111111"
},
"apiKeys": [
{
"key": "#{apiKey}",
"description": "Unrestricted API key"
}
],
"requests": [
{
"method": "POST",
"url": "/api/key/generate/#{asymmetricKeyId}",
"tenantId": "#{defaultTenantId}",
"body": {
"key": {
"algorithm": "RS256",
"name": "For example app",
"length": 2048
}
}
},
{
"method": "POST",
"url": "/api/application/#{applicationId}",
"tenantId": "#{defaultTenantId}",
"body": {
"application": {
"name": "Example App",
"oauthConfiguration": {
"authorizedRedirectURLs": [
"https://fusionauth.io"
],
"logoutURL": "https://fusionauth.io",
"clientSecret": "#{clientSecret}",
"enabledGrants": [
"authorization_code",
"refresh_token"
],
"generateRefreshTokens": true,
"requireRegistration": true
},
"jwtConfiguration": {
"enabled": true,
"accessTokenKeyId": "#{asymmetricKeyId}",
"idTokenKeyId": "#{asymmetricKeyId}"
}
}
}
},
{
"method": "POST",
"url": "/api/user/registration/#{adminUserId}",
"body": {
"registration": {
"applicationId": "#{FUSIONAUTH_APPLICATION_ID}",
"roles": [
"admin"
]
},
"roles": [
"admin"
],
"skipRegistrationVerification": true,
"user": {
"birthDate": "1981-06-04",
"data": {
"favoriteColor": "chartreuse"
},
"email": "#{adminEmail}",
"firstName": "Erlich",
"lastName": "Bachman",
"password": "#{adminPassword}",
"imageUrl": "//www.gravatar.com/avatar/5e7d99e498980b4759650d07fb0f44e2"
}
}
},
{
"method": "POST",
"url": "/api/user/registration/#{userUserId}",
"body": {
"user": {
"birthDate": "1985-11-23",
"email": "#{userEmail}",
"firstName": "Richard",
"lastName": "Flintstone",
"password": "#{userPassword}"
},
"registration": {
"applicationId": "#{applicationId}",
"data": {
"favoriteColor": "turquoise"
}
}
}
}
]
}
This code declares variables to avoid repetition, and then defines an API key. It then executes a series of requests to:
- Create a new application.
- Create a new user.
- Register the user to the application.
Step 2: Download The Docker Compose And Environment Files
Run the following commands to download the required files:
curl -o docker-compose.yml https://raw.githubusercontent.com/FusionAuth/fusionauth-containers/main/docker/fusionauth/docker-compose.yml
curl -o .env https://raw.githubusercontent.com/FusionAuth/fusionauth-containers/main/docker/fusionauth/.env
Edit these files to match your environment. In the .env
file, modify DATABASE_PASSWORD
and ensure the POSTGRES_USER
and POSTGRES_PASSWORD
are set correctly. Add the FUSIONAUTH_APP_KICKSTART_FILE
environment variable with the path to the Kickstart file, and mount the Kickstart directory as a volume on the fusionauth
service.
Step 3: Start The FusionAuth Containers
Run the following commands to bring up the services:
docker compose up
This command starts three services:
db
- A PostgreSQL instance to store your data.search
- An OpenSearch instance for advanced search features.fusionauth
- The main application handling authentication flows.
Step 4: Explore And Configure FusionAuth
FusionAuth will now be accessible at http://localhost:9011
. You can configure your application, manage users, and integrate authentication workflows from here.
The setup uses OpenSearch by default, but you can modify the docker-compose.yml
file to switch between different search engines if needed.
Step 5: Customize The Services
You can further customize the services by editing the docker-compose.yml
and .env
files. FusionAuth supports various deployment methods (for example, Kubernetes and Helm), making it adaptable to different environments. Learn more about working in a development environment in the development documentation.
Now, you can rewrite the tests without mocking the FusionAuth API.
import requests
BASE_URL = "http://localhost:9011"
APPLICATION_ID = "e9fdb985-9173-4e01-9d73-ac2d60d1dc8e"
API_KEY = "33052c8a-c283-4e96-9d2a-eb1215c69f8f-not-for-prod"
def fusionauth_login(login_id, password, application_id):
"""
Function to authenticate a user against FusionAuth.
"""
url = f"{BASE_URL}/api/login"
headers = {
"Authorization": API_KEY,
"Content-Type": "application/json"
}
data = {
"loginId": login_id,
"password": password,
"applicationId": application_id
}
response = requests.post(url, json=data, headers=headers)
if response.status_code == 200:
return {"status": "success", "token": response.json().get("token")}
elif response.status_code == 404:
return {"status": "error", "message": "User not found or incorrect password"}
else:
return {"status": "error", "message": f"Unknown error: {response.text}"}
def test_successful_login():
"""
Test successful authentication with correct credentials.
The user must exist in FusionAuth before running this test.
"""
result = fusionauth_login("richard@example.com", "password", APPLICATION_ID)
assert result["status"] == "success"
assert "token" in result
def test_invalid_credentials():
"""
Test authentication with invalid credentials.
"""
result = fusionauth_login("richard@example.com", "wrong-password", APPLICATION_ID)
assert result["status"] == "error"
assert result["message"] == "User not found or incorrect password"
def test_unknown_error():
"""
Test handling of unknown errors by using an invalid application ID.
"""
result = fusionauth_login("richard@example.com", "password", "INVALID_APP_ID")
assert result["status"] == "error"
assert "Unknown error" in result["message"]
Integration tests run against a real authentication service within the same network as the application, replicating production-like conditions. The authentication flow is validated end-to-end, ensuring that security headers, and real response times are properly accounted for before deployment.
Final Thoughts: Stop Relying On Mocks
Mocking is risky due to the fact that it does not capture a realistic environment. That is why you should use real dev versions of services for:
- Better production alignment. Your tests reflect real-world conditions, including API changes, security updates, and unexpected behavior.
- Lower maintenance. Real services stay consistent with production, eliminating the need for constant mock updates and reducing technical debt.
- Accurate testing. Complex workflows like authentication, payments, or multi-step integrations behave correctly, uncovering edge cases early.
- Developer confidence. With realistic tests, you ship features knowing they’ll perform reliably in production.
Opinions expressed by DZone contributors are their own.
Comments