Fun with Learning Technology
LearnCoursesQuestionsTracksToolsNewsExplorePractice
Fun with Learning Technology

A new problem, explained clearly, every day.

Subscribe
Learn
  • Lessons
  • Topics
  • News
  • Tools
  • Courses
  • Career tracks
  • Everything
Site
  • About
  • Contact
  • Support
  • Privacy
  • Terms
Get the daily one

One email per new problem. No spam.

Request a tutorial

Requests shape what gets made next.

© 2026 Fun with Learning TechnologyRSS
Home›Courses›pytest & Testing›Introduction to Testing and pytest

Getting Started

Introduction to Testing and pytest

Testing is the systematic process of validating that your software behaves as intended under diverse conditions. By employing pytest, developers can automate this verification, ensuring that new code changes do not inadvertently break existing functionality. You should reach for these tools whenever you aim to maintain a robust, reliable codebase that facilitates fearless refactoring and sustainable long-term development.

The Anatomy of a Test Function

At its core, a test in pytest is simply a function that starts with the prefix 'test_'. The framework uses discovery mechanisms to scan your file system for these functions and execute them automatically. When pytest runs, it monitors the 'assert' statements within these functions. Unlike traditional programming where an assertion might cause the program to crash, pytest intercepts these assertion failures, records them, and continues to the next test. The magic lies in how pytest introspects the state of your variables during an assertion failure. It doesn't just tell you that a test failed; it breaks down the expression to show you exactly which components contributed to the failure. By writing standard functions, you keep your testing logic decoupled from complex frameworks, allowing you to focus on verifying the logical correctness of your domain objects rather than fighting against an obscure testing API or rigid class-based structures.

# File: test_math.py

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

def test_add_two_numbers():
    # The assert keyword is the fundamental building block
    # pytest inspects this and provides a detailed error report on failure
    result = add(2, 3)
    assert result == 5, "Adding 2 and 3 should result in 5"

Understanding Test Discovery

Pytest is designed to be 'zero-configuration' by default, which relies heavily on a strict set of discovery rules. When you trigger the test runner from your terminal, it recursively searches through your project directory for files that start with 'test_' or end in '_test.py'. Inside these modules, it identifies functions with the 'test_' prefix and methods within classes prefixed with 'Test'. This convention-over-configuration approach is crucial because it ensures that tests remain organized in a predictable hierarchy that mirrors your source code structure. By adhering to these naming conventions, you allow the framework to map your test suite automatically without manual registry updates. This reduces the friction of adding new tests to the codebase, encouraging a workflow where small, isolated tests are written alongside new features, ensuring that the test suite scales linearly with the project's complexity while remaining easy to navigate for team members.

# Directory structure: 
# /project
#   /src
#     logic.py
#   /tests
#     test_logic.py

# Inside test_logic.py, discovery happens automatically.
# Running 'pytest' in the root triggers these files.

Handling Exceptions with pytest.raises

Robust software must handle failure states just as gracefully as success scenarios. Sometimes, the correct behavior for a function is to raise an error when given invalid input. Testing these scenarios requires a context manager, 'pytest.raises', which tells the framework that you expect a specific exception to occur within a designated block of code. This is a critical pattern because it allows you to verify not just the existence of an error, but the specific type of error, ensuring that your error handling remains predictable. If an error of the specified type is not raised, the test will fail, alerting you that the function is either too permissive or has been modified. By explicitly declaring what should fail, you create a safety net for edge cases, ensuring that internal failures don't lead to silent data corruption or undefined behavior in production environments.

import pytest

def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

def test_divide_by_zero_raises_error():
    # We expect a ValueError. If the function succeeds, the test fails.
    with pytest.raises(ValueError, match="Cannot divide by zero"):
        divide(10, 0)

Sharing Setup Logic with Fixtures

As your test suite grows, you will inevitably find that many tests require the same setup, such as creating a database connection, initializing a temporary file, or mocking a network call. Copying this setup code into every test function creates 'boilerplate' that is hard to maintain and prone to error. Pytest solves this with the 'fixture' decorator. A fixture is a function that performs setup, yields a value, and then handles cleanup. Because fixtures are injected into tests by name, you can declare your dependencies explicitly in the function signature. This inversion of control is powerful; it allows the test runner to manage the lifecycle of your resources. Furthermore, fixtures can depend on other fixtures, building a sophisticated dependency graph that ensures your test environment is initialized exactly as needed, every single time, without duplicating resource allocation logic across your codebase.

