Grade by Grade
B. Software Development · Software Development: Managing a Team Software Project

A Formal Test Plan, Part 1: Unit Testing with unittest

A4.2 B1.3About 75 minutes
Learning goals
  • I can describe the parts of a formal test plan and the levels of testing: unit, integration, system, acceptance and regression.
  • I can design unit tests with normal, boundary and invalid cases that trace back to requirements.
  • I can write and run automated unit tests with Python's unittest module, using assertEqual, assertTrue, assertFalse, assertAlmostEqual, assertRaises and setUp.
  • I can read a test failure report and use it to locate a bug.
  • I can explain why tests are written alongside (or before) the code.

Words to know

formal test plan
A written plan stating what will be tested, at which levels, with which test cases (IDs, inputs, expected results), by whom, when, and what counts as passing.
unit test
A test of one small unit of code (a function or a class) on its own.
integration test
A test that checks that several modules work correctly together.
regression test
Re-running existing tests after a change to make sure nothing that used to work is now broken.
test case
One test: an ID, the requirement it checks, the input or situation, and the expected result.
assertion
A statement in a test that checks a result, such as self.assertEqual(actual, expected). If it is false, the test fails.
test fixture
The setup a test needs, such as sample data. In unittest, setUp builds it fresh before every test.
test-driven development (TDD)
Writing a failing test first, then just enough code to pass it, then tidying the code; repeated in small steps.

"It worked when I tried it"

In Grade 11 you wrote test plans with expected and actual results, and you used assert and small test loops. That works for one person and one program. A team project needs more: hundreds of tests, run automatically, by anyone, after every change, with results that are easy to read.

The curriculum asks you to develop and implement a formal testing plan (e.g., unit testing, integration testing, regression testing) for a software project to ensure program correctness (A4.2), and to produce the software according to specifications: code, test, deploy (B1.3). This lesson covers the plan and unit testing. The next lesson covers integration, regression and acceptance testing. (Testing the correctness of particular algorithms, such as searches and sorts, is part of the sibling unit Algorithm Design and Analysis.)

What a formal test plan contains

