Write Better Python Tests with pytest

Learn how pytest simplifies Python testing with plain assert statements, fixtures, parametrize, markers, and built-in unittest compatibility.

8 min read

The pytest framework is the most widely used testing tool in the Python ecosystem. It replaces unittest's class structure with plain functions and the built-in assert statement. Less boilerplate, better output.

Install pytest with pip:

bashbash
pip install pytest

Once installed, the pytest command discovers and runs your tests. It finds files named test_*.py, functions starting with test_, and classes starting with Test. There is no need to subclass anything.

If you have worked through Python unittest explained, the concepts will feel familiar. pytest simply expresses them with less ceremony.

Plain assert statements

The most visible difference from unittest is how you write assertions. Instead of self.assertEqual(a, b), you write assert a == b. pytest inspects the assertion at runtime and, when it fails, prints the exact values involved in the comparison.

pythonpython
def add(a, b):
    return a + b
 
def test_add_positive():
    assert add(2, 3) == 5
 
def test_add_negative():
    assert add(-1, -1) == -2

Run these with the pytest command and pytest finds the file, runs both functions, and reports the results. When a test passes, pytest prints a green dot. When it fails, it shows the assertion that failed along with every subexpression value.

Compare this to a deliberately broken test:

pythonpython
def test_add_broken():
    assert add(2, 2) == 5

pytest output for this failure shows the computed value on each side of the comparison and the intermediate expression that produced it. This level of detail requires no extra work from you.

Fixtures for setup and teardown

In unittest, setUp and tearDown manage test data. pytest replaces both with a single concept: fixtures. A fixture is a function decorated with @pytest.fixture that returns a resource your test needs. Tests request fixtures by including the fixture name as a parameter.

pythonpython
import pytest
 
@pytest.fixture
def sample_list():
    return [1, 2, 3, 4, 5]
 
def test_list_length(sample_list):
    assert len(sample_list) == 5
 
def test_list_sum(sample_list):
    assert sum(sample_list) == 15

Each test receives a fresh copy of the list. If one test modifies it, the next test still starts with the original five numbers. pytest calls the fixture function once per test by default, maintaining isolation automatically.

For fixtures that need cleanup, use yield instead of return. Everything before yield is setup; everything after is teardown.

pythonpython
@pytest.fixture
def temp_file():
    path = "/tmp/test_data.txt"
    with open(path, "w") as f:
        f.write("temporary content")
    yield path
    import os
    os.remove(path)

The fixture creates a file, yields the path to the test, and removes the file afterward regardless of whether the test passed or failed.

Fixture scopes

By default, a fixture runs once per test function. Change this with the scope parameter to share expensive resources across multiple tests.

pythonpython
@pytest.fixture(scope="module")
def database_connection():
    conn = create_test_database()
    yield conn
    conn.close()

A module-scoped fixture runs once for all tests in the file. Supported scopes are function (the default), class, module, package, and session. Choose the narrowest scope that meets your performance needs.

Parametrized tests

When you have the same test logic with different inputs, the @pytest.mark.parametrize decorator runs the same function once for each set of arguments.

pythonpython
@pytest.mark.parametrize("a,b,expected", [
    (1, 2, 3),
    (0, 0, 0),
    (-1, 1, 0),
])
def test_add_parametrized(a, b, expected):
    assert a + b == expected

pytest runs this test three times and reports each run as a separate result with the argument values in the test name. When one case fails, pytest reports that specific case while continuing to run the remaining ones.

Parametrize also works with fixtures. Combine a parametrized input with a fixture that provides shared setup for each variation.

Markers for tagging and filtering

pytest markers attach metadata to tests so you can selectively run or skip groups of them. The built-in markers include skip, skipif, and xfail.

pythonpython
@pytest.mark.skip(reason="Not implemented yet")
def test_future_feature():
    assert connect_to_service() is not None
 
@pytest.mark.skipif(sys.version_info < (3, 11), reason="Requires Python 3.11+")
def test_new_syntax():
    assert True
 
@pytest.mark.xfail(reason="Known bug in the date parser")
def test_iso_date_parsing():
    assert parse_date("2026-07-13") == "July 13, 2026"

Custom markers let you group tests by category. Define them in your pyproject.toml file, then apply them to test functions and use the -m flag to run specific groups:

bashbash
pytest -m slow        # Run only slow tests
pytest -m "not slow"  # Skip slow tests

This gives you fine-grained control over which tests run in different contexts, such as running fast smoke tests on every commit and the full suite before a release.

Running unittest tests with pytest

pytest can discover and run unittest TestCase classes without modification. Install pytest and run it against a project that uses unittest, and it finds your test classes, executes them, and reports results using pytest's output format.

This compatibility means you can keep existing unittest tests and write new ones in pytest style. The article on organizing and running Python tests covers project layout and test execution strategies for both frameworks.

Rune AI

Rune AI

Key Insights

  • pytest lets you write tests as plain functions with the built-in assert statement and provides detailed failure output automatically.
  • Fixtures replace setUp and tearDown with a more flexible system of dependency injection, supporting function, class, module, and session scopes.
  • @pytest.mark.parametrize runs the same test logic with multiple input sets and reports each case independently.
  • pytest runs unittest TestCase classes without modification, so you can adopt it incrementally in existing projects.
  • Install pytest with pip install pytest and run it with the pytest command from your project root.
RunePowered by Rune AI

Frequently Asked Questions

Do I have to rewrite my unittest tests to use pytest?

No. pytest can discover and run unittest TestCase classes without any changes. You can install pytest, run it against a project that uses unittest, and it will execute your existing tests and report the results. This lets you adopt pytest gradually, writing new tests in pytest style while keeping your existing unittest tests intact.

What is the difference between a fixture and setUp in unittest?

Fixtures in pytest are more flexible than setUp. A fixture is a function decorated with @pytest.fixture that returns data or objects your tests need. Tests request fixtures by including the fixture name as a parameter, so each test only receives the fixtures it actually uses. In unittest, setUp runs before every test in the class whether the test needs the setup or not. Fixtures also support scoping to function, class, module, or session level, and automatic teardown via yield.

How do I pass multiple inputs to the same test in pytest?

Use the @pytest.mark.parametrize decorator. You provide a list of argument tuples and the test function runs once for each tuple. For example, @pytest.mark.parametrize('a,b,expected', [(1,2,3), (0,0,0), (-1,1,0)]) runs the test three times with different inputs. Each run is reported independently, so you can see exactly which inputs pass and which fail.

Conclusion

pytest is not just a unittest replacement; it is a design that makes tests shorter, clearer, and easier to maintain. Plain assert statements, dependency injection via fixtures, parametrized test runs, and marker-based organization all reduce the friction of writing and maintaining tests. If you are starting a new project, start with pytest.