pyguides

Getting Started with pytest: The Complete Beginner's Guide

Writing tests is one of the most important skills for any Python developer. Tests verify that your code works correctly, catch bugs before they reach production, and give you confidence when refactoring. Getting started with pytest is straightforward: it replaces Python’s verbose unittest module with a simpler API built around plain assert statements and automatic test discovery. This guide walks you through writing your first tests and covers the features you will use every day.

Installing pytest

Install pytest with pip:

pip install pytest

Verifying the installation is quick and gives you confidence that pytest is correctly set up in your environment. The version output also confirms which Python interpreter pytest is bound to, which matters when you are working across multiple virtual environments or Python installations on the same machine.

pytest --version
# output: pytest 8.x.x

pytest is a pure Python package with no external dependencies beyond the standard library. The installation adds a pytest command to your PATH that serves as both the test runner and the plugin host. Running pytest --version confirms the installation succeeded and shows which Python interpreter pytest is bound to. If you work across multiple virtual environments, install pytest in each one separately. This keeps test dependencies isolated and prevents version conflicts between projects.

Your First Test

pytest discovers tests automatically by looking for files named test_*.py or *_test.py. Each test is a function starting with test_.

Create a file called test_example.py:

def add(a, b):
    return a + b

def test_add_two_numbers():
    result = add(2, 3)
    assert result == 5

def test_add_negative_numbers():
    result = add(-1, -1)
    assert result == -2

This example defines the function under test and the test cases in the same file, which is fine for learning and simple scripts. In real projects, you would keep your production code in a separate module and import it into the test file. The test functions follow a clear pattern: call the function with known inputs, then assert something about the return value. pytest collects these functions automatically and runs each one in isolation, so a failure in one test does not prevent the others from executing.

Run the tests:

pytest test_example.py
# output: = test session starts =
# output: collected 2 items
# output: test_example.py::test_add_two_numbers PASSED
# output: test_example.py::test_add_negative_numbers PASSED

The assert statement checks that a condition is true. If the assertion fails, pytest shows you exactly what went wrong.

When you run pytest, it scans the current directory and all subdirectories for test files. The discovery phase reports how many tests were collected before any of them execute, giving you a quick sanity check that your tests are being found. The default output shows one line per test with a pass or fail indicator, plus a summary at the end with counts and timing information. For larger test suites, the verbose flag (-v) adds the full test name to each line, making it easier to identify which specific test passed or failed.

How pytest Discovers Tests

pytest follows specific naming conventions:

  • Files: test_*.py or *_test.py
  • Functions: test_*
  • Classes: Test*

This convention lets pytest find your tests without any configuration. You can customize this with pytest.ini or pyproject.toml if needed.

pytest’s discovery mechanism means you don’t need to maintain a test registry or manually register each test case. You also don’t need to subclass unittest.TestCase or wrap assertions in self.assertEqual() calls. This lower ceremony makes it practical to write tests for small functions that would feel like overkill with the standard library’s unittest module.

Assertions

Assertions are the heart of testing. pytest extends Python’s built-in assert to provide detailed failure messages:

def test_list_operations():
    fruits = ["apple", "banana", "cherry"]
    
    # Basic assertion
    assert len(fruits) == 3
    
    # Membership test
    assert "banana" in fruits
    
    # Equality
    assert fruits[0] == "apple"
    
    # Boolean
    assert not fruits == []

pytest uses Python’s native assert keyword, which means any boolean expression works as a test condition. You can check equality, membership, ordering, type identity, and exception raising all with the same assert syntax. When an assertion fails, pytest introspects the expression to show the intermediate values, turning a binary pass/fail into a detailed diagnostic that helps you understand what went wrong without adding print statements to your test code.

Assertion Helpers

pytest provides helpful assertion introspection. When you write:

assert actual == expected

The assert statement above is the simplest possible test: check that two values are equal. When it passes, pytest prints a green dot or PASSED line. When it fails, the introspection engine kicks in and breaks down exactly what happened, showing both the expression that failed and the computed values of every subexpression.

And it fails, pytest shows both values:

E       AssertionError: assert 5 == 6
E        +  where 5 = add(2, 3)
E        +  where 6 = 3 + 3

This makes debugging much easier than a generic assertion error.

The assertion introspection is one of pytest’s most practical features. In standard Python, a failed assertion shows only the line that raised the error — you have to add print statements or use a debugger to inspect the values. pytest rewrites assertions at import time to capture each subexpression, so the failure message includes the concrete values of both operands and any intermediate function calls. This turns the test output into a self-contained debugging session.

Test Driven Development

Test-driven development (TDD) flips the traditional workflow: write the test first, then write the code that makes it pass.

TDD follows a simple cycle:

  1. Write a failing test
  2. Write the minimum code to make it pass
  3. Refactor if needed

