↓Skip to main content
  1. Testing & Quality/

pytest Basics: Tests, Fixtures, and Clear Assertions

·1984 words·10 mins·
Python pytest Testing Unit Testing
Ifarra
Author
Ifarra
Disturbing the peace!!
Table of Contents

pytest is a widely used Python test runner with a small syntax and helpful failure output. A test can be an ordinary function whose name starts with test_; assertions use Python’s built-in assert statement.

pytest discovers a test, prepares its fixtures, runs assertions, then cleans up
Fixtures make setup and teardown explicit.

Install pytest and write a first test
#

Create and activate a virtual environment, then install pytest:

python -m venv .venv
source .venv/bin/activate  # Windows: .venv\\Scripts\\activate
python -m pip install pytest

For example, if discount.py contains:

def apply_discount(price: float, percent: float) -> float:
    if price < 0 or not 0 <= percent <= 100:
        raise ValueError("invalid price or discount")
    return price * (1 - percent / 100)

Create test_discount.py:

import pytest
from discount import apply_discount

def test_applies_percentage_discount():
    assert apply_discount(80, 25) == 60

def test_rejects_discount_over_one_hundred_percent():
    with pytest.raises(ValueError, match="invalid price"):
        apply_discount(80, 120)

Run pytest from the project root. pytest discovers test files and functions by naming convention.

Use fixtures for shared resources
#

A fixture provides a value or resource that a test needs. Fixtures are useful for temporary directories, configured application clients, or a database session. Keep setup close to the tests and make cleanup explicit when a resource must be released.

import pytest

@pytest.fixture
def cart():
    return {"items": [], "total": 0}

def test_new_cart_is_empty(cart):
    assert cart["items"] == []
    assert cart["total"] == 0

pytest creates the fixture for the test and injects it by parameter name. Fixtures should make dependencies easier to understand, not hide complicated behavior behind a chain of implicit setup.

Make failures informative
#

Assert the behavior that matters and choose test names that describe it. Separate unrelated expectations into separate tests when that improves diagnosis. Use pytest.raises for expected exceptions and check a stable part of the message only when the message is part of the contract.

Keep tests isolated
#

Avoid sharing mutable objects across tests. A fixture with function scope is the default and gives each test fresh state. For external services, use a fake or a test service rather than depending on a developer’s credentials or network.

Arrange, act, assert as a readable contract
#

A test should make three things easy to find: the starting conditions, the action being exercised, and the expected result. This is often called arrange, act, assert. The pattern is not a mandatory comment format; it is a way to make intent visible. A compact test for a discount rule can create a price and percentage, call the public function once, and assert the returned amount. Keep setup close to the assertion so a reviewer can tell why each value exists.

Prefer assertions about behavior over reproducing implementation details. If a function returns a sorted sequence, compare the sequence. If it raises for invalid input, assert the expected exception type and, when useful, a stable part of its message. Avoid asserting that a private helper was called unless that interaction is itself a required contract. Tests that mirror code structure tend to break during refactoring even when users see no change.

Pytest discovers functions whose names start with test_ and files such as test_prices.py. Keep naming consistent across the project. A name like test_discount_rejects_percentage_above_one_hundred gives useful failure output; test_invalid forces the reader to open the body to learn what failed. Keep one behavior per test when separate failures need separate diagnoses. Combine examples only when they express the same rule.

Fixtures are dependency injection for tests
#

Fixtures provide a clear place to construct reusable state. A fixture can return a temporary directory, a configured client, a database connection, or a small domain object. Pytest resolves a fixture by its function argument name and runs setup before the test. Scope controls how long the value is reused: function scope gives isolation, while module or session scope can save expensive startup work but risks shared mutable state.

Start with function-scoped fixtures. Expand scope only after measuring setup cost and confirming that a resource is safe to share. A session-scoped database does not mean each test should share rows. Use rollback, a unique schema, or explicit cleanup to isolate data. If a fixture hides important behavior, pass the dependency directly in the test instead of building a large fixture hierarchy.

Fixtures can depend on other fixtures, which is useful for composing a client from an application and a database. Keep this graph shallow enough to follow. Avoid autouse=True fixtures that silently change global state for every test unless the behavior is truly universal. When setup fails, pytest reports the fixture chain; descriptive names and narrow scopes make that report actionable.

Work at file, environment, and exception boundaries
#

For file behavior, tmp_path gives each test its own temporary directory. Write a small input file, call the public operation, and inspect the result. This avoids shared fixtures that leave files behind and behave differently on another machine. If a function must not overwrite data, create a file first and verify its contents remain unchanged after the operation rejects the request.

For environment variables, working directories, and module attributes, monkeypatch restores the original state after the test. Patch the boundary your code reads, not a deep helper that happens to be convenient today. For example, set a test API URL before constructing the client. Patching a module constant after the client captured it will not exercise the intended configuration path.

Use pytest.raises to document rejected input and inspect the exception. A broad pytest.raises(Exception) can pass when an unrelated bug occurs, so select the specific exception expected by the public contract. If the message is a stable user-facing interface, match a useful fragment; avoid asserting every character of text that may be edited for clarity.

Debug failures and flakiness systematically
#

Run the smallest failing test first with verbose output. pytest -q path/to/test_file.py::test_name narrows the feedback loop; -vv shows parametrized case IDs and assertion details. Use -x to stop at the first failure while debugging a cascade. When a failure depends on order, run the file repeatedly and inspect shared state, global caches, random seeds, and fixtures with wider scopes.

