Learn
05.5 | UNIT TESTS AND TEST DATA
01 | A TEST STARTS WITH AN EXPECTATION
A program can run without crashing and still answer the wrong question. A test gives a specific input, states the expected behaviour and compares that prediction with what actually happens.
A unit test focuses on a small part such as an amount parser or total calculation. An integration test checks connected parts, for example saving validated records and reading them back. A manual system test checks the whole user workflow. All three can help, but they answer different questions.
Choose expected results from the requirement and an independent calculation. Copying the implementation’s formula into a test can repeat the same mistake and provide false confidence.
02 | USE UNITTEST TO REPORT CHECKS
unittest is included with Python. A test class inherits from unittest.TestCase; for this lesson, treat that as the framework’s required container for assertion methods. Detailed class design comes in Section 6.
A method whose name begins test_ is discovered as a test. self refers to this test instance and lets you call assertion methods such as self.assertEqual(actual, expected). The method returns no special score; a failed assertion is reported by the runner.
The main guard calls unittest.main when the file runs directly. You can also use python -m unittest discover -v to discover matching test files, normally named test_*.py.
import unittest
def double(number):
return number * 2
class DoubleTests(unittest.TestCase):
def test_positive(self):
self.assertEqual(double(6), 12)
def test_zero(self):
self.assertEqual(double(0), 0)
if __name__ == "__main__":
unittest.main()03 | DIFFERENT ASSERTIONS ANSWER DIFFERENT QUESTIONS
assertEqual compares expected and actual values. assertTrue checks a condition. assertIsNone checks the exact absence sentinel. assertRaises checks that a specified exception occurs. Use the assertion that expresses the intended behaviour.
A function returning None for an absent search needs assertIsNone, while a legitimate index zero can be checked with assertEqual(..., 0). Printed output alone would not establish that return contract.
Ordinary assert statements are useful for quick development checks but can be removed under optimisation. unittest assertion methods provide framework checks. Neither is a substitute for validating untrusted input in the application.
04 | TEST REJECTION AS WELL AS SUCCESS
A validator’s promised behaviour includes what happens for unsuitable values. with self.assertRaises(ValueError) passes when that exception occurs and fails when the block does not raise it. An unexpected different exception is reported as an error.
A failed assertion is usually a test failure: behaviour did not match the expectation. An unexpected exception is an error in the test run. Inspect the message and traceback before deciding whether the test or implementation should change.
Check the exception type your contract promises. A division by zero is not a successful rejection if the requirement says the function should report a deliberate ValueError before performing unsuitable arithmetic.
import unittest
def parse_count(text):
count = int(text)
if not 0 <= count <= 10:
raise ValueError("Use 0 to 10")
return count
class CountTests(unittest.TestCase):
def test_lower_endpoint(self):
self.assertEqual(parse_count("0"), 0)
def test_above_limit(self):
with self.assertRaises(ValueError):
parse_count("11")
def test_unsuitable_text(self):
with self.assertRaises(ValueError):
parse_count("ten")
if __name__ == "__main__":
unittest.main()05 | SELECT DATA THAT EXPOSES THE RULE
Normal data represents ordinary accepted use. Boundary data sits at or around a rule’s limits. Invalid data breaks a rule. For a count from zero to ten inclusive, test -1, 0, 1, 9, 10 and 11, along with unsuitable text and blank input.
One test can cover several related cases using subTest. Each labelled case can fail without hiding the remaining cases in that loop. Do not treat a long list of random examples as automatically better than a small set that distinguishes the important branches.
For an amount parser, 0.01 and 10000.00 test accepted endpoints, while zero and 10000.01 test just-outside values. A third decimal tests the precision rule. These inputs target separate requirements.
06 | KEEP TESTS INDEPENDENT
A test should not need another test to run first. Fresh input data prevents an earlier mutation from changing the next test’s starting conditions. setUp runs before each test method and can prepare independent resources.
For files, TemporaryDirectory creates an isolated directory that can be cleaned afterwards. self.addCleanup registers cleanup even if later setup fails. Tests can safely create malformed practice files there without touching a real tracker save.
An integration test should reload from disk rather than compare the original in-memory list to itself. Change or discard that original state before checking restoration where appropriate.
from pathlib import Path
from tempfile import TemporaryDirectory
import unittest
class FileTests(unittest.TestCase):
def setUp(self):
self.temporary = TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.path = Path(self.temporary.name) / "note.txt"
def test_round_trip(self):
self.path.write_text("café", encoding="utf-8")
self.assertEqual(self.path.read_text(encoding="utf-8"), "café")
if __name__ == "__main__":
unittest.main()07 | TEST STATE CHANGES AND NON-CHANGES
A mutating helper has more than a return value to check. For take_item in the adventure, inspect inventory and room items as well as feedback. An invalid operation should preserve the relevant earlier state.
For storage, record the original file bytes before trying to save an invalid record. If validation fails, the earlier file should remain unchanged. For a failed expense save, the application should not accept the candidate into its current list.
Tests can reveal a missing boundary even when the normal result looks correct. A function that gives the right total but changes its input unexpectedly can still break its contract.
08 | READ A TEST MATRIX
Use a table to organise inputs before writing code. The expected result comes from the stated rule, not an automatic assumption that every input should be accepted. Select a case below to inspect a model test report.
09 | A PASSING SUITE IS EVIDENCE, NOT PROOF
A suite can pass while missing an important requirement. Review whether the tests cover boundaries, rejection routes, state effects and persistence. Coverage tools can show executed lines, but execution alone does not prove that assertions are meaningful.
After fixing a bug, keep a test that would have exposed it. Rerun earlier cases after changes; this is regression testing. If a test unexpectedly fails, do not simply change its expected value to match the program. Revisit the requirement.
For unpredictable values such as today’s date, prefer passing a fixed date into the function where possible. That makes behaviour reproducible without needing every test to know the current clock.
10 | CONNECT UNIT TESTS TO THE PROJECT
The expense tracker’s test file checks amount conversion, date rules, record shape, totals, missing-file behaviour, JSON restoration and CSV quoting. Temporary directories keep those tests separate from the user’s save.
Run the suite before adding a feature. Add tests for the new requirement, implement the change, then rerun the earlier suite. A month filter should not alter the all-record total or the saved records.
Also play the whole console workflow manually: add, list, total, quit and reopen. Unit tests make checks repeatable, while a user-facing check catches unclear prompts and startup instructions.
PUT YOUR SKILLS TO WORK
Use the Section 5 challenges to practise these ideas independently. Work in your Python editor, predict results and keep a short test record.
Section 5 challenges Python course navigation