TestingTesting
Thuộc bộ kiến thức Backend Roadmap.
Tổng quan
Testing là việc chạy code với các input đã biết và assert rằng nó hành xử đúng như mong đợi. Ở backend, test chính là thứ cho phép bạn thay đổi code một cách tự tin: refactor một service, upgrade một dependency, hay ship một feature vào chiều thứ Sáu mà không bụng dạ nơm nớp. Test không phải là một khoản “thuế” bạn trả sau khi làm xong “việc thật” — nó chính là một phần của việc thật, và trong một backend codebase lành mạnh, số dòng test thường nhiều hơn số dòng production mà nó bảo vệ.
Giá trị cốt lõi của một test suite không phải là tìm ra bug một lần; mà là nó liên tục tìm ra bug đó mãi mãi, tự động, trên mọi commit. Một bug bị bắt bởi unit test trong 50 mili-giây trên máy bạn rẻ hơn hàng bậc so với cùng bug đó bị bắt trên production lúc 3 giờ sáng bởi một cảnh báo paging. Toàn bộ kỷ luật testing ở backend là để đẩy việc phát hiện bug về càng sớm (shift left) và càng rẻ càng tốt.
Note này bàn về: vì sao cần test, mô hình pyramid và trophy, các loại test chính (unit, integration, functional/E2E, contract), test doubles và cách kiểm soát external dependencies, TDD/BDD, coverage, flaky test, và cách testing gắn vào CI.
Note liên quan: CI/CD and Deployment · Scalability and Reliability.
Kiến thức nền tảng
Vì sao phải test?
| Lý do | Bạn được gì |
|---|---|
| Regression protection | Hành vi cũ vẫn đúng khi code mới được thêm vào. |
| An toàn khi refactor | Bạn có thể tái cấu trúc phần bên trong vì test ghim chặt hành vi, không phải implementation. |
| Documentation chạy được | Một test đặt tên tốt mô tả code nên làm gì, và luôn đồng bộ với thực tế. |
| Áp lực thiết kế | Code khó test thường là code thiết kế tệ (coupling chặt, hidden state). Test đẩy bạn về phía các unit nhỏ, dễ inject. |
| Feedback nhanh | Một test đỏ trên máy bạn tốt hơn một dashboard đỏ trên production. |
| Tự tin deploy | CI xanh nghĩa là contract bạn quan tâm vẫn được giữ — điều kiện tiên quyết của continuous delivery. |
Mục tiêu không phải là zero bug (bất khả thi) — mà là rủi ro trong tầm kiểm soát: biết rằng những đường đi bạn quan tâm đều được exercise, và một đường bị hỏng sẽ la lên trước khi chạm tới người dùng.
Testing pyramid
Mô hình kinh điển (Mike Cohn) nói: hãy có nhiều test nhanh, rẻ, isolated ở dưới đáy, và ít dần khi đi lên phía các test chậm, rộng, đắt đỏ.
/\
/ \ E2E / UI ít — chậm, dễ vỡ, confidence cao mỗi test
/----\
/ \ Integration vừa — tốc độ trung bình, collaborator thật
/--------\
/ \ Unit nhiều — mili-giây, isolated, rẻ
/____________\
Hình dạng này mã hóa một sự thật kinh tế: test càng ở cao thì càng tốn công để viết, chạy và bảo trì, và càng dễ flaky. Vậy nên bạn muốn phần lớn các assertion nằm ở dưới đáy cho rẻ, và dành các tầng đắt đỏ phía trên cho một tập mỏng các hành trình người dùng trọng yếu.
Testing trophy
Với các codebase mà đa số bug nằm ở các đường ghép nối giữa các component (điển hình của backend kiểu API/service), Kent C. Dodds phổ biến mô hình testing trophy, dồn trọng lượng về integration test:
___
(E2E) ít
/-----\
| Integ | <---- phần bụng phệ: confidence-trên-công-sức tốt nhất
\-----/
|Unit|
(Static) <--- types, lint, compiler bắt cả một lớp bug miễn phí
Trophy thêm một tầng đáy là static analysis (type checker, linter, compiler), thứ loại bỏ cả loạt bug trước khi bất kỳ test nào chạy — và nó lập luận rằng integration test cho hiệu quả tốt nhất, vì nó test theo đúng cách code được dùng thực tế (“write tests. not too many. mostly integration.”).
Pyramid vs trophy không phải cuộc chiến tôn giáo. Điều rút ra thực dụng: đẩy các check xuống tầng rẻ nhất có thể bắt được nó (static > unit > integration > E2E), và để bản chất bug của bạn quyết định khối lượng nằm ở đâu. Một thư viện thuần thuật toán nghiêng về pyramid; một service CRUD chủ yếu đấu nối DB, queue và HTTP layer thì nghiêng về trophy.
Đánh đổi cost / speed / confidence
| Thuộc tính | Đi xuống (unit) | Đi lên (E2E) |
|---|---|---|
| Tốc độ | Mili-giây | Vài giây đến vài phút |
| Công viết | Thấp | Cao |
| Công bảo trì | Thấp | Cao (dễ vỡ) |
| Độ thực tế / confidence | Hẹp — một unit | Rộng — cả hệ thống |
| Flakiness | Hiếm | Thường gặp (network, timing, state) |
| Định vị lỗi | Chính xác (function này) | Mơ hồ (đâu đó trong flow) |
Mỗi test là một canh bạc: bạn chi tốc độ và công bảo trì để mua confidence. Suite tốt là một danh mục (portfolio) các canh bạc đó, dồn trọng số về đầu rẻ.
Khái niệm chính
Unit testing
Một unit test kiểm chứng một “unit” hành vi duy nhất — thường là một function, method, hay class — trong trạng thái isolation khỏi các collaborator (database, network, filesystem, clock). Isolation chính là thứ khiến unit test nhanh và deterministic: không có gì bên ngoài unit làm nó chậm hay flaky được.
Cấu trúc phổ biến nhất là AAA — Arrange, Act, Assert:
- Arrange — set up input, dependency, và trạng thái mong đợi.
- Act — gọi thứ đang được test đúng một lần.
- Assert — kiểm tra kết quả (giá trị trả về, error được ném, hay interaction).
Ví dụ — một unit test thuần (Python + pytest) cho hàm tính discount, không I/O:
# pricing.py
def apply_discount(price: float, percent: float) -> float:
if not 0 <= percent <= 100:
raise ValueError("percent must be between 0 and 100")
return round(price * (1 - percent / 100), 2)
# test_pricing.py
import pytest
from pricing import apply_discount
def test_applies_percentage_discount():
# Arrange
price, percent = 100.0, 25.0
# Act
result = apply_discount(price, percent)
# Assert
assert result == 75.0
def test_rounds_to_two_decimals():
assert apply_discount(9.99, 10) == 8.99
@pytest.mark.parametrize("bad", [-1, 101, 999])
def test_rejects_out_of_range_percent(bad):
with pytest.raises(ValueError):
apply_discount(100.0, bad)
Hãy để ý code được thiết kế để dễ test: apply_discount nhận input qua tham số và không có dependency ẩn. Đó không phải tình cờ — code dễ test và code được factor tốt chính là cùng một loại code.
Test doubles: mock, stub, fake, spy
Khi một unit phụ thuộc vào thứ gì đó chậm, không deterministic, hoặc đắt (DB, payment gateway, now()), bạn thay collaborator đó bằng một test double. Phân loại của Gerard Meszaros:
| Double | Nó làm gì | Dùng khi |
|---|---|---|
| Dummy | Được truyền vào nhưng không bao giờ dùng (chỉ để lấp chỗ tham số). | Bạn buộc phải truyền một argument mà bạn không quan tâm. |
| Stub | Trả về câu trả lời soạn sẵn cho các lời gọi. | Bạn cần collaborator cung cấp input (ví dụ “trả về user này”). |
| Fake | Một implementation chạy được nhưng đơn giản hóa (in-memory DB, in-memory queue). | Bạn muốn hành vi thực tế mà không tốn chi phí của dependency thật. |
| Spy | Là một stub nhưng còn ghi lại nó được gọi thế nào. | Bạn cần assert một interaction đã xảy ra và vẫn trả về gì đó. |
| Mock | Được lập trình trước với expectation; test fail nếu không được gọi đúng như đã định. | Chính interaction đó là hành vi đang được test (ví dụ “một email đã được gửi”). |
Phân biệt cốt lõi là state verification (assert trên giá trị trả về / trạng thái kết quả — nên ưu tiên) vs behavior verification (assert rằng một call đã xảy ra — dùng dè dặt, vì nó gắn test vào implementation). Over-mocking tạo ra những test pass trong khi hệ thống hỏng, bởi vì chúng chỉ test chính các mock.
Ví dụ — stub một repository và spy một mailer (Python unittest.mock):
# service.py
class SignupService:
def __init__(self, users, mailer):
self.users = users # a repository
self.mailer = mailer # sends emails
def register(self, email: str) -> int:
if self.users.find_by_email(email):
raise ValueError("email already registered")
user_id = self.users.create(email)
self.mailer.send_welcome(email) # side effect we care about
return user_id
# test_service.py
from unittest.mock import MagicMock
import pytest
from service import SignupService
def test_registers_new_user_and_sends_welcome():
users = MagicMock()
users.find_by_email.return_value = None # stub: no existing user
users.create.return_value = 42
mailer = MagicMock() # spy/mock
svc = SignupService(users, mailer)
user_id = svc.register("a@example.com")
assert user_id == 42 # state verification
mailer.send_welcome.assert_called_once_with("a@example.com") # behavior verification
def test_rejects_duplicate_email():
users = MagicMock()
users.find_by_email.return_value = {"id": 1} # stub: user exists
mailer = MagicMock()
with pytest.raises(ValueError):
SignupService(users, mailer).register("a@example.com")
mailer.send_welcome.assert_not_called() # no email on the failure path
Integration testing
Một integration test kiểm chứng rằng hai hay nhiều component thật phối hợp đúng với nhau — code của bạn chạy với một database thật, một message broker thật, một HTTP client gọi tới một server đang chạy. Nó bắt được các bug mà unit test về mặt cấu trúc không thể bắt: SQL sai, migration lệch, quirk trong serialization, ranh giới transaction, xử lý connection.
Chuẩn mực hiện đại cho integration test ở backend là Testcontainers: dựng lên một dependency thật (Postgres, Redis, Kafka…) trong một Docker container dùng-một-lần cho mỗi lần chạy test, để test chạy với cùng engine như production thay vì một mock hay một thứ thay thế in-memory.
Ví dụ — integration test chạy trên Postgres thật qua Testcontainers (Python):
# test_user_repo_integration.py
import psycopg
import pytest
from testcontainers.postgres import PostgresContainer
from repo import UserRepo # real repository that runs SQL
@pytest.fixture(scope="module")
def pg():
with PostgresContainer("postgres:16-alpine") as container:
yield container.get_connection_url()
@pytest.fixture
def conn(pg):
with psycopg.connect(pg, autocommit=True) as c:
c.execute("""
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL
)
""")
c.execute("TRUNCATE users RESTART IDENTITY") # clean slate per test
yield c
def test_create_and_find_user(conn):
repo = UserRepo(conn)
new_id = repo.create("alice@example.com")
found = repo.find_by_email("alice@example.com")
assert found["id"] == new_id
assert found["email"] == "alice@example.com"
def test_unique_email_constraint_is_enforced(conn):
repo = UserRepo(conn)
repo.create("dup@example.com")
with pytest.raises(psycopg.errors.UniqueViolation):
repo.create("dup@example.com") # DB-level constraint, invisible to unit tests
Quản lý test data — fixtures và cleanup. Integration test phải bắt đầu từ một trạng thái đã biết. Các chiến lược phổ biến:
| Chiến lược | Cách làm | Đánh đổi |
|---|---|---|
| Truncate / recreate | Xóa sạch bảng trước mỗi test (như trên). | Đơn giản, chắc chắn; chậm hơn với schema lớn. |
| Transaction rollback | Bọc mỗi test trong một transaction, rollback ở cuối. | Rất nhanh; hỏng nếu code được test tự commit hoặc dùng transaction riêng. |
| Fixtures / factories | Tạo object bằng factory (ví dụ factory_boy, FactoryBot). | Dễ đọc, DRY; cần kỷ luật để tránh gắn test vào shared data. |
| Migration trên DB mới | Chạy migration thật lên container. | Thực tế nhất (bắt được bug migration); setup chậm nhất. |
Hãy ưu tiên isolation ở mức từng test: không test nào được phụ thuộc vào data do test khác để lại. Shared mutable state giữa các test là nguồn số 1 của flakiness phụ thuộc thứ tự.
Functional, end-to-end, và API testing
- Functional test — kiểm chứng một feature từ đầu tới cuối từ bên ngoài, coi phần bên trong là hộp đen (ví dụ “POST /orders tạo một order và trả về 201 kèm location header”). Với backend điều này thường nghĩa là drive qua HTTP API.
- End-to-end (E2E) test — chạy toàn bộ stack theo đúng cách một client thật làm: qua API gateway, service thật, DB thật, đôi khi cả browser thật. Confidence cao nhất, chi phí cao nhất, dễ flaky nhất. Giữ số lượng này ít và tập trung vào các hành trình trọng yếu.
Ví dụ — một functional API test với test client in-process (Python + FastAPI + pytest):
from fastapi.testclient import TestClient
from app import app # your FastAPI application
client = TestClient(app)
def test_create_order_returns_201_and_location():
resp = client.post("/orders", json={"sku": "ABC-1", "qty": 2})
assert resp.status_code == 201
assert resp.headers["Location"].startswith("/orders/")
body = resp.json()
assert body["sku"] == "ABC-1"
assert body["status"] == "PENDING"
def test_rejects_invalid_quantity():
resp = client.post("/orders", json={"sku": "ABC-1", "qty": 0})
assert resp.status_code == 422 # validation error
Contract testing. Trong một hệ microservices, chạy E2E đầy đủ qua tất cả service thì chậm và dễ vỡ. Contract testing (ví dụ Pact) kiểm chứng thỏa thuận giữa một consumer và một provider mà không cần chạy cả hai cùng lúc: consumer ghi lại các request nó gửi và các response nó mong đợi (một “pact”), rồi provider được verify độc lập theo contract đã ghi đó trong pipeline riêng của nó. Cách này bắt được các thay đổi API gây breaking từ sớm mà vẫn giữ test của từng service nhanh và isolated.
// consumer.pact.test.js — consumer side (Pact, JS)
const { PactV3, MatchersV3 } = require("@pact-foundation/pact");
const { like } = MatchersV3;
const provider = new PactV3({ consumer: "web", provider: "user-service" });
test("gets a user by id", () => {
provider
.given("a user with id 42 exists")
.uponReceiving("a request for user 42")
.withRequest({ method: "GET", path: "/users/42" })
.willRespondWith({
status: 200,
headers: { "Content-Type": "application/json" },
body: like({ id: 42, email: "a@example.com" }),
});
return provider.executeTest(async (mockServer) => {
const user = await getUser(mockServer.url, 42); // your client code
expect(user.id).toBe(42);
});
});
// The generated pact file is later verified against the real user-service.
Các loại test khác
| Loại | Mục đích | Ghi chú |
|---|---|---|
| Regression | Đảm bảo một bug đã fix vẫn được fix. | Mỗi bug fix nên ship kèm một test fail trước khi fix và pass sau khi fix. |
| Smoke | Kiểm tra nhanh “nó còn sống không?” sau khi deploy. | Gọi vài endpoint/health check trọng yếu; fail sớm nếu build hỏng về căn bản. |
| Load / performance | Đo hành vi dưới tải kỳ vọng và tải đỉnh (throughput, latency percentiles). | Công cụ: k6, Locust, JMeter, Gatling. Xem Scalability and Reliability. |
| Stress / soak | Đẩy vượt giới hạn, hoặc chạy dài để lộ leak. | Lộ ra memory leak, cạn kiệt connection, degradation theo thời gian. |
| Security | Tìm lỗ hổng bảo mật. | SAST (static), DAST (app đang chạy), dependency scanning (SCA), fuzzing. |
| Property-based | Assert các invariant trên input được sinh tự động thay vì các ví dụ chọn tay. | Công cụ: Hypothesis (Python), fast-check (JS), QuickCheck. Rất tốt để tìm edge case bạn không bao giờ nghĩ ra. |
Ví dụ — property-based test (Python + Hypothesis) assert một invariant của hàm discount:
from hypothesis import given, strategies as st
from pricing import apply_discount
@given(
price=st.floats(min_value=0, max_value=1_000_000, allow_nan=False),
percent=st.floats(min_value=0, max_value=100),
)
def test_discounted_price_never_exceeds_original(price, percent):
assert apply_discount(price, percent) <= price + 0.01 # invariant, any valid input
TDD và BDD
TDD (Test-Driven Development) đảo ngược thứ tự: viết test trước, xem nó fail, rồi viết lượng code tối thiểu để pass, rồi refactor. Vòng lặp Red → Green → Refactor:
- Red — viết một test fail cho hành vi nhỏ tiếp theo.
- Green — viết code đơn giản nhất để nó pass (kể cả xấu).
- Refactor — dọn dẹp với test làm lưới an toàn; lặp lại.
Phần thưởng thực sự của TDD là thiết kế: vì bạn tiêu thụ API của chính mình trước khi nó tồn tại, bạn có xu hướng tạo ra các unit nhỏ hơn, ít coupling hơn. Đây là một kỹ năng, không phải một mệnh lệnh — nó tỏa sáng với logic đã hiểu rõ và chật vật với việc mang tính khám phá/UI.
BDD (Behavior-Driven Development) nâng mức trừu tượng lên hành vi nghiệp vụ, diễn đạt bằng một ngôn ngữ chung chia sẻ với người không phải engineer, thường qua Given/When/Then (Gherkin):
Feature: Order checkout
Scenario: Successful order with a valid coupon
Given a cart with 2 items totaling $100
And a valid coupon "SAVE10"
When the customer checks out
Then the order total should be $90
And a confirmation email should be sent
Các công cụ như Cucumber hay behave map mỗi step sang code. BDD ít nói về công cụ mà nói nhiều hơn về việc đặc tả hành vi một cách cộng tác trước khi build.
Test deterministic và flaky test
Một flaky test lúc pass lúc fail mà không có thay đổi code nào. Flakiness phá hủy niềm tin — một khi suite “thỉnh thoảng đỏ”, mọi người bắt đầu phớt lờ màu đỏ, và thế là mất sạch ý nghĩa. Các nguyên nhân thường gặp và cách xử lý:
| Nguyên nhân | Cách xử lý |
|---|---|
| Time / clock | Inject clock; dùng thời gian cố định/giả thay vì now(). |
| Randomness | Seed cho RNG; hoặc dùng property-based test một cách có chủ đích. |
| Shared state / thứ tự test | Isolate từng test; reset DB, cache, global giữa các test. |
| Gọi network thật | Stub external HTTP; dùng response đã ghi (VCR/nock) hoặc Testcontainers. |
Async / timing (sleep) | Poll một điều kiện kèm timeout thay vì sleep một khoảng cố định. |
| Collection không có thứ tự | Sort trước khi assert; đừng giả định thứ tự của dict/set. |
Quy tắc vàng: một test phải cho cùng kết quả mỗi lần chạy, độc lập với máy, đồng hồ tường, network, và thứ tự chạy test. Hãy quarantine flaky test thật nhanh; một flaky test còn tệ hơn không có test, vì nó tập cho mọi người thói quen phớt lờ failure.
Coverage — và vì sao 100% không phải mục tiêu
Code coverage đo tỷ lệ code được thực thi bởi test của bạn (line, branch, statement, hoặc function coverage). Nó là một tín hiệu phủ định hữu ích — coverage 20% cho bạn biết phần lớn code chưa từng được exercise — nhưng lại là tín hiệu khẳng định kém: 100% coverage chỉ chứng minh các dòng đã chạy, chứ không phải bạn đã assert đúng thứ.
# 100% line coverage, zero real testing:
def test_runs_but_asserts_nothing():
apply_discount(100, 10) # executes the line... asserts nothing
Định hướng:
- Coi coverage là một ngưỡng sàn để bắt việc coverage tụt, không phải một chỉ tiêu để lách (ví dụ “đừng để tụt dưới 80%”).
- Theo đuổi branch/path coverage của logic quan trọng hơn là phần trăm line thô.
- Một số code (getter tầm thường, generated code, glue) không đáng test; đừng bẻ cong test để chạm vào nó.
- Câu hỏi thực sự không phải “bao nhiêu % được cover” mà là “nếu tôi làm hỏng hành vi X, có test nào fail không?”
Testing trong CI
Test phát huy phần lớn giá trị khi được chạy tự động trên mọi thay đổi trong CI/CD, chứ không chỉ trên máy dev. Một pipeline điển hình chạy các stage nhanh-trước để fail cho rẻ:
- 01lint + type-check
- 02unit tests
- 03integration testsTestcontainers/services
- 04build
- 05contract/API tests
- 06deploy to staging
- 07smoke + E2E
Thực hành đặc thù cho CI:
- Fail fast: chạy static analysis và unit test trước; chúng bắt phần lớn vấn đề trong vài giây.
- Parallelize: shard các suite chậm ra nhiều runner; chạy các job độc lập đồng thời.
- Dependency dùng-một-lần: dùng service container / Testcontainers để mỗi lần chạy có một DB sạch.
- Required checks: đặt suite làm merge gate trên pull request (branch protection).
- Publish artifact: coverage report, JUnit XML, và trace để phục vụ điều tra flaky test.
- Giữ nhanh: pipeline chậm sẽ bị né. Ngân sách đường tới hạn tính bằng phút, không phải hàng chục phút.
Best Practices
- Viết test ở tầng rẻ nhất bắt được bug. Ưu tiên static analysis và unit test; dùng integration test cho các đường ghép nối; giữ E2E mỏng và tập trung vào hành trình trọng yếu.
- Tuân theo AAA (Arrange–Act–Assert) và giữ một trọng tâm assertion logic cho mỗi test. Một test nên fail vì đúng một lý do.
- Đặt tên test theo hành vi, không theo method —
test_rejects_duplicate_email, không phảitest_register_2. Tên nên đọc như một spec. - Test hành vi, không test implementation. Assert trên kết quả quan sát được, không trên phần private bên trong; nếu không mỗi lần refactor sẽ làm vỡ suite.
- Đừng over-mock. Mock ở các ranh giới kiến trúc (network, DB khi buộc phải, third party), không phải mọi collaborator. Ưu tiên fake hơn mock; ưu tiên state verification hơn interaction verification.
- Giữ test độc lập và không phụ thuộc thứ tự. Không shared mutable state; mỗi test tự set up và tear down thế giới của nó.
- Làm test deterministic. Inject clock và randomness; đừng bao giờ phụ thuộc vào thời gian thật, network thật, hay timing kiểu sleep.
- Coi test code như production code. Nó được đọc và bảo trì nhiều hơn phần lớn code khác; giữ DRY bằng fixtures/factories, nhưng đừng khôn lỏi tới mức khó đọc.
- Mỗi bug fix ship kèm một regression test fail trước khi fix.
- Thực hành TDD nơi logic đã hiểu rõ; nó cải thiện thiết kế kể cả khi bạn không làm một cách sùng đạo.
- Chạy toàn bộ suite trong CI như một merge gate, stage nhanh trước, với dependency sạch dùng-một-lần.
- Theo đuổi confidence, không phải con số coverage. Dùng coverage để tìm code chưa test, không phải để đạt một chỉ tiêu hình thức.
- Quarantine flaky test ngay lập tức rồi fix hoặc xóa nó; một suite flaky tập cho cả team phớt lờ màu đỏ.
Tài liệu tham khảo
- Martin Fowler — Test Pyramid
- Kent C. Dodds — The Testing Trophy and Testing Classifications
- Martin Fowler — Mocks Aren’t Stubs
- Martin Fowler — Eradicating Non-Determinism in Tests
- Testcontainers — Documentation
- Pact — Contract Testing Documentation
- pytest — Documentation
- Google Testing Blog — Code Coverage Best Practices
Part of the Backend Roadmap knowledge base.
Overview
Testing is the practice of executing your code against known inputs and asserting that it behaves as expected. On the backend, tests are what let you change code with confidence: refactor a service, upgrade a dependency, or ship a feature on Friday without a knot in your stomach. Tests are not a tax you pay after “the real work” — they are part of the real work, and on a healthy backend codebase they often outnumber the production lines they protect.
The core value of a test suite is not that it finds bugs once; it is that it keeps finding them forever, automatically, on every commit. A bug caught by a unit test in 50 milliseconds on your laptop is orders of magnitude cheaper than the same bug caught in production at 3 a.m. by a paging alert. The whole discipline of backend testing is about pushing detection as far left (early) and as far cheap as possible.
This note covers why we test, the pyramid and trophy models, the main categories of tests (unit, integration, functional/E2E, contract), test doubles and how to tame external dependencies, TDD/BDD, coverage, flaky tests, and how testing plugs into CI.
Related notes: CI/CD and Deployment · Scalability and Reliability.
Fundamentals
Why test at all?
| Reason | What it buys you |
|---|---|
| Regression protection | Old behavior stays correct as new code lands. |
| Refactoring safety | You can restructure internals because tests pin behavior, not implementation. |
| Executable documentation | A well-named test describes what the code should do, always in sync with reality. |
| Design pressure | Hard-to-test code is usually badly designed (tight coupling, hidden state). Tests push you toward small, injectable units. |
| Faster feedback | A red test on your machine beats a red dashboard in production. |
| Confidence to deploy | CI green means the contract you care about still holds — the prerequisite for continuous delivery. |
The goal is not zero bugs (impossible) — it is bounded risk: knowing that the paths you care about are exercised, and that a broken one will shout before it reaches a user.
The testing pyramid
The classic model (Mike Cohn) says: have many fast, cheap, isolated tests at the bottom, fewer as you move up toward slow, broad, expensive ones.
/\
/ \ E2E / UI few — slow, brittle, high confidence per test
/----\
/ \ Integration some — medium speed, real collaborators
/--------\
/ \ Unit many — milliseconds, isolated, cheap
/____________\
The shape encodes an economic truth: the higher a test lives, the more it costs to write, run, and maintain, and the flakier it tends to be. So you want the bulk of your assertions to live cheaply at the bottom, and reserve the expensive top layers for a thin set of critical user journeys.
The testing trophy
For codebases where most bugs live in the seams between components (typical of API/service backends), Kent C. Dodds popularized the testing trophy, which shifts weight toward integration tests:
___
(E2E) few
/-----\
| Integ | <---- the fat middle: best confidence-per-effort
\-----/
|Unit|
(Static) <--- types, lint, compiler catch a whole class of bugs for free
The trophy adds a base layer, static analysis (type checkers, linters, compilers), which eliminates entire categories of bugs before a single test runs — and it argues that integration tests give the best return, because they test the way code is actually used (“write tests. not too many. mostly integration.”).
Pyramid vs trophy is not a religious war. The practical takeaway: push checks down to the cheapest layer that can catch them (static > unit > integration > E2E), and let the nature of your bugs decide where the mass sits. A pure algorithmic library leans pyramid; a CRUD service that mostly wires a DB, a queue, and an HTTP layer leans trophy.
The cost / speed / confidence tradeoff
| Property | Move down (unit) | Move up (E2E) |
|---|---|---|
| Speed | Milliseconds | Seconds to minutes |
| Cost to write | Low | High |
| Cost to maintain | Low | High (brittle) |
| Realism / confidence | Narrow — one unit | Broad — whole system |
| Flakiness | Rare | Common (network, timing, state) |
| Failure localization | Precise (this function) | Vague (something in the flow) |
Every test is a bet: you spend speed and maintenance to buy confidence. Good suites are portfolios of these bets, weighted toward the cheap end.
Key Concepts
Unit testing
A unit test verifies a single “unit” of behavior — typically one function, method, or class — in isolation from its collaborators (database, network, filesystem, clock). Isolation is what makes unit tests fast and deterministic: nothing outside the unit can make them slow or flaky.
The dominant structure is AAA — Arrange, Act, Assert:
- Arrange — set up inputs, dependencies, and expected state.
- Act — call the thing under test exactly once.
- Assert — check the outcome (return value, thrown error, or interaction).
Example — a pure unit test (Python + pytest) of a discount calculator, no I/O:
# pricing.py
def apply_discount(price: float, percent: float) -> float:
if not 0 <= percent <= 100:
raise ValueError("percent must be between 0 and 100")
return round(price * (1 - percent / 100), 2)
# test_pricing.py
import pytest
from pricing import apply_discount
def test_applies_percentage_discount():
# Arrange
price, percent = 100.0, 25.0
# Act
result = apply_discount(price, percent)
# Assert
assert result == 75.0
def test_rounds_to_two_decimals():
assert apply_discount(9.99, 10) == 8.99
@pytest.mark.parametrize("bad", [-1, 101, 999])
def test_rejects_out_of_range_percent(bad):
with pytest.raises(ValueError):
apply_discount(100.0, bad)
Note how the code is designed for testability: apply_discount takes its inputs as arguments and has no hidden dependencies. That is not an accident — testable code and well-factored code are the same code.
Test doubles: mocks, stubs, fakes, spies
When a unit depends on something slow, non-deterministic, or expensive (a DB, a payment gateway, now()), you replace that collaborator with a test double. Gerard Meszaros’ taxonomy:
| Double | What it does | Use when |
|---|---|---|
| Dummy | Passed but never used (fills a parameter slot). | You must supply an argument you don’t care about. |
| Stub | Returns canned answers to calls. | You need the collaborator to provide input (e.g. “return this user”). |
| Fake | A working but simplified implementation (in-memory DB, in-memory queue). | You want realistic behavior without the real dependency’s cost. |
| Spy | A stub that also records how it was called. | You need to assert an interaction happened and still return something. |
| Mock | Pre-programmed with expectations; fails the test if not called as specified. | The interaction itself is the behavior under test (e.g. “an email was sent”). |
The key distinction is state verification (assert on the returned value / resulting state — prefer this) vs behavior verification (assert that a call happened — use sparingly, as it couples tests to implementation). Over-mocking produces tests that pass while the system is broken, because they only test the mocks.
Example — stubbing a repository and spying on a mailer (Python unittest.mock):
# service.py
class SignupService:
def __init__(self, users, mailer):
self.users = users # a repository
self.mailer = mailer # sends emails
def register(self, email: str) -> int:
if self.users.find_by_email(email):
raise ValueError("email already registered")
user_id = self.users.create(email)
self.mailer.send_welcome(email) # side effect we care about
return user_id
# test_service.py
from unittest.mock import MagicMock
import pytest
from service import SignupService
def test_registers_new_user_and_sends_welcome():
users = MagicMock()
users.find_by_email.return_value = None # stub: no existing user
users.create.return_value = 42
mailer = MagicMock() # spy/mock
svc = SignupService(users, mailer)
user_id = svc.register("a@example.com")
assert user_id == 42 # state verification
mailer.send_welcome.assert_called_once_with("a@example.com") # behavior verification
def test_rejects_duplicate_email():
users = MagicMock()
users.find_by_email.return_value = {"id": 1} # stub: user exists
mailer = MagicMock()
with pytest.raises(ValueError):
SignupService(users, mailer).register("a@example.com")
mailer.send_welcome.assert_not_called() # no email on the failure path
Integration testing
An integration test verifies that two or more real components work together correctly — your code against a real database, a real message broker, an HTTP client against a running server. It catches the bugs unit tests structurally cannot: wrong SQL, migration mismatches, serialization quirks, transaction boundaries, connection handling.
The modern gold standard for backend integration tests is Testcontainers: spin up a real dependency (Postgres, Redis, Kafka…) in a throwaway Docker container per test run, so tests run against the same engine as production instead of a mock or an in-memory substitute.
Example — integration test hitting a real Postgres via Testcontainers (Python):
# test_user_repo_integration.py
import psycopg
import pytest
from testcontainers.postgres import PostgresContainer
from repo import UserRepo # real repository that runs SQL
@pytest.fixture(scope="module")
def pg():
with PostgresContainer("postgres:16-alpine") as container:
yield container.get_connection_url()
@pytest.fixture
def conn(pg):
with psycopg.connect(pg, autocommit=True) as c:
c.execute("""
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL
)
""")
c.execute("TRUNCATE users RESTART IDENTITY") # clean slate per test
yield c
def test_create_and_find_user(conn):
repo = UserRepo(conn)
new_id = repo.create("alice@example.com")
found = repo.find_by_email("alice@example.com")
assert found["id"] == new_id
assert found["email"] == "alice@example.com"
def test_unique_email_constraint_is_enforced(conn):
repo = UserRepo(conn)
repo.create("dup@example.com")
with pytest.raises(psycopg.errors.UniqueViolation):
repo.create("dup@example.com") # DB-level constraint, invisible to unit tests
Managing test data — fixtures and cleanup. Integration tests must start from a known state. Common strategies:
| Strategy | How | Trade-off |
|---|---|---|
| Truncate / recreate | Wipe tables before each test (as above). | Simple, robust; slower for big schemas. |
| Transaction rollback | Wrap each test in a transaction, roll back at the end. | Very fast; breaks if the code under test commits or uses its own transactions. |
| Fixtures / factories | Build objects with a factory (e.g. factory_boy, FactoryBot). | Readable, DRY; needs discipline to avoid coupling tests to shared data. |
| Migrations on a fresh DB | Run real migrations against the container. | Highest realism (catches migration bugs); slowest setup. |
Prefer per-test isolation: no test should depend on data left behind by another. Shared mutable state between tests is the #1 source of order-dependent flakiness.
Functional, end-to-end, and API testing
- Functional test — verifies a feature end to end from the outside, treating internals as a black box (e.g. “POST /orders creates an order and returns 201 with a location header”). For a backend this usually means driving the HTTP API.
- End-to-end (E2E) test — exercises the whole stack the way a real client would: through the API gateway, real services, real DB, sometimes a real browser. Highest confidence, highest cost, most flaky. Keep these few and focused on critical journeys.
Example — a functional API test with an in-process test client (Python + FastAPI + pytest):
from fastapi.testclient import TestClient
from app import app # your FastAPI application
client = TestClient(app)
def test_create_order_returns_201_and_location():
resp = client.post("/orders", json={"sku": "ABC-1", "qty": 2})
assert resp.status_code == 201
assert resp.headers["Location"].startswith("/orders/")
body = resp.json()
assert body["sku"] == "ABC-1"
assert body["status"] == "PENDING"
def test_rejects_invalid_quantity():
resp = client.post("/orders", json={"sku": "ABC-1", "qty": 0})
assert resp.status_code == 422 # validation error
Contract testing. In a microservices system, full E2E across all services is slow and brittle. Contract testing (e.g. Pact) verifies the agreement between a consumer and a provider without running both together: the consumer records the requests it makes and the responses it expects (a “pact”), and the provider is independently verified against that recorded contract in its own pipeline. This catches breaking API changes early while keeping each service’s tests fast and isolated.
// consumer.pact.test.js — consumer side (Pact, JS)
const { PactV3, MatchersV3 } = require("@pact-foundation/pact");
const { like } = MatchersV3;
const provider = new PactV3({ consumer: "web", provider: "user-service" });
test("gets a user by id", () => {
provider
.given("a user with id 42 exists")
.uponReceiving("a request for user 42")
.withRequest({ method: "GET", path: "/users/42" })
.willRespondWith({
status: 200,
headers: { "Content-Type": "application/json" },
body: like({ id: 42, email: "a@example.com" }),
});
return provider.executeTest(async (mockServer) => {
const user = await getUser(mockServer.url, 42); // your client code
expect(user.id).toBe(42);
});
});
// The generated pact file is later verified against the real user-service.
Other kinds of tests
| Kind | Purpose | Notes |
|---|---|---|
| Regression | Ensure a previously fixed bug stays fixed. | Every bug fix should ship with a test that fails before the fix and passes after. |
| Smoke | A quick “is it alive?” check after deploy. | Hit a handful of critical endpoints/health checks; fail fast if the build is fundamentally broken. |
| Load / performance | Measure behavior under expected and peak traffic (throughput, latency percentiles). | Tools: k6, Locust, JMeter, Gatling. See Scalability and Reliability. |
| Stress / soak | Push past limits, or run long to expose leaks. | Reveals memory leaks, connection exhaustion, degradation over time. |
| Security | Find vulnerabilities. | SAST (static), DAST (running app), dependency scanning (SCA), fuzzing. |
| Property-based | Assert invariants over generated inputs instead of hand-picked examples. | Tools: Hypothesis (Python), fast-check (JS), QuickCheck. Great for finding edge cases you’d never think of. |
Example — property-based test (Python + Hypothesis) asserting an invariant of our discount function:
from hypothesis import given, strategies as st
from pricing import apply_discount
@given(
price=st.floats(min_value=0, max_value=1_000_000, allow_nan=False),
percent=st.floats(min_value=0, max_value=100),
)
def test_discounted_price_never_exceeds_original(price, percent):
assert apply_discount(price, percent) <= price + 0.01 # invariant, any valid input
TDD and BDD
TDD (Test-Driven Development) flips the order: write the test first, watch it fail, then write the minimum code to pass, then refactor. The Red → Green → Refactor loop:
- Red — write a failing test for the next small behavior.
- Green — write the simplest code that makes it pass (even if ugly).
- Refactor — clean up with the test as a safety net; repeat.
TDD’s real payoff is design: because you consume your own API before it exists, you tend to produce smaller, more decoupled units. It is a skill, not a mandate — it shines for well-understood logic and struggles for exploratory/UI work.
BDD (Behavior-Driven Development) raises the abstraction to business behavior, expressed in a ubiquitous language shared with non-engineers, often via Given/When/Then (Gherkin):
Feature: Order checkout
Scenario: Successful order with a valid coupon
Given a cart with 2 items totaling $100
And a valid coupon "SAVE10"
When the customer checks out
Then the order total should be $90
And a confirmation email should be sent
Tools like Cucumber or behave map each step to code. BDD is less about a tool and more about specifying behavior collaboratively before building it.
Deterministic tests and flaky tests
A flaky test passes and fails without any code change. Flakiness destroys trust — once a suite “sometimes goes red”, people start ignoring red, which defeats the entire point. Common causes and fixes:
| Cause | Fix |
|---|---|
| Time / clocks | Inject the clock; use a fixed/fake time instead of now(). |
| Randomness | Seed the RNG; or use property-based tests deliberately. |
| Shared state / test order | Isolate each test; reset DB, caches, globals between tests. |
| Real network calls | Stub external HTTP; use recorded responses (VCR/nock) or Testcontainers. |
Async / timing (sleep) | Poll for a condition with a timeout instead of sleeping a fixed duration. |
| Unordered collections | Sort before asserting; don’t assume dict/set ordering. |
The golden rule: a test must produce the same result every time, independent of the machine, the wall clock, the network, and the order tests run in. Quarantine flaky tests fast; a flaky test is worse than no test because it trains people to ignore failures.
Coverage — and why 100% isn’t the goal
Code coverage measures the fraction of code executed by your tests (line, branch, statement, or function coverage). It is a useful negative signal — coverage of 20% tells you large parts of the code are never exercised — but a poor positive one: 100% coverage only proves the lines ran, not that you asserted the right things.
# 100% line coverage, zero real testing:
def test_runs_but_asserts_nothing():
apply_discount(100, 10) # executes the line... asserts nothing
Guidance:
- Treat coverage as a floor to catch regressions in coverage, not a target to game (e.g. “don’t drop below 80%”).
- Chase branch/path coverage of important logic over raw line percentage.
- Some code (trivial getters, generated code, glue) is not worth testing; don’t contort tests to hit it.
- The real question isn’t “what % is covered” but “if I break behavior X, does a test fail?”
Testing in CI
Tests deliver most of their value when run automatically on every change in CI/CD, not just on developer laptops. A typical pipeline runs stages fastest-first so it fails cheaply:
- 01lint + type-check
- 02unit tests
- 03integration testsTestcontainers/services
- 04build
- 05contract/API tests
- 06deploy to staging
- 07smoke + E2E
CI-specific practices:
- Fail fast: run static analysis and unit tests first; they catch most issues in seconds.
- Parallelize: shard slow suites across runners; run independent jobs concurrently.
- Ephemeral dependencies: use service containers / Testcontainers so every run gets a clean DB.
- Required checks: make the suite a merge gate on pull requests (branch protection).
- Publish artifacts: coverage reports, JUnit XML, and traces for flaky-test triage.
- Keep it fast: a slow pipeline gets bypassed. Budget the critical path to minutes, not tens of minutes.
Best Practices
- Write tests at the cheapest layer that catches the bug. Prefer static analysis and unit tests; use integration tests for the seams; keep E2E thin and focused on critical journeys.
- Follow AAA (Arrange–Act–Assert) and keep one logical assertion focus per test. A test should fail for exactly one reason.
- Name tests by behavior, not by method —
test_rejects_duplicate_email, nottest_register_2. The name should read like a spec. - Test behavior, not implementation. Assert on observable outcomes, not on private internals; otherwise every refactor breaks the suite.
- Don’t over-mock. Mock at architectural boundaries (network, DB when you must, third parties), not every collaborator. Prefer fakes over mocks; prefer state verification over interaction verification.
- Keep tests independent and order-agnostic. No shared mutable state; each test sets up and tears down its own world.
- Make tests deterministic. Inject clocks and randomness; never depend on real time, real network, or sleep-based timing.
- Treat test code as production code. It gets read and maintained more than most; keep it DRY with fixtures/factories, but not so clever it becomes unreadable.
- Every bug fix ships with a regression test that fails before the fix.
- Practice TDD where the logic is well understood; it improves design even if you don’t do it religiously.
- Run the full suite in CI as a merge gate, fastest stages first, with clean ephemeral dependencies.
- Chase confidence, not coverage numbers. Use coverage to find untested code, not to hit a vanity target.
- Quarantine flaky tests immediately and fix or delete them; a flaky suite trains the team to ignore red.
References
- Martin Fowler — Test Pyramid
- Kent C. Dodds — The Testing Trophy and Testing Classifications
- Martin Fowler — Mocks Aren’t Stubs
- Martin Fowler — Eradicating Non-Determinism in Tests
- Testcontainers — Documentation
- Pact — Contract Testing Documentation
- pytest — Documentation
- Google Testing Blog — Code Coverage Best Practices