Python
How Unit Testing, Integration Testing, and End-to-End Testing Work in Python
Learn the three layers of the testing pyramid in Python: unit tests for logic, integration tests for external systems, and end-to-end tests for full workflows. Includes practical examples with pytest, testcontainers, and Playwright.
June 2026 · 8 min read · 1 views · 0 hearts
Advertisement
How Unit Testing, Integration Testing, and End-to-End Testing Work in Python
You've written a Python app that runs locally, passes all manual checks, and seems solid. Then you deploy it to production, and within hours everything breaks—the database returns None, an API returns 500, and users start filing bug reports. The fix? A proper testing pyramid, applied in Python.
Most Python developers lean on unit tests but skip the others. That's like checking each brick is solid but never testing if the wall can stand. Here's how the three layers work in Python, and why you need all of them.
Unit Testing: The Brick Checker
Unit tests validate the smallest pieces of your code—individual functions, methods, or classes—in isolation. You mock everything external: databases, file systems, network calls.
# app.py
def calculate_discount(price, customer_type):
discounts = {"regular": 0.1, "vip": 0.2}
return price * (1 - discounts.get(customer_type, 0))
# test_app.py
def test_calculate_discount_regular():
result = calculate_discount(100, "regular")
assert result == 90.0
def test_calculate_discount_vip():
result = calculate_discount(100, "vip")
assert result == 80.0
def test_calculate_discount_unknown():
result = calculate_discount(100, "guest")
assert result == 100.0
Tools you'll actually use: pytest (not the built-in unittest—it's cleaner), pytest-mock for mocking, and coverage.py to see what you're missing.
Key insight: Unit tests run in milliseconds. You should have hundreds of them. They catch logic errors, edge cases, and regression before anything gets messy. But they cannot catch integration problems—database schemas change, APIs drift, and your mocked objects in tests become fantasy versions of reality.
Integration Testing: The Plumber's Check
Integration tests verify that your code works with real external systems—the database, the file system, an external API, or a message queue. The "real" part is crucial.
# test_integration.py
import pytest
from app import DatabaseClient
def test_insert_and_retrieve_user_integration():
# Use a real database (or test container)
client = DatabaseClient("postgresql://test:test@localhost:5432/testdb")
client.insert_user({"name": "Alice", "email": "alice@test.com"})
user = client.get_user_by_email("alice@test.com")
assert user["name"] == "Alice"
client.cleanup()
The trick: Use testcontainers. pip install testcontainers spins up real Postgres, Redis, or MySQL instances in Docker containers during your test run and tears them down after.
# Using testcontainers with pytest
from testcontainers.postgres import PostgresContainer
def test_with_real_postgres():
with PostgresContainer("postgres:15") as postgres:
connection_url = postgres.get_connection_url()
# Now your tests run against a real database
Integration tests take seconds, not milliseconds. You'll have fewer—maybe 50-100 for a medium app. They catch the silent killers: mismatched schemas, missing indexes, null handling differences between your code and the database.
End-to-End Testing: The Full Walkthrough
End-to-end (E2E) tests run your entire application as a user would experience it. For a web app, that means opening a browser, clicking buttons, filling forms, and verifying results appear on screen. For an API, it means making actual HTTP requests and checking the full response chain.
# test_e2e.py
import requests
BASE_URL = "http://localhost:5000"
def test_user_signup_and_login_flow():
# Step 1: Sign up
signup_resp = requests.post(f"{BASE_URL}/signup", json={
"username": "testuser",
"password": "securepass123"
})
assert signup_resp.status_code == 201
# Step 2: Login
login_resp = requests.post(f"{BASE_URL}/login", json={
"username": "testuser",
"password": "securepass123"
})
assert login_resp.status_code == 200
token = login_resp.json()["token"]
# Step 3: Access protected endpoint
profile_resp = requests.get(
f"{BASE_URL}/profile",
headers={"Authorization": f"Bearer {token}"}
)
assert profile_resp.status_code == 200
assert profile_resp.json()["username"] == "testuser"
# Step 4: Clean up
requests.delete(f"{BASE_URL}/users/testuser")
For web UI testing, use playwright or selenium. Playwright is the modern choice—it's faster, more reliable, and has great Python bindings:
from playwright.sync_api import sync_playwright
def test_complete_shopping_flow():
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("http://localhost:5000/login")
page.fill("#username", "testuser")
page.fill("#password", "securepass123")
page.click("#login-button")
# Wait for dashboard to load
page.wait_for_selector("#welcome-message")
assert page.inner_text("#welcome-message") == "Welcome, testuser"
browser.close()
E2E tests are slow—minutes per test. You'll have only 5-20 critical paths. They catch things nothing else can: CSS breaking a payment flow, a missing CORS header blocking a frontend request, or a subtle auth token expiration issue.
The Testing Pyramid in Practice
For a typical Python web application (Flask, Django, or FastAPI), your test distribution should roughly look like this:
- 70% Unit Tests: Pure logic, helper functions, validators
- 20% Integration Tests: Database, API clients, file operations
- 10% End-to-End Tests: Full user workflows
The common mistake Python developers make: Writing "integration tests" that use mocked databases (they're just slow unit tests with extra steps). Or writing E2E tests that never clean up after themselves (your next test run fails because a user already exists).
What Actually Breaks Production
Here's the real-world pattern you'll see:
- Unit tests pass — your discount calculation logic is mathematically correct
- Integration test passes — the query runs against a test database with 3 users
- Production breaks — with 10,000 users, the query uses a different index, times out, and crashes the API
You need E2E tests that load real data volumes. Or at least integration tests with realistic dataset sizes. Most teams skip this until they're burned.
The Final Takeaway
Write unit tests for confidence during refactoring. Write integration tests to catch schema and API drift. Write E2E tests for the critical user paths.
And for every mock you write in a unit test, ask: "Could this mock hide a real production issue?" If the answer is yes, write an integration test too. Your future self—pushing a deploy on a Friday afternoon—will thank you.
Advertisement
Comments
Questions, corrections, and tips stay visible for everyone reading this page.
Join the discussion
No comments yet
Be the first to leave a note — it helps the next reader.