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.
| Responsibility | Description |
|---|---|
| Test Design | Define behavior, identify test conditions, and specify expected results |
| Test Cases | Implement specific input and execution scenarios with assertions |
| Assertions | Verify actual output against expected results |
| Fixtures and Cleanup | Setup and teardown controlled resources to isolate tests |
| Loading and Discovery | Locate and assemble tests automatically or explicitly |
| Execution and Results | Run tests and collect evidence including passes, failures, errors, skips |
| Mocking | Substitute collaborators to isolate units and observe interactions |
| Asynchronous Testing | Support asynchronous code execution and cleanup |
| Doctests | Extract 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_basicpasses normally.test_multiple_scenariosruns each subtest; failures in any do not stop others.test_skippedis reported as skipped.test_expected_failureis 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:
setUpClassandtearDownClass, run once perTestCaseclass. - Module-level setup/teardown:
setUpModuleandtearDownModule, 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.