Testing Python Functions

Learn how to test Python functions using assert statements and simple test patterns, and understand why functions that are pure and self-contained are easier to test.

7 min read

Testing is not a separate phase that begins after you finish writing code. It is the first thing you do with every function you write, because calling the function with known inputs and checking the output is the fastest way to confirm that it does what you intended. A test is simply a piece of code that calls your function and verifies the result. You do not need a testing framework, a test runner, or a special directory structure to start testing. You need assert statements and the willingness to write them alongside your functions.

The article on writing reusable Python functions emphasized designing functions with clear inputs and outputs. Testable functions and reusable functions are the same functions. A function that depends only on its parameters and returns a result can be tested by calling it with different arguments and asserting on the return value. The same isolation that makes a function reusable, covered also in the article on pure functions and side effects in Python, makes it straightforward to verify with tests.

Testing with assert statements

The assert statement is Python's built-in testing tool. It takes a condition and raises an AssertionError if the condition is False. You can add a message after a comma to make failures easier to diagnose. A test for a simple function looks like a series of assert statements that call the function with different inputs and check the results:

pythonpython
def add(a, b):
    return a + b
 
# Tests
assert add(2, 3) == 5
assert add(-1, 1) == 0
assert add(0, 0) == 0
assert add(2, -5) == -3

Each assert line is a test case. If any assertion fails, Python raises an AssertionError and stops execution, so you know exactly which case failed and can investigate. These tests live in the same file as the function they test, typically at the bottom, guarded by the if name == "main": check so they only run when the file is executed directly, not when it is imported as a module.

For functions that should raise exceptions under certain conditions, you test by catching the expected exception. If the exception is raised, the test passes. If the function completes without raising, something is wrong:

pythonpython
def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b
 
# Test for expected exception
try:
    divide(10, 0)
    assert False, "Should have raised ValueError"
except ValueError:
    pass  # Expected, test passes

The try block attempts the call that should fail. If no exception is raised, the assert False line runs and fails the test with a descriptive message. If a ValueError is raised, the except block catches it and the test passes silently. This pattern is verbose but explicit, and it requires no external libraries.

Testing pure functions versus impure functions

Pure functions, those that depend only on their arguments and produce no side effects, are the easiest to test. You call the function with inputs and assert on the return value. No setup is needed because the function does not read external state. No cleanup is needed because the function does not modify anything:

pythonpython
def calculate_discount(price, percentage):
    return price * (1 - percentage / 100)
 
assert calculate_discount(100, 20) == 80
assert calculate_discount(50, 0) == 50
assert calculate_discount(0, 50) == 0

Three test cases cover the normal case, the zero-discount edge case, and the zero-price edge case. Each test is independent and runs in any order. This is the ideal testing experience, and it is one of the strongest practical arguments for preferring pure functions whenever possible.

Impure functions require more work to test. A function that writes to a file needs a temporary file that is cleaned up after the test. A function that modifies a mutable argument needs the argument to be set up and its state checked after the call. A function that reads from a global variable needs that variable to be set before the test and restored afterward. These extra steps make tests longer, harder to write, and more likely to contain bugs themselves. The pattern of isolating pure logic from impure interactions, covered in the article on pure functions and side effects, directly simplifies testing.

Testing edge cases

Edge cases are inputs that sit at the boundaries of what a function is designed to handle. For a function that processes lists, the empty list is an edge case. For a function that divides numbers, zero is an edge case. For a function that accepts strings, an empty string, a very long string, and a string with unusual characters are edge cases. Testing edge cases catches bugs that normal-case testing misses because the function's logic often has special branches for boundary conditions that are not exercised by typical inputs.

Develop the habit of thinking about edge cases as you write the function, not after. If a function accepts an integer, does it handle zero correctly? Negative numbers? Very large numbers? If a function accepts a list, does it handle an empty list? A single-element list? A list with duplicate elements? Each edge case should have a corresponding assert statement in the test code, ideally written immediately after the function so the edge cases are captured while the function's logic is fresh in your mind.

Organizing tests as projects grow

For small projects, putting assert statements at the bottom of each module file works well. As the number of functions and tests grows, moving tests to separate files keeps the source modules clean and makes it easier to run all tests at once. A common structure places test files in a tests directory that mirrors the source directory structure. A module named myproject/validation.py has a corresponding test file at tests/test_validation.py.

When test organization becomes a burden, which typically happens around the time you have more than a dozen test functions, adopting a testing framework like pytest simplifies the process. Pytest discovers and runs test files automatically, provides detailed failure reports with the values that caused each assertion to fail, and supports fixtures for managing test setup and teardown. The transition from plain assert statements to pytest is gradual because pytest runs plain assert statements natively. You can adopt pytest's test discovery and reporting without changing a single assert line.

Rune AI

Rune AI

Key Insights

  • Use assert statements to verify that a function returns the expected value for a given input.
  • Test normal cases, edge cases like zero or empty inputs, and error cases where the function should raise an exception.
  • Pure functions are the easiest to test because they depend only on their arguments and return a result.
  • Impure functions require setup and cleanup for their side effects; isolate the pure logic to simplify testing.
  • Run tests frequently as you develop; a passing test suite is your safety net for refactoring and adding features.
RunePowered by Rune AI

Frequently Asked Questions

What is the simplest way to test a Python function?

Use assert statements to check that calling the function with known inputs produces the expected output. An assert statement raises an AssertionError if its condition is False. Write several assert statements for each function, covering normal inputs, edge cases, and error conditions. Run the file directly, and if no assertions fail, the tests pass.

How do I test a function that raises an exception?

Wrap the function call in a try-except block that catches the expected exception. If the exception is raised, the test passes. If it is not raised, the test should assert False to signal failure. Alternatively, use pytest.raises as a context manager, which is cleaner and more readable for testing expected exceptions.

When should I use a testing framework instead of plain assert statements?

Plain assert statements work well for small projects and quick checks. Move to a framework like pytest when you need to organize tests into classes and files, run them automatically, get detailed failure reports, use fixtures for setup and teardown, or integrate with continuous integration systems. The transition is gradual; you can start with asserts and adopt pytest features as your needs grow.

Conclusion

Testing functions is not a separate activity from writing them. Tests are the first client of every function you write, and they give you immediate feedback about whether the function's interface is clear and whether its behavior is correct. Writing tests while you write functions, rather than after, catches design problems early and produces code that is both correct and testable by construction.