Do not fix asynchronous tests by adding a long sleep. If the application signals completion through a callback, event, or stored state, wait for that signal with a bounded timeout. For code that reads the current time, inject a clock or freeze time at the boundary. For randomness, control the seed or pass a deterministic generator. A test should be reproducible enough that a failure gives evidence about code rather than machine timing.

Use --lf to rerun last failures after a change, but run the full relevant suite before merging. Coverage shows which lines were never executed; it cannot tell you whether an assertion checks the right behavior. A line can be covered while the test accepts both correct and incorrect results. Mutation tools can help reveal weak assertions by changing logic and checking whether tests detect it.

A practical review checklist
#

Check that a test fails for the bug it claims to prevent. Temporarily invert a result or remove validation and confirm the test fails, then restore the code and confirm the failure message identifies the case. Use realistic boundary values: zero, empty input, the maximum allowed value, and a value just beyond it are usually more informative than several arbitrary middle values.

Keep fixtures and factories honest. A factory should not silently create an authenticated user when the test is about missing authentication. Make special states explicit. Avoid enormous JSON fixtures when a short dictionary explains the contract better. When a payload itself matters, store it as a named fixture and validate it against the API schema.

Balance duplication against indirection. Repeating three lines of obvious setup is often clearer than adding a fixture used by one test. A shared fixture is valuable when it gives several tests a consistent boundary or expensive resource. When a fixture has many options, split it into smaller fixtures or construct the special variation in the test. The goal is not the fewest lines; it is the least knowledge a maintainer needs to understand a failure.

A service-boundary example
#

Consider a function that calculates a shipping estimate through a carrier client. Keep the business decision in a small function and inject the client at its boundary. A unit test can use a fake response to verify that a free-shipping order skips the carrier, a valid quote is returned, and an unavailable carrier produces a documented fallback. The fake should expose only the needed behavior, not reproduce the vendor’s full SDK.

Add an integration check for the adapter that turns the application’s request into the carrier API shape. Use a recorded fixture or sandbox response and assert parsing of fields, units, and error codes. This catches a mismatch between the provider payload and your adapter without making every unit check dependent on the network. Keep fixtures versioned and remove personal information.

In pytest, a fixture can construct the fake and pass it into the service. A parameterized test can cover shipping zones while a separate test covers timeout behavior. Give cases IDs such as domestic, remote, and carrier-timeout; those labels make CI output easier to scan. Do not combine unrelated scenarios merely to reduce the number of functions.

Parameterize with readable cases
#

Use @pytest.mark.parametrize when the same rule should hold for several inputs. Keep the table small and name each case. For validation, include normal, boundary, just-outside, and malformed values. Avoid a huge Cartesian product where failures are hard to interpret. Split dimensions when each represents a different rule, or select combinations that matter to the domain.

If one case needs special setup, separate it instead of adding conditional logic inside a parametrized function. Parameter IDs should explain behavior without exposing secrets. For mutable parameters such as dictionaries, create fresh values for each case or avoid mutation so one case cannot affect the next. Property-based tests can describe broader invariants and explore generated inputs, but retain explicit examples for named business rules.

Isolate database integration tests
#

An integration fixture can create an application configured for testing and a database connection inside a transaction. Each test inserts its own records and rolls back afterward. If application code opens independent connections or commits internally, an outer transaction may not isolate it; use a disposable database, schema, or container. The fixture should reflect real transaction boundaries rather than hide them.

Do not share one mutable database among parallel workers unless each has a separate schema or database. Unique identifiers do not prevent one test’s cleanup from deleting another’s rows. Keep migrations in setup when migration correctness matters, and test against the same engine as production for database-specific behavior.

Fixtures that create data should make relationships obvious. Prefer order = make_order(customer=customer, total=...) over a factory with twenty invisible defaults. For authorization tests, explicitly construct the owner and another user so the boundary is visible. A well-designed fixture lowers repetition while preserving the scenario.

Make failures easy to reproduce
#

Use strict markers for slow integration or browser tests so developers can select a group without changing test code. Keep the default command documented. pytest -q is useful for routine feedback; a focused node ID helps diagnose one failure; verbose mode helps with parameters and fixture setup. Store the exact CI command in a script or log so local reproduction is straightforward.

Capture reports and logs only when they help explain a failure, and redact secrets and user data. Identify infrastructure failures separately from assertion failures. Do not make all tests retry automatically; retries can mask shared-state bugs. A retry may collect a trace, but it should not make the final status green without explanation.

Review tests as production code. Remove obsolete cases, update fixtures when domain rules change, and keep a regression case for each fixed defect. A stable suite gives the team a fast path from failure to cause; names, isolation, and narrow assertions make that possible.

Next: use parameterized tests for algorithms to cover many inputs without duplicating test code.

Related

Parameterized Tests for Algorithms with pytest
·390 words·2 mins
Python pytest Algorithms Testing
Build a compact test matrix for a two-sum implementation and learn when example tests are more useful than property tests.
Integration Testing a Flask API with pytest
·378 words·2 mins
Python Flask pytest API Testing
Configure an isolated test app, send HTTP-like requests, and check the API contract at the boundary.
Mocks, Stubs, and Fakes: Choosing Test Doubles
·350 words·2 mins
Testing Mocks Software Design Unit Testing
Compare stubs, mocks, fakes, and spies, then apply the smallest useful substitute in a test.