Python: Testing and Debugging

Writing code that works once is not enough. Writing code that continues to work as requirements change, collaborators contribute, and dependencies update is the real challenge. Testing gives you confidence that behaviour is correct. Debugging gives you the tools to understand why it is not. This tutorial covers both, from the standard library to the tools the professional Python community uses every day.

Why Testing Matters

Every program is tested. The question is whether it is tested by you, deliberately, before it reaches users, or by your users, accidentally, in production. Automated tests are not just a safety net; they are a communication tool. A well-written test suite describes what the code is supposed to do in concrete, executable terms. When a new developer joins a project, reading the tests tells them more about the intended behaviour than reading the source code alone.

Tests also change how you design code. Functions that are hard to test are usually hard to use: they have too many responsibilities, hidden dependencies, or global side effects. Writing tests first, or at least thinking about testability while you write, pushes you toward smaller, focused, decoupled functions. That design benefit alone justifies the investment in a test suite, even before accounting for the bugs tests catch.

The Testing Pyramid

A useful mental model for how to distribute your testing effort across different levels of granularity.

  • Unit tests (base, most): test individual functions or classes in isolation, with external dependencies replaced by mocks. Fast, numerous, and precise.
  • Integration tests (middle): test how multiple components work together, such as a service class calling a real database or an API client making real network calls against a test server.
  • End-to-end tests (apex, fewest): drive the complete system from the outside, as a real user would. Slowest and most brittle, so keep the count low and focused on critical paths.

The unittest Module

Python's standard library includes unittest, a testing framework inspired by JUnit. You organise tests into classes that inherit from unittest.TestCase. Each method whose name starts with test_ is treated as an individual test. Theunittest runner finds and executes these methods, reporting which pass, which fail, and which raise unexpected errors.

Running tests is straightforward: from the command line, usepython -m unittest discover to find all test files matching thetest*.py pattern recursively, or pass a specific module or file. You can also run a file directly with if __name__ == '__main__': unittest.main() at the bottom.

A Basic unittest Test Case

Python

Writing and running a test class for a simple calculator function.

Test Life Cycle: setUp, tearDown, setUpClass, tearDownClass

Tests should be independent: the outcome of one test must not affect another. The life-cycle methods let you set up a clean environment before each test and tear it down afterward, without duplicating that logic inside each test method.

setUp(self) runs before every test method. tearDown(self) runs after every test method, even if the test failed. setUpClass(cls) andtearDownClass(cls) are classmethods decorated with @classmethod that run once before and after the entire test class. Use them for expensive one-time setup, like starting a database connection, that would be prohibitively slow if repeated per-test.

setUp, tearDown, setUpClass, tearDownClass

Python

Combining per-test and per-class setup for a service that uses a shared resource.

Assertions in unittest

unittest.TestCase provides a rich set of assertion methods. Using the specific assertion rather than a bare assertTrue(a == b) matters because the specific method produces a clear failure message. When assertEqual(a, b) fails, it tells you exactly what both values were. When assertTrue(a == b) fails, it tells you only that the expression was False, leaving you to figure out why.

Common unittest Assertions

Python

The assertions you will reach for most often, with brief examples of each.

pytest Framework

While unittest comes with the standard library, the Python community has largely converged on pytest for its simpler syntax, better error output, powerful fixtures, and extensive plugin ecosystem. Install it with pip install pytest. Run your tests with pytest from the project root; it discovers test files automatically.

The biggest practical difference: in pytest you write plain assert statements rather than self.assertEqual() and its cousins. When a pytest assertion fails, pytest's "assertion introspection" rewrites the bytecode to produce rich failure messages showing the actual values on both sides. This eliminates most of the reason to use specialised assertion methods.

Installing and Running pytest

A quick reference for the most common pytest command-line options.

  • pip install pytest: install pytest into your environment.
  • pytest: discover and run all tests under the current directory.
  • pytest test_calculator.py: run only a specific file.
  • pytest -v: verbose output, showing each test name and its result.
  • pytest -k "divide": run only tests whose names contain "divide".
  • pytest -x: stop after the first failure.
  • pytest --tb=short: shorter traceback format for quicker scanning.

Writing Test Functions in pytest

You do not need a class in pytest. Any function whose name starts with test_ in a file that matches the discovery pattern is a test. Classes are still supported if you want to group related tests, but they do not need to inherit from anything. This simplicity means less boilerplate and faster reading.

