Testing microservices in isolation is crucial for ensuring individual service reliability before integrating them.
Let’s see this in action with a simple example. Imagine we have two services: user-service and order-service. The order-service depends on user-service to validate user IDs before creating an order.
Here’s a simplified order-service Python code using Flask:
# order_service.py
from flask import Flask, request, jsonify
import requests
app = Flask(__name__)
@app.route('/orders', methods=['POST'])
def create_order():
data = request.get_json()
user_id = data.get('user_id')
item = data.get('item')
if not user_id or not item:
return jsonify({"error": "user_id and item are required"}), 400
# Assume user-service is running on http://localhost:5001
user_service_url = f"http://localhost:5001/users/{user_id}"
try:
response = requests.get(user_service_url)
response.raise_for_status() # Raise an exception for bad status codes
user_data = response.json()
if not user_data.get("exists"):
return jsonify({"error": f"User {user_id} not found"}), 404
except requests.exceptions.RequestException as e:
return jsonify({"error": f"Error communicating with user-service: {e}"}), 500
# Simulate order creation
order_id = f"ORD-{hash(item + user_id)}"
return jsonify({"order_id": order_id, "user_id": user_id, "item": item}), 201
if __name__ == '__main__':
app.run(port=5000, debug=True)
And a basic user-service (which we’ll mock):
# user_service.py (for reference, not run in isolation test)
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route('/users/<user_id>', methods=['GET'])
def get_user(user_id):
# In a real scenario, this would query a database
if user_id == "user123":
return jsonify({"exists": True, "user_id": user_id, "name": "Alice"})
else:
return jsonify({"exists": False}), 404
if __name__ == '__main__':
app.run(port=5001, debug=True)
Now, let’s write an isolated test for order-service using pytest and requests_mock. We want to test order-service’s POST /orders endpoint without actually starting user-service.
First, install necessary libraries:
pip install pytest requests requests-mock
Create a test file, e.g., test_order_service.py:
# test_order_service.py
import pytest
import json
from order_service import app # Import your Flask app
import requests_mock
@pytest.fixture
def client():
"""Configures Flask app for testing."""
app.config['TESTING'] = True
with app.test_client() as client:
yield client
@pytest.fixture
def mock_user_service():
"""Mocks the user-service requests."""
with requests_mock.Mocker() as m:
yield m
def test_create_order_success(client, mock_user_service):
"""Tests successful order creation when user exists."""
user_id_to_mock = "user123"
item_to_order = "Laptop"
# Mock the GET request to user-service
mock_user_service.get(
f"http://localhost:5001/users/{user_id_to_mock}",
json={"exists": True, "user_id": user_id_to_mock, "name": "Alice"}
)
# Make the POST request to order-service
response = client.post("/orders",
data=json.dumps({"user_id": user_id_to_mock, "item": item_to_order}),
content_type="application/json")
assert response.status_code == 201
data = json.loads(response.get_data(as_text=True))
assert "order_id" in data
assert data["user_id"] == user_id_to_mock
assert data["item"] == item_to_order
def test_create_order_user_not_found(client, mock_user_service):
"""Tests order creation when user does not exist."""
user_id_to_mock = "nonexistent_user"
item_to_order = "Keyboard"
# Mock the GET request to user-service, returning user not found
mock_user_service.get(
f"http://localhost:5001/users/{user_id_to_mock}",
json={"exists": False},
status_code=404 # Simulate the user-service response
)
# Make the POST request to order-service
response = client.post("/orders",
data=json.dumps({"user_id": user_id_to_mock, "item": item_to_order}),
content_type="application/json")
assert response.status_code == 404
data = json.loads(response.get_data(as_text=True))
assert "error" in data
assert f"User {user_id_to_mock} not found" in data["error"]
def test_create_order_missing_fields(client, mock_user_service):
"""Tests order creation with missing required fields."""
# No user_id provided
response = client.post("/orders",
data=json.dumps({"item": "Mouse"}),
content_type="application/json")
assert response.status_code == 400
data = json.loads(response.get_data(as_text=True))
assert "error" in data
assert "user_id and item are required" in data["error"]
# No item provided
response = client.post("/orders",
data=json.dumps({"user_id": "user123"}),
content_type="application/json")
assert response.status_code == 400
data = json.loads(response.get_data(as_text=True))
assert "error" in data
assert "user_id and item are required" in data["error"]
To run these tests, save the order_service.py and test_order_service.py files in the same directory, then execute pytest in your terminal.
The mental model here is that your test for order-service interacts with its Flask application object directly (app.test_client()). When order-service makes an HTTP request to user-service (using requests.get), the requests_mock library intercepts this outgoing request. Instead of the request actually going over the network, requests_mock returns a predefined response that you’ve configured. This allows you to control the behavior of dependencies, simulating success, failure, or specific error conditions from user-service without needing it to be running.
You control the interaction by defining the URL, HTTP method, and the json payload or status_code that requests_mock should return. This isolation is powerful because it decouples your test from the availability and state of other services, making tests faster, more reliable, and easier to debug. You’re essentially creating a "stub" or "mock" for the external dependency.
The core idea is to treat each service as a black box during its own isolation tests. You send it inputs and assert its outputs or side effects. For dependencies, you provide controlled, predictable responses. This is achieved by intercepting network calls your service makes to other services.
A common pitfall is forgetting to mock all external HTTP calls. If your service makes multiple calls to different external services, or even multiple calls to the same service under different conditions, you need to ensure each of those scenarios is explicitly mocked. Failing to mock a call means the request will attempt to go over the network, which will likely fail in a test environment and break your isolated test.
The next logical step in testing is exploring contract testing, where you verify that your service adheres to the agreed-upon communication contract with its dependencies.