The most surprising truth about microservices unit testing is that you don’t test the microservice in isolation, you test the component in isolation, and the "microservice" is just a deployment boundary.

Let’s see this in action. Imagine a simple user service that handles user creation and retrieval.

# user_service.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import uuid

app = FastAPI()

class User(BaseModel):
    id: str
    username: str
    email: str

# In-memory database for simplicity
users_db = {}

@app.post("/users/", response_model=User)
def create_user(user_data: User):
    if user_data.id in users_db:
        raise HTTPException(status_code=400, detail="User ID already exists")
    users_db[user_data.id] = user_data
    return user_data

@app.get("/users/{user_id}", response_model=User)
def get_user(user_id: str):
    user = users_db.get(user_id)
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    return user

# To run this:
# pip install fastapi uvicorn pydantic
# uvicorn user_service:app --reload

Now, how do you unit test this? You don’t want to spin up uvicorn for every test. You want to test the core logic, the create_user and get_user functions, independent of the web framework.

The problem this solves is the classic dependency hell and slow feedback loop of integration testing. When you only test the core business logic functions, your tests are fast, deterministic, and don’t rely on external services (databases, other microservices, network availability). This allows developers to get instant feedback on their code changes.

Internally, the FastAPI app is just a wrapper. The actual work is done by the functions decorated with @app.post and @app.get. These functions take arguments, perform operations (like dictionary lookups or additions), and return values or raise exceptions. This is the "component" we want to test in isolation.

Here’s how you’d unit test the create_user and get_user logic using pytest:

# test_user_logic.py
import pytest
from pydantic import BaseModel
from typing import Dict

# We're going to import and test the core logic directly,
# bypassing the FastAPI framework for unit tests.

# Define the User model and the in-memory database structure
class User(BaseModel):
    id: str
    username: str
    email: str

# Mock the in-memory database
users_db: Dict[str, User] = {}

# --- Mocked Functions (representing the core logic) ---
def create_user_logic(user_data: User, db: Dict[str, User]):
    if user_data.id in db:
        # In a real app, this would be an HTTPException,
        # but for unit testing, we might return an error indicator
        # or raise a specific custom exception. For simplicity here,
        # we'll just return None to indicate failure.
        return None
    db[user_data.id] = user_data
    return user_data

def get_user_logic(user_id: str, db: Dict[str, User]):
    user = db.get(user_id)
    if not user:
        # In a real app, this would be an HTTPException.
        # For unit testing, we'll return None.
        return None
    return user

# --- Pytest Tests ---

def test_create_user_success():
    # Reset the mock database for each test
    global users_db
    users_db = {}
    user_data = User(id="123", username="testuser", email="test@example.com")
    created_user = create_user_logic(user_data, users_db)
    assert created_user is not None
    assert created_user.id == "123"
    assert users_db["123"] == user_data

def test_create_user_duplicate_id():
    global users_db
    users_db = {}
    user_data_1 = User(id="123", username="testuser", email="test@example.com")
    create_user_logic(user_data_1, users_db) # Create the first user
    user_data_2 = User(id="123", username="anotheruser", email="another@example.com")
    created_user = create_user_logic(user_data_2, users_db) # Try to create with same ID
    assert created_user is None # Expecting failure due to duplicate ID
    assert len(users_db) == 1 # Ensure only the first user is in the DB

def test_get_user_success():
    global users_db
    users_db = {}
    user_data = User(id="123", username="testuser", email="test@example.com")
    users_db["123"] = user_data # Pre-populate the DB
    retrieved_user = get_user_logic("123", users_db)
    assert retrieved_user is not None
    assert retrieved_user.id == "123"
    assert retrieved_user == user_data

def test_get_user_not_found():
    global users_db
    users_db = {}
    retrieved_user = get_user_logic("nonexistent_id", users_db)
    assert retrieved_user is None # Expecting None as user is not found

Notice how we’re not importing FastAPI or HTTPException. We’re directly testing the functions and passing in a mock database. This is the essence of isolating the component. The "microservice" is just how you wire these components up to be discoverable and accessible over a network.

The exact levers you control are the inputs to your functions (arguments, mock data, mock dependencies) and the outputs you assert on (return values, exceptions raised, state changes in mocks). For the user_service, the users_db dictionary is the state, and the functions create_user_logic and get_user_logic are the pure functions operating on that state.

The one thing most people don’t know is that you can achieve a surprisingly high level of test coverage by stubbing out the framework’s request/response cycle and directly invoking the business logic functions. This means writing a test that looks like result = my_function(arg1, arg2) and then asserting assert result == expected_value. The framework’s job is to translate incoming HTTP requests into these function calls and then translate the function’s return values or exceptions back into HTTP responses. For unit tests, you skip the translation and just test the core function.

The next concept you’ll run into is how to test the wiring: how your framework (like FastAPI, Spring Boot, or Express) correctly maps HTTP routes to your business logic functions and handles request validation/parsing and response serialization.

Want structured learning?

Take the full Microservices course →