Here’s an example:

# test_calculator.py

def test_divide_by_zero_returns_infinity():
    result = divide(10, 0)
    assert result == float('inf')

The test above describes the desired behavior before the function exists. This is the red phase of the TDD cycle: the test fails because divide has not been defined yet. Writing the test first forces you to think about the interface before the implementation, which often leads to simpler function signatures and clearer contracts between callers and callees. The failing test also gives you a concrete signal that tells you exactly when you are done implementing.

Running this test fails because divide doesn’t exist yet:

E   NameError: name 'divide' is not defined

The NameError tells you exactly what is missing, and the test name tells you what behavior is expected. With these two pieces of information, implementing the function becomes a focused mechanical task: define divide, check for zero, return the appropriate value. Each test case serves as an executable specification that stays with the codebase after implementation, preventing regressions when the function is modified later.

Now implement the function:

# calculator.py

def divide(a, b):
    if b == 0:
        return float('inf')
    return a / b

Run the test again—it passes. This workflow ensures your code is always tested.

TDD is a discipline, not a library feature. pytest supports it well because the test runner is fast enough to rerun after every small code change, and the assertion introspection makes failure output actionable. The workflow works best for functions with clear inputs and outputs: mathematical operations, data transformations, and validation logic. For code with complex side effects like database writes or network calls, you may want to combine TDD with mocking or integration tests to keep the feedback loop tight.

Fixtures

Fixtures provide reusable test data and setup code. They run before each test that uses them:

import pytest

@pytest.fixture
def user():
    return {"name": "Alice", "email": "alice@example.com"}

def test_user_name(user):
    assert user["name"] == "Alice"

def test_user_email(user):
    assert user["email"] == "alice@example.com"

The user fixture runs once and the same instance is shared between tests. This keeps tests fast and organized.

Fixtures solve the problem of repetitive setup code. Instead of creating test data inside each test function, you define it once in a fixture and inject it by adding the fixture name as a parameter. pytest resolves dependencies automatically: when it sees test_user_name(user), it finds the user fixture, calls it, and passes the return value to the test. The fixture runs once per test by default, so if one test modifies the fixture’s return value, that modification does not leak into the next test.

Fixtures with Setup

Use fixtures to handle expensive setup:

@pytest.fixture
def database():
    db = Database.connect("test.db")
    db.seed()  # Insert test data
    yield db   # Provide to tests
    db.cleanup()  # Cleanup after tests

The yield keyword splits the fixture into setup and teardown. Code before yield runs before the test; code after yield runs after.

The yield-based pattern replaces unittest’s setUp and tearDown methods with a single function. This keeps setup and teardown logic together in one place, making it easier to verify that resources are properly released. If the test itself raises an exception, the teardown code after yield still runs, ensuring that database connections are closed and temporary files are cleaned up even when a test fails.

Fixture Scope

Control how often fixtures run with scope:

@pytest.fixture(scope="session")
def database():
    """Created once per test session."""
    return Database.connect("test.db")

@pytest.fixture(scope="function") 
def user():
    """Created for each test function."""
    return User()

Common scopes:

  • function (default): runs for each test
  • class: runs once per test class
  • session: runs once per test session

Choosing the right scope is a tradeoff between speed and isolation. A session-scoped database connection is fast because it is created once for the entire test run, but it risks state leaking between test modules if one test modifies data that another test depends on. Function scope gives you maximum isolation at the cost of repeated setup. Most projects settle on function scope for unit tests and session scope for expensive resources like database connections that are read-only during the test run.

Parametrized Tests

Run the same test with different inputs using @pytest.mark.parametrize:

@pytest.mark.parametrize("a,b,expected", [
    (2, 3, 5),
    (-1, 1, 0),
    (0, 0, 0),
    (100, 200, 300),
])
def test_add(a, b, expected):
    assert add(a, b) == expected

This creates four separate tests from one function. Parametrization is powerful for testing edge cases without duplicating code.

Parametrized tests scale particularly well with boundary-value analysis. When you are testing a function that accepts numeric inputs, you can add cases for zero, negative numbers, the maximum and minimum allowed values, and values just outside the valid range, all without writing a separate test function for each case. pytest reports each parametrized combination as its own test in the output, so failures point directly to the specific input that caused the problem.

Test Organization

Group related tests in classes:

class TestMathOperations:
    def test_add(self):
        assert add(1, 2) == 3
    
    def test_subtract(self):
        assert subtract(5, 3) == 2
    
    def test_multiply(self):
        assert multiply(4, 5) == 20

class TestStringOperations:
    def test_uppercase(self):
        assert "hello".upper() == "HELLO"

Classes help organize tests and can share fixtures with class scope.

