Test-Driven Development in Python with pytest: A Practical Guide
Use pytest to practice red-green-refactor on a realistic inventory rule. Write each failing test first, add only enough code, and refactor safely.
Article summary
TL;DR
- Start each TDD cycle with one test for the next observable behavior, then confirm that it fails for the reason you expected.
- Write the smallest production change that turns the test green; do not predict requirements that no failing test demands.
- Refactor only while the suite is green, and keep tests focused on public behavior so internal design can change safely.
- Use TDD where requirements are concrete and feedback is fast; use characterization tests or a thin integration test when legacy code and external systems make the boundary uncertain.
01 / WHY TDD
Why test-driven development changes Python design decisions.
Most Python tests are written after the implementation. The code already has a shape, its edge cases are already implied, and the test often becomes a description of what the developer happened to build.
Test-driven development changes that order. You describe one observable behavior first, run the test and see it fail, add the smallest implementation that makes it pass, then improve the design while the tests remain green.
That short feedback loop is commonly called red-green-refactor:
- Red: write a small test for behavior that does not exist and confirm that it fails for the expected reason.
- Green: write only enough production code to make that test pass.
- Refactor: improve names, structure, or duplication without changing behavior.
- Repeat: choose the next behavior and begin another small cycle.
The value is not that tests appear before code as a matter of ceremony. The value is that a failing example forces you to decide what the code should do before you become attached to how it does it.
This practical pytest tutorial applies test-driven development in Python to a small inventory rule. The example is compact enough to follow line by line, but it includes state, an important failure path, and an input boundary. Those are the places where TDD becomes more useful than a calculator kata.
02 / THE LOOP
Red, green, and refactor are three different jobs.
A useful TDD cycle keeps each stage narrow. When the stages blur together, it becomes easy to write the implementation and its tests at the same time, see a green result, and assume the test could have caught a mistake.
Red proves that the test can detect missing behavior
Write one example of the next requirement. Run it immediately. Read the failure before touching production code.
The red stage represents a controlled failure. The failing-test card and red X are not signs that the work went wrong. They show that the new test reached the behavior you intend to add and can detect that it is still missing.

A red test is useful only when it fails for the reason you intended. A misspelled import, the wrong working directory, or a broken fixture is red, but it says nothing about the behavior you meant to add. Fix unrelated failures until the new test reaches the expected assertion or missing implementation.
A test that has never failed has not yet shown that it can detect the behavior being absent.
Green is deliberately small
The green stage is the moment the expected behavior works. The illustration shows a green check being written because the test now passes, but green does not mean the feature is complete or the design is final.

Satisfy the current example with the simplest honest implementation. A small change makes the cause of the new result obvious and leaves fewer decisions to debug.
Small does not mean careless. Do not hard-code a return value that merely recognizes the test input. Implement the rule expressed by the example, but stop before adding validation, configuration, or abstractions that no test requires.
Refactor protects the design
The construction helmet in the refactor stage represents design work performed after behavior is protected. The test remains green while you improve names, move responsibilities, or remove real duplication.

Refactoring is an inspection step, not a quota. If the code is already clear, leave it alone and start the next red test. Extracting a helper simply because the cycle contains a refactor phase creates indirection without improving the design.
The three stages form one feedback loop
Viewed together, the stages are not a one-time checklist. Red defines the next missing behavior, green proves the smallest solution, and refactor improves the design without changing the result. The arrows return to red because every new requirement starts another focused cycle.