import pytest

@pytest.fixture
def sample_data():
    # Setup: prepare resources
    data = {"user": "admin", "permissions": ["read", "write"]}
    return data

def test_user_permissions(sample_data):
    # The fixture is passed as an argument automatically
    assert "admin" in sample_data["user"]
    assert "write" in sample_data["permissions"]

Parameterizing Tests for Coverage

One of the most common mistakes in testing is writing a single, complex test function that tries to cover too many inputs using loops. When a loop fails, it is often difficult to determine exactly which input caused the issue without adding complex diagnostic logging. Pytest addresses this with 'pytest.mark.parametrize', which allows you to run the same test logic against multiple sets of data. Each set of inputs is executed as an independent, isolated test case. If one set fails, the others will still run, and the report will clearly indicate exactly which parameters caused the failure. This technique drastically increases your test coverage while keeping your code clean and readable. By separating your test data from the test logic, you create a maintainable suite where adding a new edge case is as simple as adding a single line of data to a list, rather than writing a new test function.

import pytest

@pytest.mark.parametrize("input_a, input_b, expected", [
    (1, 2, 3),
    (5, 5, 10),
    (0, 0, 0)
])
def test_add_multiple_cases(input_a, input_b, expected):
    # This function runs 3 times, once for each tuple provided above.
    assert input_a + input_b == expected

Key points

  • Tests must be defined as functions starting with the test_ prefix to be recognized by the framework.
  • The assert keyword serves as the primary mechanism for verifying expected outcomes in test functions.
  • Pytest automatically discovers and executes tests located in files beginning with the test_ naming convention.
  • Fixture functions manage the lifecycle of resources needed for tests, promoting code reuse and modularity.
  • Dependency injection via fixture names allows for clear, explicit declarations of test requirements.
  • The pytest.raises context manager is essential for verifying that expected error states are triggered correctly.
  • Parameterization enables running identical logic against multiple data sets to ensure comprehensive input coverage.
  • Isolated test execution ensures that individual test failures do not interfere with the reporting of other results.

Common mistakes

  • Mistake: Naming test files without the 'test_' prefix. Why it's wrong: pytest discovery mechanism only picks up files starting with 'test_' or ending in '_test.py'. Fix: Rename files to follow the 'test_*.py' convention.
  • Mistake: Using 'assert' with complex logic inside a single statement. Why it's wrong: When the assertion fails, pytest's introspection is less helpful for debugging complex expressions. Fix: Break down complex logic into variables before asserting, or use multiple assertions.
  • Mistake: Assuming tests run in a specific, hardcoded order. Why it's wrong: Unit tests should be isolated and independent; relying on order makes them brittle. Fix: Design tests to be stateless and independent of execution order.
  • Mistake: Performing setup logic directly in the test function instead of using fixtures. Why it's wrong: This leads to code duplication and makes teardown/cleanup logic difficult to manage. Fix: Move initialization and cleanup into pytest fixtures.
  • Mistake: Placing too many assertions in a single test case. Why it's wrong: A test failure at the first assertion hides failures in subsequent assertions, obscuring the root cause. Fix: Aim for one logical concept per test function to improve granularity.

Interview questions

What is the primary purpose of automated testing in a pytest environment?

The primary purpose of automated testing is to ensure that code changes do not break existing functionality, a concept known as regression testing. By using pytest, developers can write small, repeatable tests that provide immediate feedback on the health of the application. This process significantly reduces the manual effort required to verify features, improves code quality, and gives developers the confidence to refactor or extend the codebase without fear of introducing hidden bugs.

How does pytest identify which functions are tests, and why is this convention important?

Pytest identifies test functions by looking for files starting with 'test_' or ending with '_test.py'. Within those files, it automatically collects any function prefixed with 'test_'. This convention is vital because it enables the framework to perform 'test discovery' without needing explicit configuration files. By standardizing this naming scheme, any new developer joining a project immediately knows where to put their tests and how the test runner will successfully find and execute them.

Can you explain the role of a pytest fixture and why we use them?

A pytest fixture is a function decorated with @pytest.fixture that provides a baseline or a setup state for your tests. We use them because they promote modularity and code reuse. Instead of repeating setup logic—like initializing a database connection or creating a dummy user—inside every test function, you define the logic once in a fixture and inject it into the tests that require that specific state, keeping the test code clean.

Compare using a standard setup/teardown function approach versus the pytest fixture dependency injection system.

