Comprehensive Testing Strategies for Backend Applications
Testing is often treated as an afterthought—something to add once the "real" code is written. This mindset leads to either no tests or brittle tests that break with every refactor. A well-designed testing strategy is different. It's an investment that compounds over time, giving you confidence to refactor, deploy, and iterate faster.
This guide covers practical testing patterns for backend applications: what to test at each level, how to structure tests for maintainability, and the mocking strategies that keep tests fast and reliable.
Problem
Testing backend applications is hard because:
- External dependencies — Databases, APIs, and message queues are slow and non-deterministic
- State management — Tests affect each other through shared database state
- Complex setups — Authentication, authorization, and configuration complicate test setup
- Unclear boundaries — It's not obvious what should be unit tested vs. integration tested
- Slow feedback loops — Full test suites take minutes, discouraging frequent runs
Many teams respond by either not testing or by writing tests that are so tightly coupled to implementation that they break constantly. Neither approach builds confidence.
Why This Matters
Good tests provide:
- Confidence to refactor — Change internals without fear of breaking functionality
- Living documentation — Tests show how code is supposed to be used
- Faster debugging — Failing tests localize problems
- Design feedback — Hard-to-test code is often poorly designed
NOTE: The goal isn't 100% coverage. It's confidence. Some code—simple getters, framework boilerplate—doesn't need tests. Complex business logic does.
Solution
The Testing Pyramid
Structure your test suite with more unit tests at the base and fewer integration/E2E tests at the top:
/\
/ \ E2E Tests (few, slow, high confidence)
/----\
/ \ Integration Tests (some, medium speed)
/--------\
/ \ Unit Tests (many, fast, focused)
/------------\
- Unit tests: Test individual functions/classes in isolation
- Integration tests: Test components working together
- E2E tests: Test complete user flows through the system
Implementation: Unit Testing
Test business logic in isolation. Mock external dependencies.
import pytest
from decimal import Decimal
from datetime import datetime, timedelta
from app.services.pricing import PricingService, DiscountRule
class TestPricingService:
"""Unit tests for the pricing service."""
@pytest.fixture
def pricing_service(self):
"""Create pricing service with standard rules."""
rules = [
DiscountRule(min_amount=100, percentage=5),
DiscountRule(min_amount=500, percentage=10),
DiscountRule(min_amount=1000, percentage=15),
]
return PricingService(discount_rules=rules)
def test_no_discount_below_threshold(self, pricing_service):
"""Orders under $100 receive no discount."""
result = pricing_service.calculate_total(
subtotal=Decimal("99.99"),
is_member=False
)
assert result.discount == Decimal("0")
assert result.total == Decimal("99.99")
def test_percentage_discount_for_large_orders(self, pricing_service):
"""Orders over $500 receive 10% discount."""
result = pricing_service.calculate_total(
subtotal=Decimal("500.00"),
is_member=False
)
assert result.discount == Decimal("50.00")
assert result.total == Decimal("450.00")
def test_member_discount_stacks(self, pricing_service):
"""Members receive additional 5% on top of order discount."""
result = pricing_service.calculate_total(
subtotal=Decimal("500.00"),
is_member=True
)
# 10% order discount + 5% member = 15%
assert result.discount == Decimal("75.00")
assert result.total == Decimal("425.00")
@pytest.mark.parametrize("subtotal,is_member,expected_discount", [
(Decimal("50"), False, Decimal("0")),
(Decimal("100"), False, Decimal("5")),
(Decimal("500"), False, Decimal("50")),
(Decimal("500"), True, Decimal("75")),
(Decimal("1000"), True, Decimal("200")), # 15% + 5%
])
def test_discount_thresholds(self, pricing_service, subtotal, is_member, expected_discount):
"""Verify discount calculation at various thresholds."""
result = pricing_service.calculate_total(
subtotal=subtotal,
is_member=is_member
)
assert result.discount == expected_discount
TIP: Use
pytest.mark.parametrizefor testing multiple scenarios with the same logic. It's more readable than separate test methods.
Mocking External Dependencies
Mock at the boundary, not deep within your code.
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.services.order_service import OrderService
from app.models import Order, OrderStatus
class TestOrderService:
"""Tests for order service with mocked dependencies."""
@pytest.fixture
def mock_repo(self):
"""Mock order repository."""
repo = AsyncMock()
repo.get_by_id.return_value = None
repo.save.side_effect = lambda order: order
return repo
@pytest.fixture
def mock_payment(self):
"""Mock payment gateway."""
payment = AsyncMock()
payment.charge.return_value = {"transaction_id": "txn_123", "status": "success"}
return payment
@pytest.fixture
def mock_email(self):
"""Mock email service."""
return AsyncMock()
@pytest.fixture
def order_service(self, mock_repo, mock_payment, mock_email):
return OrderService(
order_repo=mock_repo,
payment_gateway=mock_payment,
email_service=mock_email
)
async def test_successful_order_sends_confirmation(
self, order_service, mock_repo, mock_email
):
"""Successful order triggers confirmation email."""
# Arrange
order_data = {"items": [{"sku": "ABC", "qty": 2}], "total": Decimal("99.99")}
# Act
result = await order_service.create_order(
user_id="user_123",
email="customer@example.com",
**order_data
)
# Assert
mock_repo.save.assert_called_once()
mock_email.send_order_confirmation.assert_called_once_with(
email="customer@example.com",
order_id=result.id
)
async def test_payment_failure_does_not_save_order(
self, order_service, mock_repo, mock_payment
):
"""Failed payment should not persist the order."""
# Arrange
mock_payment.charge.side_effect = PaymentDeclinedError("Insufficient funds")
# Act & Assert
with pytest.raises(PaymentDeclinedError):
await order_service.create_order(
user_id="user_123",
email="customer@example.com",
items=[{"sku": "ABC", "qty": 1}],
total=Decimal("99.99")
)
mock_repo.save.assert_not_called()
Integration Testing
Test components working together with real dependencies.
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from app.main import create_app
from app.database import Base, get_db
# Test database URL (use separate database!)
TEST_DATABASE_URL = "postgresql+asyncpg://test:test@localhost:5432/test_db"
@pytest.fixture(scope="session")
def event_loop():
"""Create event loop for async tests."""
import asyncio
loop = asyncio.new_event_loop()
yield loop
loop.close()
@pytest.fixture(scope="session")
async def test_engine():
"""Create test database engine."""
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
@pytest.fixture
async def db_session(test_engine):
"""Provide transactional database session that rolls back after each test."""
async_session = sessionmaker(
test_engine, class_=AsyncSession, expire_on_commit=False
)
async with async_session() as session:
async with session.begin():
yield session
await session.rollback()
@pytest.fixture
async def client(db_session):
"""Async HTTP client with test database."""
app = create_app()
# Override database dependency
app.dependency_overrides[get_db] = lambda: db_session
async with AsyncClient(app=app, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
class TestUserAPI:
"""Integration tests for user API endpoints."""
async def test_create_user_returns_201(self, client):
"""POST /users creates user and returns 201."""
response = await client.post("/api/v1/users", json={
"email": "newuser@example.com",
"name": "Test User",
"password": "securepassword123"
})
assert response.status_code == 201
data = response.json()
assert data["email"] == "newuser@example.com"
assert "password" not in data # Password not exposed
assert "id" in data
async def test_create_duplicate_user_returns_409(self, client):
"""Creating user with existing email returns 409."""
user_data = {
"email": "duplicate@example.com",
"name": "First User",
"password": "password123"
}
# Create first user
await client.post("/api/v1/users", json=user_data)
# Try to create duplicate
response = await client.post("/api/v1/users", json={
**user_data,
"name": "Second User"
})
assert response.status_code == 409
assert response.json()["error"]["code"] == "CONFLICT"
async def test_get_nonexistent_user_returns_404(self, client):
"""GET /users/{id} returns 404 for unknown user."""
response = await client.get("/api/v1/users/nonexistent-id")
assert response.status_code == 404
WARNING: Always use a separate test database. Never run tests against production or development databases.
Test Factories
Use factories (like Factory Boy) for consistent test data:
import factory
from factory import Faker, SubFactory, LazyAttribute
from app.models import User, Order, OrderItem
class UserFactory(factory.Factory):
"""Factory for creating test users."""
class Meta:
model = User
id = factory.LazyFunction(lambda: str(uuid.uuid4()))
email = Faker("email")
name = Faker("name")
is_active = True
created_at = factory.LazyFunction(datetime.utcnow)
class OrderFactory(factory.Factory):
"""Factory for creating test orders."""
class Meta:
model = Order
id = factory.LazyFunction(lambda: str(uuid.uuid4()))
user = SubFactory(UserFactory)
status = OrderStatus.PENDING
total = Faker("pydecimal", left_digits=4, right_digits=2, positive=True)
created_at = factory.LazyFunction(datetime.utcnow)
# Usage in tests
def test_order_total_calculation():
user = UserFactory(is_member=True)
order = OrderFactory(user=user, total=Decimal("500.00"))
# Test with consistent, readable data
assert calculate_discount(order) == Decimal("75.00")
Example: Testing Async Code
import pytest
from unittest.mock import AsyncMock
import asyncio
@pytest.mark.asyncio
class TestAsyncService:
"""Tests for async service methods."""
async def test_concurrent_operations(self):
"""Service handles concurrent requests correctly."""
service = MyAsyncService()
# Run multiple operations concurrently
results = await asyncio.gather(
service.process("item_1"),
service.process("item_2"),
service.process("item_3"),
)
assert len(results) == 3
assert all(r.status == "completed" for r in results)
async def test_timeout_handling(self):
"""Service handles timeouts gracefully."""
slow_client = AsyncMock()
slow_client.fetch.side_effect = asyncio.TimeoutError()
service = MyAsyncService(client=slow_client)
with pytest.raises(ServiceTimeoutError):
await service.fetch_with_timeout("resource_id", timeout=5)
Common Mistakes
1. Testing Implementation, Not Behavior
# Wrong - tests implementation details
def test_user_service_calls_repo_save():
service.create_user(data)
mock_repo.save.assert_called_once_with(ANY) # Fragile!
# Correct - tests behavior
def test_user_service_creates_user():
result = service.create_user(data)
assert result.email == data["email"]
2. Shared Mutable State Between Tests
# Wrong - tests affect each other
test_users = [] # Module-level mutable state
def test_first():
test_users.append("user1")
def test_second():
# Depends on test_first running first!
assert len(test_users) == 1
# Correct - use fixtures
@pytest.fixture
def test_users():
return [] # Fresh for each test
3. Over-Mocking
When you mock everything, you're testing your mocks, not your code. Mock at boundaries (database, HTTP, message queue), not internal classes.
4. Ignoring Edge Cases
# Incomplete - only tests happy path
def test_division():
assert divide(10, 2) == 5
# Complete - includes edge cases
def test_division():
assert divide(10, 2) == 5
def test_division_by_zero():
with pytest.raises(ZeroDivisionError):
divide(10, 0)
def test_division_negative():
assert divide(-10, 2) == -5
Conclusion
A good testing strategy is layered: fast unit tests for business logic, integration tests for component interaction, and fewer E2E tests for critical paths. Mock at boundaries, use factories for test data, and maintain transactional isolation in database tests.
The time invested in testing infrastructure pays dividends in deployment confidence. When your test suite passes, you can deploy without holding your breath. That confidence is worth the setup cost.