← Backend← Backend
BackendBackend19 Th7, 2026Jul 19, 202620 phút đọc18 min read

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ý doBạn được gì
Regression protectionHành vi cũ vẫn đúng khi code mới được thêm vào.
An toàn khi refactorBạ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 đượcMộ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 nhanhMột test đỏ trên máy bạn tốt hơn một dashboard đỏ trên production.
Tự tin deployCI 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âyVài giây đến vài phút
Công viếtThấpCao
Công bảo trìThấpCao (dễ vỡ)
Độ thực tế / confidenceHẹp — một unitRộng — cả hệ thống
FlakinessHiếmThường gặp (network, timing, state)
Định vị lỗiChí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:

  1. Arrange — set up input, dependency, và trạng thái mong đợi.
  2. Act — gọi thứ đang được test đúng một lần.
  3. 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:

DoubleNó 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.
StubTrả 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”).
FakeMộ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.
SpyLà 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ẫ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ượcCách làmĐánh đổi
Truncate / recreateXó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 rollbackBọ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 / factoriesTạ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ớiChạ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

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ạiMục đíchGhi 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.
SmokeKiể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.
SecurityTìm lỗ hổng bảo mật.SAST (static), DAST (app đang chạy), dependency scanning (SCA), fuzzing.
Property-basedAssert 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:

  1. Red — viết một test fail cho hành vi nhỏ tiếp theo.
  2. Green — viết code đơn giản nhất để nó pass (kể cả xấu).
  3. 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ânCách xử lý
Time / clockInject clock; dùng thời gian cố định/giả thay vì now().
RandomnessSeed cho RNG; hoặc dùng property-based test một cách có chủ đích.
Shared state / thứ tự testIsolate từng test; reset DB, cache, global giữa các test.
Gọi network thậtStub 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:

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ẻ:

CI pipeline
  1. 01lint + type-check
  2. 02unit tests
  3. 03integration testsTestcontainers/services
  4. 04build
  5. 05contract/API tests
  6. 06deploy to staging
  7. 07smoke + E2E

Thực hành đặc thù cho CI:

Best Practices

Tài liệu tham khảo

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?

ReasonWhat it buys you
Regression protectionOld behavior stays correct as new code lands.
Refactoring safetyYou can restructure internals because tests pin behavior, not implementation.
Executable documentationA well-named test describes what the code should do, always in sync with reality.
Design pressureHard-to-test code is usually badly designed (tight coupling, hidden state). Tests push you toward small, injectable units.
Faster feedbackA red test on your machine beats a red dashboard in production.
Confidence to deployCI 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

PropertyMove down (unit)Move up (E2E)
SpeedMillisecondsSeconds to minutes
Cost to writeLowHigh
Cost to maintainLowHigh (brittle)
Realism / confidenceNarrow — one unitBroad — whole system
FlakinessRareCommon (network, timing, state)
Failure localizationPrecise (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:

  1. Arrange — set up inputs, dependencies, and expected state.
  2. Act — call the thing under test exactly once.
  3. 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:

DoubleWhat it doesUse when
DummyPassed but never used (fills a parameter slot).You must supply an argument you don’t care about.
StubReturns canned answers to calls.You need the collaborator to provide input (e.g. “return this user”).
FakeA working but simplified implementation (in-memory DB, in-memory queue).You want realistic behavior without the real dependency’s cost.
SpyA stub that also records how it was called.You need to assert an interaction happened and still return something.
MockPre-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:

StrategyHowTrade-off
Truncate / recreateWipe tables before each test (as above).Simple, robust; slower for big schemas.
Transaction rollbackWrap 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 / factoriesBuild objects with a factory (e.g. factory_boy, FactoryBot).Readable, DRY; needs discipline to avoid coupling tests to shared data.
Migrations on a fresh DBRun 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

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

KindPurposeNotes
RegressionEnsure a previously fixed bug stays fixed.Every bug fix should ship with a test that fails before the fix and passes after.
SmokeA quick “is it alive?” check after deploy.Hit a handful of critical endpoints/health checks; fail fast if the build is fundamentally broken.
Load / performanceMeasure behavior under expected and peak traffic (throughput, latency percentiles).Tools: k6, Locust, JMeter, Gatling. See Scalability and Reliability.
Stress / soakPush past limits, or run long to expose leaks.Reveals memory leaks, connection exhaustion, degradation over time.
SecurityFind vulnerabilities.SAST (static), DAST (running app), dependency scanning (SCA), fuzzing.
Property-basedAssert 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:

  1. Red — write a failing test for the next small behavior.
  2. Green — write the simplest code that makes it pass (even if ugly).
  3. 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:

CauseFix
Time / clocksInject the clock; use a fixed/fake time instead of now().
RandomnessSeed the RNG; or use property-based tests deliberately.
Shared state / test orderIsolate each test; reset DB, caches, globals between tests.
Real network callsStub 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 collectionsSort 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:

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:

CI pipeline
  1. 01lint + type-check
  2. 02unit tests
  3. 03integration testsTestcontainers/services
  4. 04build
  5. 05contract/API tests
  6. 06deploy to staging
  7. 07smoke + E2E

CI-specific practices:

Best Practices

References