Practical Strategies for Backend Testing with Mocks, Stubs, and Fakes
Ethan Miller
Product Engineer · Leapcell

Introduction
In the intricate world of backend development, ensuring the reliability and robustness of our applications is paramount. This often translates to rigorously testing various components, from business logic to database interactions and external API calls. However, typical unit or integration tests can become cumbersome, slow, or even impossible when dependent services are unavailable, expensive to set up, or introduce non-determinism. This is where the strategic application of test doubles—specifically Mocks, Stubs, and Fakes—becomes incredibly valuable. By intelligently replacing real dependencies with controlled substitutes, we can achieve true unit isolation, accelerate test execution, and create predictable testing environments. This article delves into the effective utilization of these powerful techniques to streamline backend testing processes.
Core Concepts Explained
Before diving into their application, it's crucial to understand the distinct roles of Mocks, Stubs, and Fakes. While often used interchangeably, they serve different purposes within the testing landscape.
-
Stubs: A Stub is an object that holds predefined answers to method calls made during a test. It essentially provides canned responses to method calls, ensuring that the test does not depend on external systems. Stubs are primarily concerned with providing data to the system under test (SUT). You assert against the SUT's behavior, not the stub itself.
-
Mocks: A Mock is a more sophisticated type of test double. Like a stub, it can return predefined values. However, a mock also allows us to verify interactions with it. We can assert that specific methods were called, how many times they were called, and with what arguments. Mocks are critical for testing interactions between objects and verifying command invocation.
-
Fakes: A Fake is a lightweight implementation of an interface or class, often used for testing, that has some working behavior but is not suitable for production. A common example is an in-memory database used for tests instead of a real database server. Fakes actually do something, even if it's a simplified version of the real thing.
Effective Implementation and Application
Let's illustrate these concepts with practical examples, focusing on a common backend scenario: a user service that interacts with a database and an external email service.
Consider a simple UserService
in Python that needs to create a user and send a welcome email.
# user_service.py class User: def __init__(self, user_id, name, email): self.user_id = user_id self.name = name self.email = email class Database: def save_user(self, user): print(f"Saving user {user.name} to database...") # Simulates actual database interaction pass class EmailService: def send_email(self, recipient, subject, body): print(f"Sending email to {recipient} with subject '{subject}'...") # Simulates actual email sending pass class UserService: def __init__(self, db: Database, email_service: EmailService): self.db = db self.email_service = email_service def create_user(self, user_id, name, email): user = User(user_id, name, email) self.db.save_user(user) self.email_service.send_email(email, "Welcome!", f"Hello {name}, welcome to our service!") return user
Using Stubs for Controlled Data
When testing create_user
, we want to ensure the UserService
correctly processes user data, irrespective of the actual database or email sending mechanism. A stub for the Database
can provide a controlled environment.
# test_user_service_stub.py import unittest from unittest.mock import MagicMock from user_service import UserService, User, Database, EmailService class TestUserServiceWithStubs(unittest.TestCase): def test_create_user_saves_and_sends_welcome_email(self): # Stub the database to ensure .save_user doesn't interact with a real DB # For a stub, we might just use a MagicMock without configuring return values # if the method doesn't return anything that the SUT depends on. mock_db = MagicMock(spec=Database) mock_email_service = MagicMock(spec=EmailService) # Acts as a stub here, we don't verify calls yet user_service = UserService(mock_db, mock_email_service) user = user_service.create_user("123", "John Doe", "john@example.com") self.assertIsInstance(user, User) self.assertEqual(user.name, "John Doe") # In a stub test, we usually don't verify interactions with the stub itself, # but rather the SUT's output or state changes. # However, for demonstration, we show how MagicMock can be used as a stub. # We'd typically verify UserService's return value or internal state if it had any. # When treating mock_db and mock_email_service purely as stubs, # we're primarily interested in the behavior of UserService itself. # The fact that create_user returns a User object is our primary assertion.
In this stub example, MagicMock(spec=Database)
ensures that mock_db
behaves like a Database
object but doesn't actually connect to a database. We're primarily focused on the UserService
's return value (user
object), making mock_db
and mock_email_service
serve as stubs that fulfill dependencies without complex behavior.
Using Mocks for Interaction Verification
Now, let's use mocks to verify that save_user
on the database and send_email
on the email service were indeed called with the correct arguments.
# test_user_service_mock.py import unittest from unittest.mock import MagicMock from user_service import UserService, User, Database, EmailService class TestUserServiceWithMocks(unittest.TestCase): def test_create_user_interacts_with_dependencies_correctly(self): mock_db = MagicMock(spec=Database) mock_email_service = MagicMock(spec=EmailService) user_service = UserService(mock_db, mock_email_service) user = user_service.create_user("123", "John Doe", "john@example.com") # Assertions about INTERACTIONS with the mocks: mock_db.save_user.assert_called_once_with(user) mock_email_service.send_email.assert_called_once_with( "john@example.com", "Welcome!", "Hello John Doe, welcome to our service!" ) self.assertIsInstance(user, User) self.assertEqual(user.name, "John Doe")
Here, mock_db
and mock_email_service
are used as mocks. We configure no return values because their methods (save_user
, send_email
) don't return anything the UserService
consumes. The key difference is the assert_called_once_with
methods, which explicitly verify the interactions and the arguments passed to those interactions. This is crucial for ensuring the UserService
correctly orchestrates calls to its dependencies.
Using Fakes for Simplified Behavior
Imagine our Database
class has a more complex find_user
method, and we want to test a UserService
method that reads from the database. A fake database can provide a simple, in-memory implementation.
# user_service.py (additional method) # ... (existing classes) class UserService: # ... (existing __init__ and create_user) def get_user_by_id(self, user_id): return self.db.find_user(user_id)
# test_user_service_fake.py import unittest from user_service import UserService, User, Database, EmailService # A Fake Database implementation class FakeDatabase(Database): def __init__(self): self.users = {} # In-memory storage def save_user(self, user): self.users[user.user_id] = user print(f"Fake DB: Saved user {user.name}") def find_user(self, user_id): print(f"Fake DB: Finding user {user_id}") return self.users.get(user_id) class TestUserServiceWithFakes(unittest.TestCase): def test_get_user_by_id_retrieves_user_from_fake_db(self): fake_db = FakeDatabase() mock_email_service = MagicMock(spec=EmailService) # Email service can still be a mock/stub user_service = UserService(fake_db, mock_email_service) # First, create a user using the FakeDatabase's save_user user_service.create_user("456", "Jane Doe", "jane@example.com") # Now, test the get_user_by_id method using the data stored in the FakeDatabase retrieved_user = user_service.get_user_by_id("456") self.assertIsNotNone(retrieved_user) self.assertEqual(retrieved_user.name, "Jane Doe") self.assertEqual(retrieved_user.email, "jane@example.com") self.assertEqual(retrieved_user.user_id, "456")
In this scenario, FakeDatabase
is a fake. It implements the Database
interface but uses an in-memory dictionary to store users instead of a real database. This allows us to test get_user_by_id
including some simplified database-like behavior, without the overhead or complexity of a real database connection. Fakes are excellent for integration tests that still need to be fast and controlled.
Conclusion
Effectively using Mocks, Stubs, and Fakes is a cornerstone of robust backend testing. Stubs provide simplified data, Mocks verify interactions, and Fakes offer lightweight working implementations, each serving a distinct role in achieving test isolation and efficiency. Mastering these test doubles allows developers to build confidence in their code, knowing that each unit performs its duty reliably under controlled and predictable conditions. These tools empower rapid, reliable verification of backend components.