Testing a monolith isn’t about picking one strategy; it’s about orchestrating unit, integration, and end-to-end tests to form a cohesive safety net.

Let’s see this in action. Imagine a simple e-commerce monolith with a UserService, an OrderService, and a ProductService. We’ll need to test how these pieces interact.

Here’s a simplified UserService and OrderService in Python:

class UserService:
    def __init__(self, user_repo):
        self.user_repo = user_repo

    def get_user(self, user_id):
        return self.user_repo.find_by_id(user_id)

    def create_user(self, name, email):
        user = {"name": name, "email": email}
        return self.user_repo.save(user)

class OrderService:
    def __init__(self, order_repo, user_service):
        self.order_repo = order_repo
        self.user_service = user_service

    def place_order(self, user_id, product_id, quantity):
        user = self.user_service.get_user(user_id)
        if not user:
            raise ValueError("User not found")
        
        order_data = {
            "user_id": user_id,
            "product_id": product_id,
            "quantity": quantity,
            "status": "PENDING"
        }
        return self.order_repo.save(order_data)

# Mock repositories for demonstration
class MockUserRepo:
    def __init__(self):
        self.users = {}
        self.next_id = 1

    def find_by_id(self, user_id):
        return self.users.get(user_id)

    def save(self, user_data):
        user_id = self.next_id
        self.users[user_id] = {"id": user_id, **user_data}
        self.next_id += 1
        return self.users[user_id]

class MockOrderRepo:
    def __init__(self):
        self.orders = {}
        self.next_id = 1

    def save(self, order_data):
        order_id = self.next_id
        self.orders[order_id] = {"id": order_id, **order_data}
        self.next_id += 1
        return self.orders[order_id]

# Setting up the services with mocks
user_repo = MockUserRepo()
order_repo = MockOrderRepo()
user_service = UserService(user_repo)
order_service = OrderService(order_repo, user_service)

# Example usage
created_user = user_service.create_user("Alice", "alice@example.com")
placed_order = order_service.place_order(created_user["id"], "PROD123", 2)

print(f"User: {created_user}")
print(f"Order: {placed_order}")

This code shows OrderService depending on UserService. Unit tests would check each service in isolation, while integration tests would verify their interaction, and E2E tests would simulate a user placing an order through the entire application flow.

The core problem monolith testing solves is managing complexity and preventing regressions in a single, large codebase. Without a strategy, changes in one area can silently break unrelated functionality elsewhere. The goal is to build confidence that the system as a whole behaves as expected, by layering tests from the smallest units to the broadest workflows.

Unit Tests: These are the bedrock. They test individual functions or methods in isolation. For UserService, a unit test would verify create_user correctly formats the user data and returns it, without actually touching any database or external system.

# Example Unit Test for UserService.create_user
import unittest
from unittest.mock import MagicMock

class TestUserService(unittest.TestCase):
    def test_create_user(self):
        mock_repo = MagicMock()
        user_service = UserService(mock_repo)
        user_data = {"name": "Bob", "email": "bob@example.com"}
        expected_user = {"id": 1, **user_data}
        mock_repo.save.return_value = expected_user

        result = user_service.create_user("Bob", "bob@example.com")

        mock_repo.save.assert_called_once_with(user_data)
        self.assertEqual(result, expected_user)

if __name__ == '__main__':
    unittest.main()

Here, MagicMock replaces the actual user_repo. We assert that create_user calls repo.save with the correct arguments and returns what the mock repository would provide. This is fast, deterministic, and targets a single piece of logic.

Integration Tests: These focus on the interactions between components or services within the monolith. For our example, an integration test would verify that OrderService.place_order correctly calls UserService.get_user and then OrderRepo.save. We might use in-memory databases or shared test instances of services.

# Example Integration Test for OrderService.place_order
import unittest
from unittest.mock import MagicMock

# Assuming UserService and OrderService classes are defined as above