A formal test plan is a document, written during design and updated during development. Amara's plan for LendIt has these sections:

  1. Scope: what will be tested (every Must and Should requirement, R1 to R7 and N1 to N4, plus any Could, R8 or N5, that gets built) and what will not (online booking, R9, which is out of scope).
  2. Levels and approach: unit tests for every public function; integration tests for module pairs and the whole program; acceptance tests with the client; regression testing by re-running everything before every merge.
  3. Test cases: each with an ID, the requirement it checks, the kind (normal, boundary, invalid), the input, the expected result, and later the actual result and pass/fail.
  4. Environment: Python 3 on Windows (like the centre's laptop) and on the team's computers; sample data files.
  5. Responsibilities and schedule: each module's author writes its unit tests; Amara reviews them and writes integration and acceptance tests; tests run at every merge.
  6. Entry and exit criteria: testing of a module starts when it is code-complete; the release is ready when every Must requirement's tests pass and no known bug is rated "critical".
  7. Bug reporting: how failures are recorded and tracked.
Acceptancethe client checks requirementsSystemthe whole program, as users use itIntegrationmodules working togetherUnitone function or class at a timeRegression: re-run them allafter every changemany small fast tests at the bottom, a few big ones at the top
The levels of testing. Most tests are small, fast unit tests; fewer test modules together; a handful test the whole system and the client's acceptance. Regression testing means re-running all of them after each change.

Choosing unit test cases

For each function, pick inputs from three groups (you used these in Grade 11):

  • Normal: typical values (3 days late).
  • Boundary: values on both sides of every edge in the rules. For the late fee: returned on the due date (0 days), one day late, exactly at the cap (20 days × $0.25 = $5.00), and beyond the cap.
  • Invalid: values the function should reject. What should happen if returned is not a date? The interface should say, and a test should check it.

Also think about white-box cases: look at the code and make sure every branch and every loop path (zero times, once, many times) runs in at least one test.

Python's unittest module

Python includes a testing framework called unittest. You write tests as methods of a class that inherits from unittest.TestCase. (Classes and inheritance are taught in the sibling unit Modular and Object-Oriented Design; here you only need the pattern.) Rules:

  • The test file is named test_<module>.py.
  • Each test method's name starts with test_ and describes the behaviour being checked.
  • Each test arranges inputs, acts (calls the function) and asserts the result.
  • setUp (optional) runs before every test, giving each one fresh data.

Useful assertions: assertEqual(a, b), assertTrue(x), assertFalse(x), assertAlmostEqual(a, b) for floats (it rounds the difference to 7 decimal places), assertIn(item, collection), and assertRaises(ErrorType) for code that should raise an exception.

def test_three_days_late(self):due = date(2027, 1, 18)returned = date(2027, 1, 21)fee = late_fee(due, returned)self.assertAlmostEqual(fee, 0.75)Arrangeset up the inputsActcall the function onceAssertcompare with expectedName says what is tested; one behaviour per test; no input() or print() needed.
The arrange, act, assert pattern. Unit tests call functions directly with chosen inputs, so they never wait for typing and run in a fraction of a second.

Here is LendIt's loans.py at the end of sprint 1. It builds on the design-stage version from the design documents lesson, adding days_late and can_borrow.

"""loans.py: loan rules for LendIt."""
from datetime import date, timedelta
LOAN_DAYS = 14
FEE_PER_DAY = 0.25
MAX_FEE = 5.00
MAX_ITEMS = 3
def due_date(borrowed: date) -> date:
"""Return the date a loan is due (R2)."""
return borrowed + timedelta(days=LOAN_DAYS)
def days_late(due: date, returned: date) -> int:
"""Return how many days late a return is, or 0 if on time or early."""
return max(0, (returned - due).days)
def late_fee(due: date, returned: date) -> float:
"""Return the late fee in dollars (R3): $0.25 per day late, at most $5.00."""
return min(MAX_FEE, days_late(due, returned) * FEE_PER_DAY)
def can_borrow(member_no: int, loans: list) -> bool:
"""Return True if the member has fewer than MAX_ITEMS items out (R7)."""
out = 0
for loan in loans:
if loan["member_no"] == member_no and loan["returned"] is None:
out += 1
return out < MAX_ITEMS

loans.py (a module: it defines functions but prints nothing when run on its own).

And here are its unit tests. Save both files in the same folder.

import unittest
from datetime import date
from loans import due_date, late_fee, can_borrow
class TestDueDate(unittest.TestCase):
def test_normal(self):
self.assertEqual(due_date(date(2027, 1, 4)), date(2027, 1, 18))
def test_across_month_end(self):
self.assertEqual(due_date(date(2027, 1, 25)), date(2027, 2, 8))
class TestLateFee(unittest.TestCase):
def setUp(self):
self.due = date(2027, 1, 18) # runs before every test
def test_on_due_date(self):
self.assertEqual(late_fee(self.due, date(2027, 1, 18)), 0.0)
def test_early(self):
self.assertEqual(late_fee(self.due, date(2027, 1, 10)), 0.0)
def test_three_days_late(self):
self.assertAlmostEqual(late_fee(self.due, date(2027, 1, 21)), 0.75)
def test_exactly_at_cap(self):
self.assertAlmostEqual(late_fee(self.due, date(2027, 2, 7)), 5.00)
def test_past_cap(self):
self.assertAlmostEqual(late_fee(self.due, date(2027, 2, 17)), 5.00)
class TestCanBorrow(unittest.TestCase):
def loan(self, member, returned=None):
return {"item_id": "SS-01", "member_no": member, "returned": returned}
def test_two_out(self):
self.assertTrue(can_borrow(1042, [self.loan(1042), self.loan(1042)]))
def test_three_out(self):
self.assertFalse(can_borrow(1042, [self.loan(1042)] * 3))
def test_returned_loans_do_not_count(self):
loans = [self.loan(1042), self.loan(1042), self.loan(1042, date(2027, 1, 10))]
self.assertTrue(can_borrow(1042, loans))
def test_other_members_do_not_count(self):
self.assertTrue(can_borrow(1042, [self.loan(7)] * 3))
if __name__ == "__main__":
unittest.main(verbosity=2)

test_loans.py. Each class groups the tests for one function. unittest.main(verbosity=2) runs every test and lists each one by name.

Run it with python3 test_loans.py (or press Run in your editor). unittest writes its report to the error stream, which most editors show in the same window. The time on the Ran line will be different on your computer.

$ python3 test_loans.py
test_other_members_do_not_count (__main__.TestCanBorrow.test_other_members_do_not_count) ... ok
test_returned_loans_do_not_count (__main__.TestCanBorrow.test_returned_loans_do_not_count) ... ok
test_three_out (__main__.TestCanBorrow.test_three_out) ... ok
test_two_out (__main__.TestCanBorrow.test_two_out) ... ok
test_across_month_end (__main__.TestDueDate.test_across_month_end) ... ok
test_normal (__main__.TestDueDate.test_normal) ... ok
test_early (__main__.TestLateFee.test_early) ... ok
test_exactly_at_cap (__main__.TestLateFee.test_exactly_at_cap) ... ok
test_on_due_date (__main__.TestLateFee.test_on_due_date) ... ok
test_past_cap (__main__.TestLateFee.test_past_cap) ... ok
test_three_days_late (__main__.TestLateFee.test_three_days_late) ... ok
----------------------------------------------------------------------
Ran 11 tests in 0.000s
OK

Terminal output. Every test is listed with ok. Tests run in alphabetical order by class and then by method name, not in the order they appear in the file.

You can also run every test file in a folder at once with python3 -m unittest. It finds every file named test_*.py, runs everything, and prints one dot per passing test: ........... for these 11 tests, then Ran 11 tests and OK.

Worked example

Example 1: Designing the late-fee tests

Using requirement R3 ($0.25 per day late, at most $5.00), list the test cases for late_fee with expected results, and say which kind each is. The due date is January 18, 2027.

  1. Early (normal, on-time group): returned January 10 → 0 days late → $0.00.
  2. On the due date (boundary): returned January 18 → $0.00. This catches code that counts the due date itself as late.
  3. Normal late: returned January 21 → 3 days × $0.25 = $0.75.
  4. Exactly at the cap (boundary): $5.00 ÷ $0.25 = 20 days, so returned February 7 (January 18 + 20 days) → $5.00.
  5. Beyond the cap: returned February 17 → 30 days would be $7.50, capped at $5.00. This catches a missing min.
  6. The fee is a float, so use assertAlmostEqual, which tolerates tiny rounding errors in floating-point arithmetic.

Five cases, matching the five tests in the TestLateFee class. Each one targets a specific way the code could be wrong.

When a test fails

Reading a failure report uses the same skills as reading any traceback (see Code Maintenance: Using Documentation to Fix Errors Independently in Data Types, Files and Expressions). Suppose a teammate "tidies" can_borrow and changes its last line to return out <= MAX_ITEMS. The tests now report:

$ python3 -m unittest
..F........
======================================================================
FAIL: test_three_out (test_loans.TestCanBorrow.test_three_out)
----------------------------------------------------------------------
Traceback (most recent call last):
File ".../test_loans.py", line 42, in test_three_out
self.assertFalse(can_borrow(1042, [self.loan(1042)] * 3))
AssertionError: True is not false
----------------------------------------------------------------------
Ran 11 tests in 0.001s
FAILED (failures=1)

Output from python3 -m unittest with the bug (the F is the third test run). The exact layout of the traceback differs slightly between Python versions, and the path is shortened here.

Worked example

Example 2: Reading the failure

Use the failure report to find and fix the bug.

  1. Which test? test_three_out in class TestCanBorrow (it is test case T10 in the plan).
  2. What was expected? assertFalse(...): a member with 3 items out must be refused (R7).
  3. What happened? True is not false: the function returned True, so it would have allowed a fourth item.
  4. Where to look? can_borrow compares the count with MAX_ITEMS. With 3 items out, 3 <= 3 is True. The rule "fewer than 3" needs <.
  5. Fix and re-run: change it back to out < MAX_ITEMS; all 11 tests pass. Record T10 as fail, then pass after the fix, in the test plan.

An off-by-one error at the boundary, caught by a boundary test within seconds of the change, before it reached any user.

IDReq.LevelKindInputExpectedActualP/F
T3R2unitnormalborrowed 2027-01-04due 2027-01-182027-01-18pass
T3bR2unitboundary (month end)borrowed 2027-01-25due 2027-02-082027-02-08pass
T4R3unitboundarydue Jan 18, returned Jan 18$0.00$0.00pass
T5R3unitnormaldue Jan 18, returned Jan 21$0.75$0.75pass
T6R3unitboundary (cap)due Jan 18, returned Feb 7 (20 days)$5.00$5.00pass
T6bR3unitbeyond capreturned Feb 17 (30 days)$5.00$5.00pass
T10R7unitboundarymember has 3 items outFalse (refused)TrueFAIL
T11R7unitnormal2 out, 1 returnedTrueTruepass
Part of LendIt's test plan for the loans module after the first run. T10 failed; the cause is investigated in Example 2.

Testing invalid input

What should late_fee do if someone passes the text "2027-01-21" instead of a date? Subtracting a string from a date raises a TypeError. The team decides that this is correct behaviour for a programming mistake (the menu, not loans.py, is responsible for turning user input into dates), and writes a test to pin that decision down:

  • with self.assertRaises(TypeError): followed by an indented late_fee(self.due, "2027-01-21")

The test passes only if that call raises a TypeError. Input that users type is validated in main.py, and those checks get their own tests.

Tests first?

Many developers write the test before the code, an approach called test-driven development (TDD):

  1. Red: write a test for the next small behaviour. Run it; it fails (the code does not exist yet).
  2. Green: write just enough code to make it pass.
  3. Refactor: tidy the code, re-running the tests to make sure it still works.

Whether you write tests first or right after, the rule for the team is the same: no function is "done" until it has tests (it is in LendIt's definition of done).

Just for fun

In 1947, operators of the Harvard Mark II computer found a moth trapped in one of its relays and taped it into the logbook with the note "First actual case of bug being found." Engineers had already been calling faults "bugs" for decades; this was just the first one with wings.

Hands-on activity

Project step 8: Write your test plan and unit tests

You need: your project's code (or module stubs), the traceability matrix from project step 5, and Python.

  1. Write the test plan document (docs/test-plan.md) with the seven sections listed in this lesson. Keep it short, but specific.
  2. For your two most important modules, list unit test cases in a table: ID, requirement, kind (normal, boundary, invalid), input, expected result. Include at least two boundary cases per function that has a rule with a number in it.
  3. Write the tests in tests/test_<module>.py with unittest. (If your code is in a src folder, the simplest set-up for now is to keep the test files next to the modules they test.)
  4. Run them. Record actual results and pass/fail in your table.
  5. Plant a bug (change a < to <= or a number by one) and check that at least one test fails. If no test fails, you are missing a boundary test: add it. Then remove the bug.
  6. Commit the tests with a message such as Add unit tests for loans module (R2, R3, R7).

What to look for: every test name describes a behaviour; every number rule has tests on both sides of its boundary; the planted bug was caught.

Your turn

1

Name four levels of testing, from smallest to largest.

2

What must every unittest test method's name start with?

3

Why use assertAlmostEqual for late fees but assertEqual for due dates?

4

Give the expected fee for a return 19 days late, and say why this is a good test value.

5

What does setUp do, and why is it useful?

6

A test report shows ..F... How many tests ran, and how many failed?

7

What are the three steps of test-driven development?

More practice: warm-up

1

What is a unit test?

2

Which assertion checks that a call raises an exception?

3

What is regression testing?

4

Which command runs every test_*.py file in the current folder?

5

In the test plan, what two columns are filled in only when the tests run?

More practice: on your own

1

Write a unit test method that checks a member with no loans can borrow.

2

Write a test for due_date across a year end: borrowed December 25, 2026.

3

A teammate changes late_fee to return days_late(due, returned) * FEE_PER_DAY (removing the cap). Which tests fail, and with what actual value?

4

Why should unit tests not use input() or print()?

5

Write the test-plan row for a test that a member with 2 items out can borrow.

Challenge

1

The team argues about whether late_fee should raise an error or return 0 when returned is before borrowed (impossible dates). Give one argument for each, and write a test for the option you choose.

2

The tests run in alphabetical order, not the order written. Explain why good unit tests must not depend on running in a particular order.

3

Count how many tests in test_loans.py are boundary tests, and name one boundary that is not tested yet.

Check your understanding

Quiz

1.

Which is a unit test?

2.

Which test case is a boundary case for "at most $5.00" at $0.25 per day?

3.

A unittest report ends with FAILED (failures=2). What does it mean?

4.

Why are fees compared with assertAlmostEqual?

5.

Which belongs in a formal test plan?

6.

What is the first step of test-driven development?

For parents and teachers

Big idea: A4.2 asks students to develop and implement a formal testing plan, including unit, integration and regression testing; B1.3 asks them to produce software to specification (code, test, deploy). This lesson covers the test plan document and automated unit tests with Python's built-in unittest module.

How to help: Ask your teen to show you their tests running, then to "plant a bug" while you watch and see a test catch it. Ask "What's the trickiest value for this rule?" for one of their requirements; the answer should be a boundary.

Common difficulties:

  • Testing only typical values and missing boundaries (the most common source of bugs).
  • Test methods whose names do not start with test_, so they silently never run.
  • Comparing floats with assertEqual.
  • Writing tests that depend on each other or on running in a certain order.