Getting Started with pytest

· 5 min read · Updated March 13, 2026 · beginner
pytest testing unit-testing test-driven-development tdd

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. pytest is the most popular testing framework in Python—it’s simple, powerful, and has a great ecosystem.

This guide walks you through writing your first tests with pytest.

Installing pytest

Install pytest with pip:

pip install pytest

Verify the installation:

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

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

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.

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.

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 == []

Assertion Helpers

pytest provides helpful assertion introspection. When you write:

assert actual == expected

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.

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')

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

E   NameError: name 'divide' is not defined

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.

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 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.

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

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.

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.

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/

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)

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

Run specific markers:

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

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

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)

Use Descriptive Names

Test names should describe what they verify:

# Good
def test_division_by_zero_returns_infinity():

# Bad
def test_div():

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"

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

Conclusion

pytest makes testing accessible and even enjoyable. Start with simple assertions, use fixtures for reusable setup, and leverage 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