class TestOrderServiceIntegration(unittest.TestCase):
    def test_place_order_success(self):
        mock_user_repo = MagicMock()
        mock_order_repo = MagicMock()
        
        # Setup mock user service to return a user
        user_service = UserService(mock_user_repo)
        test_user_id = 10
        mock_user_repo.find_by_id.return_value = {"id": test_user_id, "name": "Charlie", "email": "charlie@example.com"}
        
        # Instantiate OrderService with the actual UserService and mock OrderRepo
        order_service = OrderService(mock_order_repo, user_service)
        
        product_id = "PROD456"
        quantity = 1
        expected_order_data = {
            "user_id": test_user_id,
            "product_id": product_id,
            "quantity": quantity,
            "status": "PENDING"
        }
        mock_order_repo.save.return_value = {"id": 1, **expected_order_data}

        result = order_service.place_order(test_user_id, product_id, quantity)

        mock_user_repo.find_by_id.assert_called_once_with(test_user_id)
        mock_order_repo.save.assert_called_once_with(expected_order_data)
        self.assertEqual(result["id"], 1)
        self.assertEqual(result["user_id"], test_user_id)
        self.assertEqual(result["product_id"], product_id)
        self.assertEqual(result["quantity"], quantity)
        self.assertEqual(result["status"], "PENDING")

    def test_place_order_user_not_found(self):
        mock_user_repo = MagicMock()
        mock_order_repo = MagicMock()

        # Setup mock user service to NOT find a user
        user_service = UserService(mock_user_repo)
        test_user_id = 99
        mock_user_repo.find_by_id.return_value = None # User not found

        order_service = OrderService(mock_order_repo, user_service)

        with self.assertRaisesRegex(ValueError, "User not found"):
            order_service.place_order(test_user_id, "PROD789", 5)

        mock_user_repo.find_by_id.assert_called_once_with(test_user_id)
        mock_order_repo.save.assert_not_called() # Order should not be saved

if __name__ == '__main__':
    unittest.main()

Here, UserService is instantiated with a real MockUserRepo (or potentially an in-memory DB instance), but OrderService’s order_repo is mocked. This tests the interaction between UserService and OrderService, and OrderService’s logic for calling its dependencies.

End-to-End (E2E) Tests: These simulate real user scenarios by interacting with the application through its public interfaces (e.g., HTTP API, UI). They are the most comprehensive but also the slowest and most brittle. An E2E test would start a test instance of the entire monolith, potentially with a test database, and then make an API call to create a user and another to place an order, finally asserting that the order exists in the database.

# Conceptual E2E Test Snippet (using a hypothetical HTTP client)
# This assumes you have a running test instance of your monolith
import requests
import unittest

class TestE2EOrderFlow(unittest.TestCase):
    def setUp(self):
        self.base_url = "http://localhost:8000" # URL of your test monolith instance

    def test_user_can_place_order_e2e(self):
        # 1. Create a user via API
        user_payload = {"name": "David", "email": "david@example.com"}
        create_user_response = requests.post(f"{self.base_url}/users", json=user_payload)
        self.assertEqual(create_user_response.status_code, 201)
        user_data = create_user_response.json()
        user_id = user_data["id"]

        # 2. Place an order via API
        order_payload = {"user_id": user_id, "product_id": "PRODXYZ", "quantity": 3}
        place_order_response = requests.post(f"{self.base_url}/orders", json=order_payload)
        self.assertEqual(place_order_response.status_code, 201)
        order_data = place_order_response.json()
        self.assertEqual(order_data["status"], "PENDING")

        # 3. Verify order exists by fetching it (optional, but good practice)
        get_order_response = requests.get(f"{self.base_url}/orders/{order_data['id']}")
        self.assertEqual(get_order_response.status_code, 200)
        fetched_order = get_order_response.json()
        self.assertEqual(fetched_order["user_id"], user_id)
        self.assertEqual(fetched_order["product_id"], "PRODXYZ")

if __name__ == '__main__':
    unittest.main()

This test hits the actual network layer, database, and all business logic. It’s the closest to a real user interaction but requires a fully spun-up environment.

The "testing pyramid" is a useful mental model here: a wide base of fast unit tests, a smaller layer of integration tests, and a very narrow tip of slow E2E tests. This ensures that most regressions are caught quickly and cheaply by unit tests, while the broader scenarios are validated by integration and E2E tests.

A common pitfall in monolith testing is the "test database" strategy. While seemingly robust, using a shared test database for many integration or E2E tests can lead to flaky tests due to unpredictable state. Each test needs to guarantee its own isolated environment. This is often achieved through transactional tests (rolling back after each test) or by spinning up and tearing down dedicated test databases per test suite.

The next challenge you’ll face is managing test data, ensuring your tests are repeatable and not dependent on specific pre-existing data.

Want structured learning?

Take the full Monolith course →