pytest: Plain Functions and assert Statements

Python

The same calculator tests rewritten for pytest, without any class or special assertion methods.

Fixtures in pytest

A fixture is a function decorated with @pytest.fixture that produces a resource or piece of setup logic that tests can request by name. When a test function declares a parameter with the same name as a fixture, pytest injects the fixture's return value automatically. This is dependency injection for tests, and it scales far more cleanly than setUp/tearDown as test suites grow.

Fixtures support teardown via yield: code before the yield is setup, and code after is teardown. The scope parameter controls how often the fixture runs:"function" (default, once per test), "class", "module", or"session" (once for the entire test run). Fixtures placed in aconftest.py file are available to all tests in the same directory and its subdirectories without any import.

Fixtures: Setup, Teardown, and Scope

Python

A database fixture with teardown via yield, shared at module scope.

Parameterised Tests

Copying and pasting a test to cover slightly different inputs is a maintenance problem. When the function changes, you have to update every copy. Parameterised tests solve this by declaring the inputs and expected outputs as data, running the same test logic once for each row.

In pytest, the @pytest.mark.parametrize decorator takes a string of comma-separated parameter names and a list of value tuples. Each tuple becomes one test case with its own entry in the report. pytest names them automatically using the values, or you can set explicit IDs with the ids argument.

@pytest.mark.parametrize: Multiple Inputs, One Test

Python

Testing a password validator against multiple valid and invalid inputs in a single test definition.

Parameterised Tests in unittest with subTest()

Python

The standard library equivalent using the subTest context manager.

Mocking with unittest.mock

A mock replaces a real object during a test with a controlled substitute. The real object might make network calls, write to a database, or read from the filesystem. In a unit test, you want to control exactly what those dependencies return and verify that your code calls them correctly, without the test depending on external systems being available.

The unittest.mock module provides Mock, MagicMock, and thepatch decorator and context manager. Mock accepts any attribute access or call without raising errors, recording what was done to it. MagicMock is a subclass that pre-configures magic methods like __len__, __iter__, and__enter__, making it suitable for objects used in loops or with statements.

Mock, MagicMock, and patch

Mock and MagicMock: Basic Usage

Python

Creating mocks, configuring return values, and asserting call behaviour.

patch: Replacing Objects During a Test

Python

Mocking an external API call so the test does not hit the network.

patch as a Decorator and side_effect

Python

Using patch as a decorator and simulating exceptions with side_effect.

Test Coverage with coverage.py

Coverage measures which lines of your source code are actually executed when your test suite runs. A line that is never executed cannot be tested, and any bug hiding in it is invisible to your tests. Install coverage with pip install coverage, then run your tests through it.

Coverage percentage is a useful signal but not a goal in itself. 100% line coverage does not mean 100% of your logic is tested; it only means every line was reached. Branch coverage is more thorough: it checks that every if condition was exercised in both its true and false paths. A function with an uncovered branch has a latent bug lurking in the path your tests never took.

Running coverage.py with pytest

Python

The commands to measure, report, and generate an HTML coverage report.

.coveragerc: Configuring What to Measure

Python

Excluding test files, virtual environments, and generated code from coverage reports.

Test-Driven Development

Test-Driven Development (TDD) inverts the usual order: you write the test before you write the code it tests. The cycle has three phases, often called Red-Green-Refactor. First, write a test for the next piece of behaviour you need; run it and watch it fail (Red). Second, write the simplest possible code that makes the test pass (Green). Third, improve the code without changing its observable behaviour, with the passing tests confirming you have not broken anything (Refactor).

TDD is not about writing tests as documentation after the fact, nor about achieving coverage metrics. It is a design practice. Writing the test first forces you to think about the interface your code will expose before you think about how to implement it. The result tends to be smaller, more focused functions with cleaner interfaces.

TDD in Action: A Stack Implementation

Python

Writing each test in the Red-Green-Refactor cycle before the corresponding implementation.

Debugging with pdb

The Python Debugger (pdb) is a command-line interactive debugger built into the standard library. When execution reaches a breakpoint, pdb pauses the program and drops you into a prompt where you can inspect variables, evaluate expressions, step through code line by line, and navigate up and down the call stack.

Essential pdb Commands

