✦ For everyone, free.

Practical knowledge for real and everyday life

Home

Testing Python Programs

Testing Python Programs ensures code reliability through automated checks, covering unit, integration, and system-level validation.

Testing Python programs is the systematic construction and execution of controlled checks that compare observed program behavior with expected behavior, isolate failures, preserve reproducibility, manage test resources, substitute selected collaborators, exercise asynchronous code, and report evidence that supports confidence without proving the absence of defects.


Foundations of Testing Python Programs

A test case is a set of inputs, execution conditions, and expected outcomes designed to verify a specific aspect of a program's behavior.

A test condition is a particular state or input scenario under which the program is evaluated.

An expected result is the anticipated output or effect that confirms the program behaves correctly under the test condition.

An assertion is a statement within a test that checks if an actual result matches the expected result.

A failure occurs when an assertion evaluates to false, indicating a mismatch between expected and actual results.

An error is an unexpected exception during test execution that is not directly related to assertion failures.

A fixture is the controlled setup and teardown environment necessary to prepare and clean up test resources.

A test double is a substitute for a collaborator component used to isolate the unit under test (e.g., mocks, stubs).

A test suite is a collection of test cases aggregated for combined execution.

A runner is the component responsible for executing tests and collecting results.

A test result is the accumulated evidence from running tests, including counts of runs, failures, errors, skips, and other outcomes.

Tests provide evidence about specified behavior rather than mathematical proof that a program is correct under every possible condition.

ResponsibilityDescription
Test DesignDefine behavior, identify test conditions, and specify expected results
Test CasesImplement specific input and execution scenarios with assertions
AssertionsVerify actual output against expected results
Fixtures and CleanupSetup and teardown controlled resources to isolate tests
Loading and DiscoveryLocate and assemble tests automatically or explicitly
Execution and ResultsRun tests and collect evidence including passes, failures, errors, skips
MockingSubstitute collaborators to isolate units and observe interactions
Asynchronous TestingSupport asynchronous code execution and cleanup
DoctestsExtract and execute interactive documentation examples

Test Design for Python Programs

Effective test design begins with clearly defined behavior: each test targets externally observable effects specified by the program’s contract rather than incidental implementation details. Tests cover representative normal cases, boundary conditions where behavior may change, and relevant failure cases expected to trigger error handling. Expectations must be deterministic and dependencies controlled to ensure reproducibility. Tests should be independent so that one test’s outcome does not affect another’s.

A common pattern to structure tests is arrange-act-assert:

  • Arrange: Set up the environment and inputs.
  • Act: Execute the behavior under test.
  • Assert: Verify the observed results against expectations.

Usually, a test should have a single coherent behavioral responsibility, even if it contains multiple assertions needed to confirm that behavior.

Example function with tests:

def divide(dividend, divisor):
    """Divide dividend by divisor, raising ZeroDivisionError if divisor is zero."""
    if divisor == 0:
        raise ZeroDivisionError("division by zero")
    return dividend / divisor

Tests illustrating contract-based expectations:

import unittest

class DivideTests(unittest.TestCase):
    def test_normal_case(self):
        # Arrange & Act
        result = divide(10, 2)
        # Assert expected quotient
        self.assertEqual(result, 5)

    def test_boundary_case(self):
        # Dividing by 1 should return the dividend unchanged
        self.assertEqual(divide(7, 1), 7)

    def test_failure_case(self):
        # Dividing by zero should raise ZeroDivisionError
        with self.assertRaises(ZeroDivisionError):
            divide(5, 0)

Each test derives expected behavior from the documented contract (e.g., division by zero raises an error), not from how the function is implemented internally.


Python Test Cases

The standard representation of test cases in Python is the unittest.TestCase class. Each test method within a TestCase represents an individual test. These methods are self-contained and can be executed independently or in arbitrary combinations. Descriptive method names document the test’s purpose.

subTest enables grouping related scenarios within a single test method, allowing all scenarios to run and report failures individually rather than stopping at the first failure.

Tests can be explicitly skipped or marked as expected failures. Skipping and expected-failure annotations provide explicit reporting of test status rather than silently ignoring or deleting tests.

Example:

import unittest

class SampleTests(unittest.TestCase):

    def test_basic(self):
        self.assertEqual(1 + 1, 2)

    def test_multiple_scenarios(self):
        for i in range(3):
            with self.subTest(i=i):
                self.assertLessEqual(i, 2)

    @unittest.skip("Demonstrating skipping")
    def test_skipped(self):
        self.fail("This test should be skipped")

    @unittest.expectedFailure
    def test_expected_failure(self):
        self.assertEqual(1, 0)  # Intentionally fails but reported as expected failure

In test reports:

  • test_basic passes normally.
  • test_multiple_scenarios runs each subtest; failures in any do not stop others.
  • test_skipped is reported as skipped.
  • test_expected_failure is reported as an expected failure, not as a hard failure.

Python Test Assertions