Continue with the complete Pytest course to practice TDD, fixtures, mocking, API tests, coverage, and CI/CD on larger Python examples.
View course03 / FIRST RED TEST
Write the first failing pytest test before the Python code.
Imagine a booking service that tracks how many seats remain. The first requirement is intentionally narrow:
Requirement: Reserving seats reduces the available quantity.
Create an empty inventory.py file and add this test to test_inventory.py:
from inventory import Inventory
def test_reserving_seats_reduces_available_quantity():
inventory = Inventory(available=3)
inventory.reserve(2)
assert inventory.available == 1
If pytest is new to you, first install pytest and run your first Python test. Here we will keep the setup out of the way and focus on the TDD workflow.
Run only the new test while the cycle is small:
python -m pytest test_inventory.py::test_reserving_seats_reduces_available_quantity -q
The test fails because Inventory does not exist. That is the expected reason: the behavior has no implementation yet. If pytest reports that it cannot find test_inventory.py, fix the path first. If another test fails, narrow the command until this new behavior is the signal you are reading.
Notice what the test commits to and what it leaves open. It commits to creating an inventory with three available seats, reserving two, and observing one remaining seat. It does not choose a database, an API, a repository interface, or a reservation object. None of those decisions are required yet.
04 / MAKE IT GREEN
Add the smallest implementation that satisfies the rule.
Put this code in inventory.py:
class Inventory:
def __init__(self, available: int) -> None:
self.available = available
def reserve(self, quantity: int) -> None:
self.available -= quantity
Run the same focused command:
python -m pytest test_inventory.py::test_reserving_seats_reduces_available_quantity -q
The test should pass. The implementation is small, but it is not a fake that returns the expected value for 2. It expresses the rule the test asked for: reserving a quantity reduces the available quantity.
This version is incomplete in ways that are easy to see. Reserving four seats from an inventory of three would produce -1. Reserving zero or a negative quantity would also change state incorrectly. Do not fix those cases while every test is green. Turn the next important rule into the next red test first.
Stopping at green can feel artificial when the missing validation is obvious. That pause is useful. It keeps the new behavior, the code that satisfies it, and the reason for the change close together.
05 / DRIVE THE BOUNDARY
Let a failure path drive the next design decision.
The next requirement protects inventory state:
Requirement: A reservation larger than the available quantity is rejected, and availability does not change.
Add pytest and the expected domain exception to the test file:
import pytest
from inventory import Inventory, OutOfStockError
def test_reserving_seats_reduces_available_quantity():
inventory = Inventory(available=3)
inventory.reserve(2)
assert inventory.available == 1
def test_reserving_more_than_available_is_rejected():
inventory = Inventory(available=2)
with pytest.raises(OutOfStockError, match="not enough seats"):
inventory.reserve(3)
assert inventory.available == 2
Run the new test directly:
python -m pytest test_inventory.py::test_reserving_more_than_available_is_rejected -q
It first fails because OutOfStockError does not exist. Add the exception and the check before the state mutation:
class OutOfStockError(Exception):
pass
class Inventory:
def __init__(self, available: int) -> None:
self.available = available
def reserve(self, quantity: int) -> None:
if quantity > self.available:
raise OutOfStockError("not enough seats")
self.available -= quantity
Both tests now pass. The assertion after pytest.raises matters: it proves that rejection happens before mutation. Checking only the exception would allow an implementation to subtract the quantity and then raise, leaving corrupted state behind.
Now add the input boundary. Zero and negative quantities have the same expected behavior, so pytest parametrization keeps the shared rule visible without merging unrelated cases:
@pytest.mark.parametrize("quantity", [0, -1], ids=["zero", "negative"])
def test_non_positive_quantity_is_rejected(quantity):
inventory = Inventory(available=3)
with pytest.raises(ValueError, match="quantity must be positive"):
inventory.reserve(quantity)
assert inventory.available == 3
Run that test and watch it fail before adding this check at the start of reserve:
if quantity <= 0:
raise ValueError("quantity must be positive")
Pytest runs the parametrized function once for zero and once for negative one. Separate tests would be better if the cases required different behavior or setup. Here they are two examples of the same input contract.
06 / REFACTOR
Refactor while every behavior is protected.
The method now validates two preconditions and then mutates state. Keeping mutation visibly after validation is the important design property. We can make that order clearer by giving validation its own name:
class OutOfStockError(Exception):
pass
class Inventory:
def __init__(self, available: int) -> None:
self.available = available
def reserve(self, quantity: int) -> None:
self._validate_reservation(quantity)
self.available -= quantity
def _validate_reservation(self, quantity: int) -> None:
if quantity <= 0:
raise ValueError("quantity must be positive")
if quantity > self.available:
raise OutOfStockError("not enough seats")
Run the complete file after the structural change:
python -m pytest test_inventory.py -q
The result should report four passing cases: the normal reservation, the out-of-stock path, zero, and the negative quantity.
The complete runnable inventory example on GitHub contains the final inventory.py, tests, locked dependencies, and exact commands. Clone it or open the files beside this guide, then run uv sync --locked and uv run pytest test_inventory.py -v from the example directory.
The tests do not mention _validate_reservation. That helper is an implementation detail. Testing it directly would make a later inline or redesign look like a behavioral change even if reserve still kept every promise.
This is where tests drive design without owning it. The tests protect the public result and the state invariant. The implementation is free to move validation, replace in-memory state with a repository, or introduce a richer domain object later. A new external boundary would deserve an integration test, but the business behavior should remain stable.
Do not add rules for negative initial availability, cancellation, concurrent reservations, or database transactions until the requirements demand them. TDD reduces speculative design only when you resist implementing future scenarios that have no failing example.
07 / REAL PROJECTS
Use TDD in Python when the next behavior is clear.
The inventory example has three properties that make TDD effective: the requirement is concrete, the feedback is fast, and the result can be observed through a small public boundary.
TDD is often a good fit for:
- business rules with clear examples and boundaries;
- bug fixes that can begin with a regression test;
- parsers, formatters, validators, and transformations;
- service methods whose dependencies can be replaced without hiding the behavior;
- refactoring where existing tests already describe the contract.
It is less direct when you are exploring an unfamiliar API, tuning a model, discovering a user experience, or debugging a failure you cannot yet reproduce. In those situations, a short experiment may be the honest first step. Once the behavior becomes clear, capture it with a test and continue in a smaller loop.
Put TDD into practice in the complete Pytest course.
View courseWhy write the test before the Python code?
Writing the test first makes the missing behavior observable. A test added after the implementation may pass immediately, so you have not observed it detect the behavior being absent. Watching it fail for the expected reason gives you evidence before you rely on it.
It also limits the next design decision. You specify one public result, implement only that rule, and postpone abstractions until another behavior requires them.
Is test-driven development really worth it?
It is worth the cost when an example helps you make a design decision early or prevents a meaningful regression later. It is not automatically valuable because every production line has a corresponding test.
TDD can slow you down when tests depend on private calls, every unit is mocked, or the feedback loop requires a full environment. Those are signals to reconsider the boundary, not reasons to generate more test code.
Does TDD mean every test must be written first?
No practical workflow needs that purity. Characterization tests are written after legacy behavior already exists. Integration and end-to-end tests may be added after a thin vertical slice reveals the real contract. A production incident may require exploration before you can express the failure deterministically.
The useful discipline is narrower: when you know the next behavior and can test it cheaply, see the test fail before writing the change. That protects the evidence that the test can detect the missing behavior.
Should TDD use only unit tests?
Fast unit tests make the red-green-refactor loop comfortable, but the correct boundary depends on the risk. A unit test is too narrow when the failure lives in a database constraint, serialization contract, HTTP integration, or permission boundary.
Use the cheapest test that can detect the failure you care about. Keep most inner-loop tests fast, then add a smaller number of integration tests for boundaries that mocks would erase. That balance is more useful than treating TDD as a rule about test labels.
For legacy code, start at a stable pinch point. Add a characterization test around the observable behavior, make the smallest safe change, and extract smaller units only when the protected behavior gives you room to refactor.
08 / NEXT CYCLE
Use one small TDD cycle on tomorrow’s change.
Choose a requirement with one visible result. Write the smallest example, run it, and confirm the failure explains the missing behavior. Make it green with one focused change. Then read both the test and implementation before deciding whether a refactor would improve either one.
Use this checklist:
- Name one behavior, not an implementation task.
- Run the new test before changing production code.
- Confirm that the failure is the one you expected.
- Add the smallest honest implementation.
- Run the focused test, then the related suite.
- Refactor only while everything is green.
- Repeat with the next boundary or failure path.
Once the loop works locally, keep it fast enough to use during development and run the wider contract automatically. The pytest and GitHub Actions guide shows how to turn the same local test command into a repeatable CI check.
For the next change, do not start by planning an entire test suite. Start with one behavior that matters, see it fail, and earn the next small green step.
More field notes
Keep reading.
Pytest for Beginners: Write and Run Your First Python Test
If you know how to write a Python function, you know enough to start testing. Build, run, and debug your first pytest test without creating a package.
pytestPython testingGitHub Actions Python Testing: A Complete pytest CI Guide
Build a pytest workflow in GitHub Actions with dependency caching, multiple Python versions, branch coverage, reports, and a stable merge check.
pytestGitHub ActionsHow to Onboard a Python Team to Testing with Pytest
Learn how a 15-developer Python team used shared practice, legacy-code pinch points, and CI to turn pytest into an everyday engineering habit.
pytestPython testing