In older frameworks, setup/teardown usually relies on class-level methods that run before and after every test, which can lead to bloated classes and rigid hierarchies. In contrast, pytest’s fixture system uses dependency injection, where you explicitly request the fixture by name as an argument. This is superior because it is modular; you only inject what you need, and fixtures can be chained together or easily shared across multiple modules, making dependencies highly transparent and maintainable.

What is the importance of parametrization in pytest, and how does it improve test coverage?

Parametrization in pytest allows you to run the same test function multiple times with different input values and expected outputs using the @pytest.mark.parametrize decorator. This is crucial for improving test coverage because it allows you to test edge cases, boundary conditions, and diverse data sets without writing multiple unique test functions. It keeps the test suite DRY (Don't Repeat Yourself) while ensuring that your logic handles a wide variety of scenarios correctly.

How would you design a testing strategy for a complex system using conftest.py, and why is that file special?

The conftest.py file is a specialized local configuration file that allows you to share fixtures and hooks across your entire test suite without needing to import them explicitly. To design a complex strategy, I would place high-level, global fixtures—like database configurations or shared API clients—within a conftest.py at the project root. For module-specific needs, I would create sub-directory level conftest.py files, which override or supplement the global ones, ensuring that the test environment is correctly scoped and highly efficient.

All pytest & Testing interview questions →

Check yourself

1. What is the primary reason for using a fixture instead of a standard setup function in pytest?

  • A.Fixtures are required to run tests in parallel.
  • B.Fixtures allow for modularity, dependency injection, and automatic teardown.
  • C.Fixtures are faster than standard Python functions.
  • D.Fixtures allow you to execute tests without using the assert keyword.
Show answer

B. Fixtures allow for modularity, dependency injection, and automatic teardown.
Fixtures enable dependency injection and systematic setup/teardown, which modularizes test environments. The other options are incorrect because fixtures are not strictly required for parallelism, they are not inherently faster, and they do not remove the need for assertions.

2. How does pytest determine which functions are tests?

  • A.By identifying functions decorated with @pytest.run.
  • B.By looking for functions that start with the 'test_' prefix.
  • C.By importing all functions that contain 'assert'.
  • D.By reading the naming convention defined in the conftest.py file.
Show answer

B. By looking for functions that start with the 'test_' prefix.
Pytest uses a default discovery pattern where it collects files starting with 'test_' and functions/methods within them starting with 'test_'. The other options refer to incorrect decorators, implementation details, or misinterpret the purpose of configuration files.

3. When a test function fails with an assertion error, what does pytest do?

  • A.It stops the entire test suite immediately.
  • B.It catches the error and marks the test as 'skipped'.
  • C.It uses internal introspection to provide a detailed report of the failure.
  • D.It prompts the user to manually enter a debug mode.
Show answer

C. It uses internal introspection to provide a detailed report of the failure.
Pytest performs assertion rewriting, allowing it to inspect the expression and show the values of the variables involved in the failure. It does not stop the suite by default, does not mark them as skipped, and does not require manual prompts.

4. What is the benefit of 'assert' rewriting in pytest?

  • A.It compiles the code into machine language for faster execution.
  • B.It allows developers to use plain 'assert' statements while getting rich failure diagnostics.
  • C.It enables the test suite to run on different operating systems.
  • D.It ensures that tests are only run if the environment is configured correctly.
Show answer

B. It allows developers to use plain 'assert' statements while getting rich failure diagnostics.
Assertion rewriting transforms the simple Python assert into a diagnostic powerhouse, reporting the state of the comparison at the moment of failure. It is not related to compilation, OS compatibility, or environment configuration.

5. Which of the following best describes the philosophy of an isolated test?

  • A.A test that executes without any dependencies on external systems or previous tests.
  • B.A test that only uses local variables defined within the test function.
  • C.A test that requires a dedicated database instance to verify functionality.
  • D.A test that runs in a separate process to prevent memory leaks.
Show answer

A. A test that executes without any dependencies on external systems or previous tests.
Isolation ensures that a test's outcome is independent of side effects from other tests or external state. Using local variables is just a coding style, requiring a database violates isolation, and separate processes are a performance optimization, not a definition of isolation.

Take the full pytest & Testing quiz →

Next →Writing Your First Test

pytest & Testing

25 lessons, free to read.

All lessons →

Track your progress

Sign in to mark lessons done, score quizzes and keep notes.

Open in the app