unittest.TestCase provides assertion methods for a wide range of checks:

  • Equality: assertEqual(a, b)
  • Identity: assertIs(a, b)
  • Truth values: assertTrue(x), assertFalse(x)
  • Membership: assertIn(a, b), assertNotIn(a, b)
  • Ordering (where appropriate): assertLess(a, b), assertGreaterEqual(a, b), etc.
  • Approximate numeric comparison: assertAlmostEqual(a, b, places=7)
  • Exception expectations: assertRaises(ExpectedException)
  • Warning expectations: assertWarns(ExpectedWarning)
  • Selected collection comparisons: assertCountEqual(a, b) for unordered comparison ignoring duplicates

Choosing an assertion that clearly communicates the intended relationship improves test readability and diagnostics.

A test failure results from an unmet assertion, while a test error occurs from an unexpected exception during test execution.

Context-manager forms such as assertRaises and assertWarns allow inspecting the caught exception or warning object for additional details.

Example assertions:

import unittest
import warnings

class AssertionExamples(unittest.TestCase):
    def test_equality(self):
        self.assertEqual(3 * 3, 9)

    def test_approximate(self):
        self.assertAlmostEqual(3.1415926, 3.14159, places=4)

    def test_identity_vs_equality(self):
        a = [1, 2]
        b = a
        c = [1, 2]
        self.assertIs(a, b)
        self.assertEqual(a, c)
        self.assertIsNot(a, c)

    def test_membership(self):
        self.assertIn('py', 'python')

    def test_expected_exception(self):
        with self.assertRaises(ZeroDivisionError):
            _ = 1 / 0

    def test_expected_warning(self):
        with self.assertWarns(DeprecationWarning) as cm:
            warnings.warn("deprecated", DeprecationWarning)
        self.assertIn("deprecated", str(cm.warning))

    def test_deliberate_failure(self):
        self.assertEqual(1, 0)  # Will report as failure

    def test_unexpected_error(self):
        # This will raise an unexpected TypeError, reported as error
        _ = len(5)

Python Test Fixtures and Cleanup

Fixtures are controlled test state required for reliable testing. unittest provides:

  • setUp: Run before each test method to prepare state.
  • tearDown: Run after each test method to clean up.
  • Class-level setup/teardown: setUpClass and tearDownClass, run once per TestCase class.
  • Module-level setup/teardown: setUpModule and tearDownModule, run once per module.

Fixtures must isolate tests to avoid hidden cross-test state.

addCleanup registers functions to run after a test method completes, in last-in-first-out order. Importantly, cleanup functions registered during setUp still run even if setUp fails, making cleanup more reliable than depending solely on tearDown.

enterContext is a helper that registers a context manager for automatic cleanup.

Example demonstrating fixture usage and cleanup reliability:

import unittest
import tempfile
import os

class FixtureExample(unittest.TestCase):

    def setUp(self):
        # Register cleanup first
        self.addCleanup(self.cleanup_temp_file)
        # Create a temporary file resource
        self.temp_file = tempfile.NamedTemporaryFile(delete=False)
        # Simulate a setup failure after registering cleanup
        raise RuntimeError("Setup failure after resource allocation")

    def cleanup_temp_file(self):
        try:
            os.unlink(self.temp_file.name)
        except Exception:
            pass

    def test_dummy(self):
        # This test will not run because setUp fails
        pass

Because addCleanup was called before the failure, cleanup_temp_file is still executed, ensuring resource cleanup even when setup fails. This is more robust than relying solely on tearDown, which is not called if setUp raises.


Python Test Loading and Discovery

unittest.TestLoader is responsible for loading tests from TestCase classes, modules, or specific names. It supports automatic discovery of tests via the discover method, which finds tests matching a naming pattern (commonly starting with test) in a directory tree.

Test files and modules must be importable Python modules, and test classes and methods must follow naming conventions to be found automatically.

The load_tests protocol allows modules to customize how tests are assembled by providing a load_tests function.

Common commands:

python -m unittest test_module
python -m unittest test_module.TestClass.test_method
python -m unittest discover -s tests -p "test_*.py"

Here, discover searches the tests directory for files matching test_*.py and loads tests from them.

Discovery assembles a test suite but does not itself execute the tests; running the suite occurs separately.


Python Test Execution and Results

A TestSuite aggregates multiple tests, which a test runner such as unittest.TextTestRunner executes.

TestResult accumulates evidence from the run, including counts of:

  • tests run,
  • failures (assertion failures),
  • errors (unexpected exceptions),
  • skips (explicitly skipped tests),
  • expected failures, and
  • unexpected successes (tests marked as expected failures but passing).

Example:

import unittest

class Sample(unittest.TestCase):
    def test_pass(self):
        self.assertTrue(True)

    def test_fail(self):
        self.assertEqual(1, 0)

    def test_error(self):
        raise RuntimeError("Unexpected error")

    @unittest.skip("skip example")
    def test_skip(self):
        pass

    @unittest.expectedFailure
    def test_expected_failure(self):
        self.assertEqual(1, 0)

    @unittest.expectedFailure
    def test_unexpected_success(self):
        self.assertEqual(1, 1)