Test classes in pytest are plain Python classes that do not need to inherit from any base class. They serve as namespaces for grouping related tests, which is useful when you have dozens of tests for a single module and want to keep the test file navigable. Using classes also unlocks class-scoped fixtures, which are created once for all test methods in the class and destroyed when the last method finishes.

Running Tests

Common pytest commands:

# Run all tests
pytest

# Run specific file
pytest test_math.py

# Run specific test function
pytest test_math.py::TestMathOperations::test_add

# Run tests matching a pattern
pytest -k "test_add"

# Show extra test summary info
pytest -v

# Stop on first failure
pytest -x

# Run tests in a specific directory
pytest tests/unit/

The -k flag accepts a substring or expression that pytest matches against test names, so pytest -k "add" runs every test whose name contains “add”. This is faster than specifying full file paths when you want to run a subset of related tests across multiple files. The --lf (last failed) flag is especially useful during debugging: after fixing a broken test, you can rerun only the tests that failed in the previous run without waiting for the full suite to execute.

Common pytest Options

OptionDescription
-vVerbose output
-xStop after first failure
-kRun tests matching expression
--tbTraceback format (short/long/line)
--lfRun only tests that failed last time
--covRun with coverage (requires pytest-cov)

The command-line options above cover the most common workflows, but pytest’s plugin system adds many more. Plugins like pytest-cov for coverage, pytest-xdist for parallel execution, and pytest-mock for mocking integrate through the same option interface and marker system that the built-in features use. Most plugins are a single pip install away and require no additional configuration beyond what pytest already understands.

Marking Tests

Use markers to categorize tests:

@pytest.mark.slow
def test_large_dataset():
    # Takes several seconds
    pass

@pytest.mark.integration
def test_database_connection():
    # Requires database
    pass

Once you have tagged your tests with markers, you can selectively include or exclude them at runtime using the -m flag. This is especially useful in CI pipelines where you might want to run fast unit tests on every commit but reserve slow integration tests for pull request validation or nightly builds.

Run specific markers:

pytest -m "not slow"
pytest -m "integration"

Built-in markers include @pytest.mark.skip, @pytest.mark.xfail, and @pytest.mark.parametrize.

Markers let you build a test taxonomy that reflects your project’s structure. A typical setup includes slow for tests that take more than a second, integration for tests that require external services, and custom markers for feature flags or environment-specific behavior. To avoid typos and accidental marker usage, register custom markers in your pytest.ini or pyproject.toml file. pytest warns about unregistered markers by default, helping you catch mistakes before they silently skip tests.

Best Practices

One Assertion Per Test (Mostly)

It’s not a hard rule, but testing one thing per test makes debugging easier:

# Good - focused test
def test_user_name_is_required():
    with pytest.raises(ValueError):
        User(name=None)

A focused test like the one above checks exactly one behavior: that creating a User without a name raises a ValueError. If this test fails, you immediately know what broke. A test that asserts five different things about a User object is harder to debug because any of those five assertions could be the culprit. The guideline is a recommendation, not a law — sometimes testing a small cluster of related invariants in one test is clearer than splitting them across multiple functions.

Use Descriptive Names

Test names should describe what they verify:

# Good
def test_division_by_zero_returns_infinity():

# Bad
def test_div():

The test name is the first thing you see when a failure appears in CI or a terminal. A name like test_division_by_zero_returns_infinity tells you the function under test, the input condition, and the expected outcome without opening the file. A name like test_div tells you nothing. Long test names are a feature, not a problem — they serve as living documentation that stays accurate because the test fails when the behavior changes.

Keep Tests Independent

Each test should work on its own:

# Good - creates fresh data
@pytest.fixture
def user():
    return User(name="Alice")

def test_user_name(user):
    assert user.name == "Alice"

Independent tests are easier to debug, reorder, and run in parallel. When each test creates its own data through fixtures, there is no hidden dependency on test execution order. This also enables pytest’s parallel execution plugins like pytest-xdist, which can distribute tests across multiple CPU cores but require each test to be fully self-contained.

Test Edge Cases

Don’t just test the happy path:

def test_add_handles_empty_list():
    assert add([]) == 0

def test_add_handles_single_item():
    assert add([5]) == 5

Edge cases reveal bugs that happy-path tests miss. Empty collections, single elements, negative numbers, and values at the boundaries of valid ranges are the inputs most likely to expose off-by-one errors, unhandled None values, or incorrect assumptions about data types. Adding an edge case test costs seconds to write and can save hours of debugging when that edge case inevitably surfaces in production data.

Conclusion

pytest makes testing accessible and even enjoyable. Start with simple assertions, use fixtures for reusable setup, and use parametrization for thorough test coverage. The pytest ecosystem includes plugins for coverage reporting, mocking, and async testing, extending its power as your needs grow.

Remember: tests are code too. Keep them clean, organized, and focused.

See Also