课程 · 12 · 09 / 12
Testing Best Practices: pytest and TDD
Master pytest with fixtures, parametrization, and mocking. Learn test-driven development, achieve meaningful coverage, and write maintainable tests.
TIPLearning Objectives: After this lesson, you'll master pytest with fixtures, parametrization, and mocking, understand test-driven development, achieve meaningful coverage, and write maintainable tests.
Why Testing Matters
Tests are insurance for your code—they catch bugs before users do and give you confidence to refactor.
The Testing Pyramid
The pyramid says: write many cheap, fast tests at the bottom and few expensive, slow tests at the top. Each layer up costs more to write and maintain and runs slower, so you want progressively fewer of them.
| Layer | Share | Tests | Cost & Speed |
|---|---|---|---|
| Unit | ~70% | Individual functions/classes, isolated, external dependencies mocked | Fastest, cheapest |
| Integration | ~20% | Component interactions with a real database or API calls | Medium speed |
| End-to-End | ~10% | Full user workflows via browser automation and real systems | Slowest, most expensive |
Whatever the layer, good tests share five properties — the FIRST qualities:
- Fast — run in milliseconds, so the whole suite stays quick.
- Isolated — don't depend on other tests or on execution order.
- Repeatable — produce the same result every time, on any machine.
- Self-validating — pass or fail clearly, with no manual interpretation.
- Timely — written close to the code they exercise (ideally first).
pytest Fundamentals
Basic Test Structure
Test Organization
Fixtures: Setup and Teardown
Fixtures provide reusable test setup and cleanup.
Basic Fixtures
Fixture Scopes
A fixture's scope controls how often it is created and torn down. A narrower scope is more isolated; a wider scope avoids repeating expensive setup. Choose the widest scope that keeps your tests independent.
| Scope | Created | Use it for |
|---|---|---|
function (default) | Once per test function | The common case — most isolated |
class | Once per test class | State shared among a class's methods |
module | Once per test file | Expensive setup reused across a file |
session | Once per test session | Very expensive operations, e.g. a DB connection |
A wider-scoped fixture can be consumed by a narrower-scoped one. Here a module-scoped connection is opened once, while each test gets a fresh transaction that rolls back afterward:
@pytest.fixture(scope="module") def database_connection(): """Expensive: create once per module.""" conn = create_connection() yield conn conn.close() @pytest.fixture def transaction(database_connection): """Uses the module-scoped fixture.""" tx = database_connection.begin() yield tx tx.rollback()
The yield pattern. Code before yield is setup; the value yielded is what the test receives; code after yield is teardown that runs once the test completes — even if it fails. This is how you guarantee cleanup:
@pytest.fixture def temp_file(): """Create a temp file, clean it up after the test.""" import tempfile import os # Setup fd, path = tempfile.mkstemp() yield path # Teardown (runs after the test completes) os.close(fd) os.unlink(path) def test_write_to_file(temp_file): with open(temp_file, 'w') as f: f.write("test data") with open(temp_file) as f: assert f.read() == "test data" # temp_file is automatically cleaned up
Parametrization: Test Multiple Cases
Mocking: Isolate Units
Patching
Test-Driven Development (TDD)
TIP▶ Try this first. Open the AlgorithmStepper below and step through the TDD loop one phase at a time — watch the status flip from RED (the test fails because the code doesn't exist) to GREEN (minimal code makes it pass) to REFACTOR (the code improves while tests stay green). Come back to the theory once you've seen the cycle move.
TDD inverts the usual order: you write the test before the code, then loop through three phases for every new piece of behavior.
- RED — write a failing test. Describe the new functionality as a test, run it, and watch it fail. The failure confirms the test is actually exercising something that doesn't exist yet.
- GREEN — make the test pass. Write the minimal code needed to go green. Don't over-engineer; just make it work.
- REFACTOR — improve the code. Clean up the implementation and remove duplication. The passing tests are your safety net that nothing broke.
Then repeat the loop for the next feature.
The example below applies this cycle to build a Stack: the tests are written first, then a minimal implementation makes them pass.
Testing Async Code
Coverage and Quality
Coverage tells you which lines your tests actually exercise. The standard tool is pytest-cov:
pip install pytest-cov pytest --cov=mypackage --cov-report=html
There are several kinds of coverage:
- Line coverage — which lines were executed.
- Branch coverage — which branches (each side of an
if/else) were taken. - Function coverage — which functions were called.
Treat coverage as a guide, not a goal. 80%+ is healthy for most projects; chasing 100% is usually overkill. Spend your effort on the critical paths:
| Worth testing | Skip |
|---|---|
| Business logic | Third-party libraries |
| Edge cases | Simple getters/setters |
| Error handling | Framework code |
| Integration points | Configuration |
A coverage report looks like this — Miss is the count of un-executed statements, and the tool points you at the exact missing lines:
Name Stmts Miss Cover -------------------------------------------- mypackage/__init__.py 5 0 100% mypackage/calculator.py 20 2 90% mypackage/utils.py 15 5 67% -------------------------------------------- TOTAL 40 7 82% Missing lines: calculator.py:45-46, utils.py:12-16
Best Practices
A handful of habits keep a test suite trustworthy and pleasant to work in:
-
Name tests descriptively. Follow
test_<what>_<condition>_<expected>, e.g.test_divide_by_zero_raises_errorortest_empty_list_returns_none. The name should read like a spec. -
Structure each test as Arrange-Act-Assert (AAA). Set up inputs, perform the action, then assert on the result:
def test_user_creation(): # Arrange name = "Alice" email = "alice@example.com" # Act user = create_user(name, email) # Assert assert user.name == name assert user.email == email -
Prefer one logical assertion per test. It makes failures easy to pinpoint. The exception is a small group of closely related assertions about the same outcome.
-
Test the edge cases, not just the happy path: empty inputs,
Nonevalues, boundary values, and large inputs. -
Test behavior, not implementation. Assert on the public interface, not private internals:
# ✗ couples the test to internals assert len(user._internal_list) == 3 # ✓ tests observable behavior assert user.get_items() == [1, 2, 3] -
Use fixtures to stay DRY. Share setup across tests instead of repeating it.
-
Keep tests fast. Mock slow operations, use in-memory databases, and parallelize with
pytest -n auto. -
Keep tests independent. No shared state between tests; execution order must not matter.
Finally, a few pytest command-line flags worth memorizing:
pytest -v # Verbose output pytest -x # Stop on first failure pytest -k "pattern" # Run tests matching a name pattern pytest -m "marker" # Run marked tests pytest --lf # Run last failed pytest --ff # Run failed first pytest -n auto # Parallel execution (pytest-xdist) pytest --pdb # Drop into the debugger on failure
Practice Exercises
Exercise 1: Test a Shopping Cart
Key Takeaways
| Concept | Description |
|---|---|
| Fixtures | Reusable test setup and teardown |
| Parametrize | Run same test with different data |
| Mock | Replace dependencies with fake objects |
| Patch | Temporarily replace module attributes |
| TDD | Red-Green-Refactor cycle |
| Coverage | Measure which code is tested |
| AAA | Arrange, Act, Assert pattern |
Testing Checklist
- Unit tests for business logic
- Edge cases and error handling
- Integration tests for components
- Mocks for external dependencies
- Async tests with pytest-asyncio
- Coverage > 80% for critical code
- Fast test suite (< 1 minute)
- CI pipeline runs all tests
Next Steps
In the next lesson, we'll explore Design Patterns in Python—implement classic patterns like Factory, Strategy, Observer, and Dependency Injection the Pythonic way.
Ready to write elegant code? Design patterns await!
Further Reading
Official Docs
- pytest Documentation — the test framework nearly everyone uses. Start with Getting Started.
- pytest — Fixtures — the killer feature. Covers dependency-injection, scopes, and the
yieldfixture pattern. - pytest — Parametrize — run the same test against many inputs.
- Python —
unittest.mock— official mocking. Or usepytest-mockfor better ergonomics.
Tutorials & Talks
- Real Python — Getting Started With Testing in Python — the canonical primer.
- Brian Okken — Test & Code podcast — author of Python Testing with pytest. Every episode is gold.
Modern Testing Stack
pytest-xdist— run your test suite in parallel on all CPU cores.pytest-mock— friendlier API forunittest.mock.pytest-cov— coverage plugin.hypothesis— property-based testing. Finds bugs that example-based tests miss. Life-changing once you adopt it.syrupy— modern snapshot testing.pytest-asyncio— testasyncfunctions.
Mutation & Property-Based Testing
mutmut— mutation testing. Shows which parts of your code are actually covered by assertions, not just "executed."- Hypothesis — Quick Start Guide — the 10-minute tour.
Books
- Book: Python Testing with pytest (2nd ed., 2022) — Brian Okken. Definitive.
- Book: Test-Driven Development with Python — Harry Percival (free online). TDD applied to a real Django app.