These commands cover the vast majority of debugging sessions. Type the letter and press Enter.

  • n (next): execute the current line and move to the next one, staying in the current function.
  • s (step): step into a function call, entering the called function.
  • c (continue): continue execution until the next breakpoint or the program ends.
  • q (quit): exit the debugger and terminate the program.
  • p expression: print the value of an expression.
  • pp expression: pretty-print the value (useful for nested structures).
  • l (list): show the surrounding source code.
  • u / d (up / down): move up or down the call stack.
  • b line_number: set a new breakpoint at a given line.
  • w (where): print the current call stack trace.

Using pdb.set_trace() and post-mortem debugging

Python

Inserting a hard-coded breakpoint and debugging after an unhandled exception.

breakpoint() in Python 3.7+

Python 3.7 introduced the built-in breakpoint() function as a cleaner alternative to import pdb; pdb.set_trace(). It does the same thing by default, but its behaviour is controlled by the PYTHONBREAKPOINT environment variable. SettingPYTHONBREAKPOINT=0 disables all breakpoints without modifying source code, which is useful in CI environments. You can also point it at a different debugger, such asPYTHONBREAKPOINT=ipdb.set_trace to use IPython's debugger instead.

breakpoint(): Simple and Configurable

Python

Inserting breakpoints with the modern built-in and controlling them via environment variables.

The Logging Module

Print statements are adequate for quick scripts, but they are the wrong tool for production code. The logging module gives you severity levels, configurable output destinations, formatted timestamps, and the ability to turn output on and off per module without changing any source code. Log statements you would have deleted after debugging can stay in the codebase at DEBUG level; they produce no output in production unless you enable them.

The five standard levels in ascending severity are DEBUG, INFO,WARNING, ERROR, and CRITICAL. The root logger's level acts as a global gate: messages below that level are discarded before reaching any handler. Each module should create its own logger with logging.getLogger(__name__) rather than using the root logger directly; this lets callers configure library logging independently of application logging.

Logging Setup and Basic Usage

Python

Configuring a logger with a custom format, file handler, and stream handler.

Structured Logging and Logger Hierarchy

Python

Adding contextual information and controlling verbosity per module.

Profiling with cProfile and timeit

Before optimising code, measure it. Guessing at bottlenecks is almost always wrong. The standard library provides two complementary tools for this. timeit measures the execution time of a small code snippet with high precision, averaged over many repetitions. It is the right tool for comparing two implementations of the same function. cProfile profiles an entire program run, reporting how many times each function was called and how much total and per-call time was spent in each one. It points you at the functions worth optimising in the first place.

timeit: Comparing Two Implementations

Python

Measuring whether a list comprehension or a for loop is faster for the same task.

cProfile: Finding the Real Bottleneck

Python

Profiling a program to identify which functions consume the most time.

Profiling Workflow

A repeatable process for finding and validating performance improvements.

  • 1. Write a test or benchmark that represents real workload. Never profile toy examples.
  • 2. Run cProfile to find the function consuming the most cumulative time. That is your target.
  • 3. Use timeit to compare your proposed fix against the original in isolation.
  • 4. Apply the fix and re-run cProfile to confirm the bottleneck has moved.
  • 5. Check that all tests still pass. An optimisation that breaks correctness is not an optimisation.

Quiz - Test Your Knowledge

Ten questions covering the purpose of setUp and tearDown, assertion semantics, pytest discovery, fixtures, parameterised tests, the distinction between Mock and MagicMock, the patch decorator, branch coverage, the TDD red-green-refactor cycle, and the debugging tools pdb and breakpoint. Read each option carefully before answering.

Knowledge Check

1. What is the purpose of setUp() in a unittest.TestCase class?

2. What is the difference between assertEqual() and assertIs() in unittest?

3. How does pytest discover test functions automatically?

4. What is a pytest fixture, and what makes it different from unittest setUp()?

5. What does @pytest.mark.parametrize do?

6. What is the key difference between unittest.mock.Mock and unittest.mock.MagicMock?

7. What does @patch("module.ClassName") do when used as a test decorator?

8. What does a coverage report's "branch coverage" measure beyond line coverage?

9. In Test-Driven Development, what is the correct order of the Red-Green-Refactor cycle?

10. What is the purpose of pdb.set_trace() or breakpoint() in a Python program?