suite = unittest.TestSuite()
suite.addTest(Sample('test_pass'))
suite.addTest(Sample('test_fail'))
suite.addTest(Sample('test_error'))
suite.addTest(Sample('test_skip'))
suite.addTest(Sample('test_expected_failure'))
suite.addTest(Sample('test_unexpected_success'))

runner = unittest.TextTestRunner()
result = runner.run(suite)

print(f"Run: {result.testsRun}")
print(f"Failures: {len(result.failures)}")
print(f"Errors: {len(result.errors)}")
print(f"Skipped: {len(result.skipped)}")
print(f"Expected Failures: {len(result.expectedFailures)}")
print(f"Unexpected Successes: {len(result.unexpectedSuccesses)}")

This example demonstrates the distinction between:

  • successful execution (test_pass),
  • assertion failure (test_fail),
  • unexpected error (test_error),
  • skipped test (test_skip),
  • expected failure (test_expected_failure),
  • unexpected success (test_unexpected_success).

Mocking in Python Tests

Mock and MagicMock provide objects that simulate collaborators, allowing control over return values, side effects (such as raising exceptions), and recording of calls and arguments.

Mocks test assumptions about interactions rather than proving the behavior of the real collaborator.

patch temporarily replaces the name used by the code under test, which must be applied where the collaborator is looked up (the usage location), not necessarily where it was originally defined.

spec, spec_set, and autospec constrain mocks to have the interface of the real collaborator, reducing errors due to typos or nonexisting attributes, but do not guarantee semantic equivalence.

Example:

# mymodule.py
def collaborator():
    return "real value"

def function_under_test():
    return collaborator().upper()

Test with patching:

import unittest
from unittest.mock import patch

import mymodule

class PatchExample(unittest.TestCase):
    def test_function(self):
        with patch('mymodule.collaborator') as mock_collab:
            mock_collab.return_value = 'mocked value'
            result = mymodule.function_under_test()
            self.assertEqual(result, 'MOCKED VALUE')
            mock_collab.assert_called_once()

    def test_incorrect_patch(self):
        # Patching the original definition location (if different) may not affect usage
        with patch('some_other_module.collaborator') as mock_wrong:
            # This patch will have no effect if function_under_test uses 'mymodule.collaborator'
            result = mymodule.function_under_test()
            self.assertNotEqual(result, 'MOCKED VALUE')

Dependency substitution improves isolation when collaborators are complex or slow, but excessive mocking can make tests brittle by coupling them to implementation details. Prefer simple real collaborators or lightweight fakes when they provide clearer behavioral evidence.


Testing Asynchronous Python Code

unittest.IsolatedAsyncioTestCase supports isolated asynchronous test execution with:

  • asyncSetUp: asynchronous setup before each test.
  • Asynchronous test methods.
  • asyncTearDown: asynchronous cleanup after each test.
  • Cancellation of remaining event-loop tasks at test completion to avoid interference.
  • addAsyncCleanup: register asynchronous cleanup handlers.
  • enterAsyncContext: manage asynchronous context managers safely.

AsyncMock is a mock designed for async callables, allowing configured awaited results or exceptions and await-oriented assertions. Calling an async mock returns a coroutine that must be awaited to produce the result or raise an exception.

Example:

import unittest
from unittest.mock import AsyncMock, patch

class AsyncExample(unittest.IsolatedAsyncioTestCase):

    async def asyncSetUp(self):
        self.resource = await self.acquire_resource()
        self.addAsyncCleanup(self.cleanup_resource)

    async def acquire_resource(self):
        return "resource"

    async def cleanup_resource(self):
        pass

    async def test_async_behavior(self):
        async def coro():
            return 42

        result = await coro()
        self.assertEqual(result, 42)

    async def test_async_mock(self):
        async_mock = AsyncMock(return_value='mocked result')

        result = await async_mock()
        self.assertEqual(result, 'mocked result')
        async_mock.assert_awaited_once()

    @patch('module.async_function', new_callable=AsyncMock)
    async def test_patch_async(self, mock_async):
        mock_async.return_value = 'patched'
        from module import async_function
        result = await async_function()
        self.assertEqual(result, 'patched')
        mock_async.assert_awaited_once()

Python Doctests

The doctest module discovers interactive-style examples in documentation strings, executes their source, and compares actual output with the expected output.

By default, output comparison is strict, but option flags like ELLIPSIS allow flexible matching with .... Other flags normalize whitespace differences.

Doctests provide executable documentation examples, distinct from comprehensive behavioral test suites.

Example with doctest:

def greet(name):
    """
    Return a greeting message.

    >>> greet('Alice')
    'Hello, Alice!'

    >>> greet('')
    ''
    """
    if not name:
        return ''
    return f'Hello, {name}!'

Run doctests programmatically:

if __name__ == "__main__":
    import doctest
    doctest.testmod()

Or from the command line:

python -m doctest -v script.py

Nondeterministic output (e.g., timestamps) should be deliberately normalized or excluded rather than hidden indiscriminately to keep tests informative and maintainable.