CMPUT-301 · Lesson 9 of 12 · 30 min

Testing I: fundamentals and unit testing

Testing is the most widely used way to gain confidence that software works. A test runs the software under controlled conditions and compares what happens with what should happen. Testing cannot prove that a program is correct; as Edsger Dijkstra famously put it, program testing can be used to show the presence of bugs, but never to show their absence. What well-designed tests can do is find defects early and cheaply, document expected behavior precisely, make refactoring safe and catch regressions when later changes break something that used to work. This lesson covers the vocabulary and levels of testing, unit testing with pytest, how to choose test cases systematically with equivalence partitioning and boundary value analysis, test doubles such as mocks and fakes, and test-driven development. Lesson 10 continues with integration, system and advanced techniques. CMPUT-174 lesson 11 introduced assert and unittest, and CMPUT-201 lesson 11 tested C programs; here testing becomes a team discipline.

The vocabulary of testing

Precise words help. In the IEEE's standard terminology, a person makes an error (a mistake, such as misreading a requirement), which introduces a fault, also called a defect or bug, into the code; when the faulty code executes under the right conditions, it may cause a failure, an observable difference between actual and expected behavior. Not every fault causes a failure: a fault in code that never runs, or that runs only with rare inputs, may lie dormant for years. Testing looks for failures; debugging then finds and fixes the faults behind them.

Every test needs a test oracle, a way to decide whether an outcome is correct. Often the oracle is simply the expected value written in the test, but sometimes it is harder: what is the correct output of a route planner or a machine learning model? Techniques such as property-based testing and metamorphic testing (lesson 10) help when exact expected values are unknown.

Levels of testing
LevelWhat is testedTypical ownerSpeed
UnitA single function, class or small module in isolationDevelopersMilliseconds
IntegrationSeveral units or a unit with real infrastructure such as a databaseDevelopersSeconds
System (end-to-end)The whole deployed system through its external interfacesDevelopers and testersSeconds to minutes
AcceptanceWhether the system meets requirements and users' needsProduct owner and users, often automated by developersVaries

Mike Cohn's test pyramid summarizes how a healthy test suite is balanced: many fast unit tests at the base, fewer integration tests in the middle, and a small number of slow end-to-end tests at the top. End-to-end tests give the most realistic confidence but are slow, brittle and hard to diagnose when they fail; unit tests are fast and precise but cannot catch problems in how parts fit together. Lesson 10 returns to the pyramid and its alternatives.

A triangle divided into three bands. The wide base is unit tests, many and fast. The middle band is integration tests. The narrow top is end-to-end tests, few and slow. An arrow on the left points up labeled slower, more realistic, more brittle; an arrow on the right points down labeled faster, cheaper, more precise.
The test pyramid: many fast unit tests, fewer integration tests, a few end-to-end tests

Unit testing with pytest

Python's standard library includes unittest, modeled on Java's JUnit, but most Python teams use pytest, which runs plain functions whose names start with test_ and uses ordinary assert statements, rewriting them so that failures show the values involved. A good unit test follows the arrange–act–assert pattern: set up the inputs, call the code under test, and check the result. It tests one behavior, has a name that describes that behavior, runs fast, and does not depend on other tests or on the order in which tests run.

Consider a rule from a volunteer organization that also sells seats in paid training workshops: canceling a seat less than 24 hours before costs $15, between 24 and 72 hours costs $5, and minors never pay. Here is the function and a first set of tests.

The code under test: a cancellation fee with three bands and an exemption
def late_cancel_fee(hours_before: float, is_minor: bool = False) -> int:
    """Fee in dollars for cancelling a paid workshop seat."""
    if hours_before < 0:
        raise ValueError("cannot cancel after the start")
    if is_minor:
        return 0
    if hours_before < 24:
        return 15
    if hours_before < 72:
        return 5
    return 0
Five unit tests in pytest, one behavior each (real run)
pytest -v --no-header test_fee.py
Output
$ pytest -v --no-header test_fee.py
============================= test session starts ==============================
collecting ... collected 5 items
test_fee.py::test_no_fee_well_in_advance PASSED                          [ 20%]
test_fee.py::test_small_fee_two_days_before PASSED                       [ 40%]
test_fee.py::test_large_fee_on_the_day PASSED                            [ 60%]
test_fee.py::test_minors_never_pay PASSED                                [ 80%]
test_fee.py::test_cancelling_after_start_is_an_error PASSED              [100%]
============================== 5 passed in 0.01s ===============================

Each test name reads as a statement of the rule, so the test file doubles as documentation. The last test checks an error path with pytest.raises, which passes only if the expected exception occurs. Error paths deserve tests as much as the happy path, because that is where defects hide.

Choosing test cases: equivalence partitioning

Exhaustive testing is impossible for nearly any real function: even late_cancel_fee accepts infinitely many floating-point values. Test design techniques choose a small set of cases that is likely to reveal faults. Equivalence partitioning divides the input domain into classes, or partitions, whose members the program should treat the same way, then picks one representative from each. For late_cancel_fee the partitions of hours_before are: negative (error), 0 up to but not including 24 (fee 15), 24 up to but not including 72 (fee 5), and 72 or more (no fee); is_minor adds two more partitions. Invalid partitions, such as negative hours, are as important as valid ones.

Boundary value analysis

Faults cluster at the edges of partitions, because programmers write < when they meant <=, or count from 1 when they should count from 0. Boundary value analysis tests values at and immediately around each boundary: for the 24-hour boundary, test 23.99, 24 and 24.01 (or 23, 24 and 25 for whole numbers). pytest's parametrize decorator makes this compact: one test function, many cases, each reported separately.

Boundary tests catch an off-by-one fault: a version of the code that uses <= 72 instead of < 72
pytest -q test_boundaries.py
Output
$ pytest -q test_boundaries.py
.....F...                                                                [100%]
=================================== FAILURES ===================================
_________________________ test_fee_at_boundaries[72-0] _________________________
hours = 72, expected = 0
    @pytest.mark.parametrize("hours, expected", [
        (0, 15), (23.99, 15),           # lowest valid partition and its upper edge
        (24, 5), (24.01, 5),            # the 24-hour boundary
        (71.99, 5), (72, 0), (72.01, 0) # the 72-hour boundary
    ])
    def test_fee_at_boundaries(hours, expected):
>       assert late_cancel_fee(hours) == expected
E       assert 5 == 0
E        +  where 5 = late_cancel_fee(72)
test_boundaries.py:10: AssertionError
=========================== short test summary info ============================
FAILED test_boundaries.py::test_fee_at_boundaries[72-0] - assert 5 == 0
1 failed, 8 passed in 0.01s

This run uses a deliberately faulty version of the function, with <= 72 where the rule says less than 72 hours. All the representative-value tests from the previous section would still pass, because 48 and 100 are far from the boundary. Only the boundary case at exactly 72 hours exposes the fault, and pytest's failure message shows the actual and expected values. That is the whole argument for boundary value analysis in one example.

Equivalence partitions and boundary values for the cancellation fee
InputPartitionBoundary cases to testExpected
hours_before < 0Invalid-0.01ValueError
0 <= hours_before < 24Fee $150, 23.9915
24 <= hours_before < 72Fee $524, 71.995
hours_before >= 72No fee72, 72.010
is_minor = TrueExemptwith hours 3 and 480

When several inputs interact, the number of combinations grows quickly. Decision tables list the combinations of conditions and the expected action for each; for example, is_minor combined with each time band. Pairwise (all-pairs) testing chooses a small set of cases that covers every pair of parameter values at least once, on the evidence that most interaction faults involve only two parameters. Tools such as Microsoft's PICT generate pairwise suites automatically.

Test doubles: stubs, fakes and mocks

Unit tests should be fast and isolated, but code often depends on slow or unpredictable things: databases, networks, email servers, the clock, random numbers. A test double stands in for such a dependency. Gerard Meszaros named several kinds. A dummy is passed but never used. A stub returns canned answers, such as a fixed current time. A fake is a working but simplified implementation, such as an in-memory repository instead of a database. A spy records how it was called. A mock is configured with expectations about how it should be called and lets the test verify them. Python's unittest.mock provides Mock and MagicMock objects that can act as stubs, spies or mocks, and patch, which temporarily replaces a name during a test.

A stubbed clock, a fake repository and a mock notifier make a deterministic unit test
from datetime import datetime, timedelta
from unittest.mock import Mock

class ReminderService:
    def __init__(self, shifts, notifier, clock):
        self.shifts, self.notifier, self.clock = shifts, notifier, clock

    def send_due(self):
        """Remind volunteers whose shift starts within the next 24 hours."""
        now = self.clock()
        sent = 0
        for s in self.shifts.upcoming():
            if now <= s["start"] <= now + timedelta(hours=24):
                self.notifier.send(s["email"], f"Reminder: shift at {s['start']:%H:%M}")
                sent += 1
        return sent

# Stub for the clock: a fixed "now" makes the test deterministic
fixed_now = datetime(2026, 11, 6, 9, 0)
# Fake repository: a tiny in-memory implementation
class FakeShifts:
    def upcoming(self):
        return [{"email": "ana@example.org", "start": fixed_now + timedelta(hours=3)},
                {"email": "ben@example.org", "start": fixed_now + timedelta(hours=30)}]
# Mock notifier: records calls so the test can verify them
notifier = Mock()

service = ReminderService(FakeShifts(), notifier, clock=lambda: fixed_now)
assert service.send_due() == 1
notifier.send.assert_called_once_with("ana@example.org", "Reminder: shift at 12:00")
print("reminders sent:", notifier.send.call_count)
print("calls recorded:", notifier.send.call_args_list)
Output
reminders sent: 1
calls recorded: [call('ana@example.org', 'Reminder: shift at 12:00')]

This test runs in microseconds, never sends real email and never depends on the time of day. It works because ReminderService receives its dependencies through its constructor rather than creating them, the dependency injection from CMPUT-274 lesson 5. Code that reads the clock or opens a database connection deep inside a function is hard to test, which is one reason testability is a design quality, not only a testing concern.

Mocks are powerful and easily overused. A test that mocks everything and checks that particular internal methods were called in a particular order is coupled to the implementation: a harmless refactoring breaks it even though behavior is unchanged. Two schools describe the tension. The classicist (or Detroit) school uses real objects wherever practical and doubles only for awkward dependencies, testing state and results. The mockist (or London) school, associated with the book Growing Object-Oriented Software, Guided by Tests, uses mocks to drive the design of object interactions. A good rule of thumb for both: verify behavior that matters to the caller, such as 'one reminder was sent to Ana', and prefer fakes to mocks for things like repositories.

Test-driven development

Test-driven development (TDD), popularized by Kent Beck as part of Extreme Programming, reverses the usual order: write a failing test for the next small piece of behavior, write just enough code to make it pass, then refactor while the tests stay green. The cycle, often summarized as red–green–refactor, takes minutes and repeats continuously. TDD produces a thorough test suite as a by-product, keeps the design testable because every unit is used by a test before it exists, and gives frequent feedback. Its critics point out that it can lead to over-specified tests and that it suits well-understood problems better than exploratory work. Research results are mixed: studies at IBM and Microsoft found substantially fewer defects with TDD at the cost of somewhat more initial development time, while other controlled experiments found smaller effects. Many developers use TDD selectively, for logic-heavy code and bug fixes, where writing the failing test first proves the bug is understood.

A circular cycle with three stages: red, write a failing test; green, write the simplest code that passes; refactor, improve the design with all tests passing. Arrows go around the circle repeatedly.
The test-driven development cycle: red, green, refactor

Here is one red–green cycle for a new rule: a volunteer may not sign up for two overlapping shifts. First the test, which fails because the function does not exist yet.

Red: the tests cannot even be collected because overlaps does not exist yet (key lines of real output)
pytest -q test_overlap.py 2>&1 | grep -E "^E |error"
Output
$ pytest -q test_overlap.py 2>&1 | grep -E "^E |error"
E   ImportError: cannot import name 'overlaps' from 'overlap' (~/project/overlap.py)
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01s
Green: the simplest correct implementation makes all three tests pass
cat overlap.py
pytest -q test_overlap.py
Output
$ cat overlap.py
def overlaps(a, b):
    """True if the half-open intervals [start, end) a and b share any time."""
    return a[0] < b[1] and b[0] < a[1]
$ pytest -q test_overlap.py
...                                                                      [100%]
3 passed in 0.01s

The implementation uses half-open intervals, a choice the first test forced: a shift ending at 12:00 and one starting at 12:00 do not overlap. Writing the test first made that decision explicit before any code existed. The refactor step here might rename the parameters or introduce a Shift type; with the tests green, such changes are safe.

What makes tests good

Good unit tests share a set of properties sometimes summarized as FIRST: Fast, so they can run on every change; Independent, so they can run in any order; Repeatable, giving the same result every time in any environment; Self-validating, passing or failing without a person reading output; and Timely, written with or before the code. Tests are code and deserve the same care: clear names, no duplication that hides intent, and helpers or fixtures for shared setup. A test that fails intermittently, a flaky test, is worse than no test, because people learn to ignore failures; lesson 10 discusses causes and cures.

Worked problem

Designing tests from a requirement

Requirement: "A shift accepts sign-ups until it is full. Capacity is between 1 and 50. When full, further sign-ups go to a waiting list of at most 10; beyond that, sign-up is refused." Use equivalence partitioning and boundary values to list test cases.

Show solution
  1. Capacity partitions: invalid below 1, valid 1 to 50, invalid above 50. Boundary cases: 0, 1, 50, 51.
  2. Sign-up partitions given capacity c: confirmed while confirmed count < c; waitlisted while confirmed = c and waitlist < 10; refused when waitlist = 10. Boundaries: the c-th sign-up (last confirmed), the (c+1)-th (first waitlisted), the (c+10)-th (last waitlisted), the (c+11)-th (refused).
  3. Use a small capacity such as 2 to keep tests short: sign-ups 1 and 2 confirmed, 3 to 12 waitlisted, 13 refused; plus capacity 1 and 50 to test the extremes of the valid range.
  4. Add invalid-input tests: creating a shift with capacity 0 or 51 raises an error.

Answer: About ten focused tests cover every partition and boundary, which is far more effective than dozens of random sign-ups.

Worked problem

A test that breaks on refactoring

A test for ReminderService mocks the repository and asserts that repository.upcoming() is called exactly once and then repository.get_email(id) is called for each shift. A developer refactors upcoming() to return emails directly, and the test fails though reminders are still sent correctly. What went wrong, and how should the test be written?

Show solution
  1. The test verified internal interactions (how the service talks to its repository) rather than the behavior that matters (who receives a reminder).
  2. Rewrite it with a fake repository holding known shifts and assert on the outcome: which emails the notifier received.
  3. Keep mocks for the boundary that matters to the outside world, here the notifier, and assert on the message and recipient rather than on call order.

Answer: Over-mocking coupled the test to the implementation. Testing observable behavior through fakes and a mock at the outer boundary lets the code be refactored freely.

Common mistakes

  • MistakeTesting only the happy path with typical values.

    InsteadUse equivalence partitions, including invalid ones, and test at boundaries where off-by-one faults hide.

  • MistakeLetting unit tests touch real databases, networks or the clock.

    InsteadInject dependencies and use stubs, fakes or mocks so unit tests are fast and deterministic.

  • MistakeMocking everything and asserting on internal calls.

    InsteadAssert on observable results; prefer fakes for collaborators and reserve mocks for outer boundaries.

  • MistakeTests that depend on each other or on order.

    InsteadEach test sets up its own state, so tests can run in any order or in parallel.

  • MistakeBelieving passing tests prove correctness.

    InsteadTests show the presence of bugs, not their absence; design them to be likely to reveal faults.

Summary and key terms

  • An error introduces a fault, which may cause a failure; tests look for failures and need an oracle to judge results.
  • Testing levels are unit, integration, system and acceptance; the test pyramid favors many fast unit tests and few slow end-to-end tests.
  • pytest runs plain test functions with assert, parametrized cases, fixtures and pytest.raises for error paths.
  • Equivalence partitioning picks a representative from each class of inputs; boundary value analysis tests at and around each edge.
  • Test doubles (dummies, stubs, fakes, spies and mocks) isolate units from slow or unpredictable dependencies; dependency injection makes them possible.
  • Test-driven development repeats red, green, refactor in short cycles, keeping designs testable.
  • Good tests are fast, independent, repeatable, self-validating and timely, and verify behavior rather than implementation details.
Fault
A defect in code that can cause a failure; also called a bug.
Failure
An observable deviation of actual from expected behavior.
Test oracle
A mechanism for deciding whether a test outcome is correct.
Unit test
A test of a single function, class or small module in isolation.
Equivalence partitioning
Dividing inputs into classes treated alike and testing one representative of each.
Boundary value analysis
Testing values at and adjacent to the edges of partitions.
Test double
An object that stands in for a real dependency in a test.
Mock
A test double that records calls and lets the test verify expected interactions.
Fake
A working, simplified implementation of a dependency, such as an in-memory repository.
Test-driven development
Writing a failing test before the code that makes it pass, then refactoring.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. A developer misreads "under 24 hours" as "24 hours or less". Name the error, the fault and a possible failure.

    Show answer

    Error: the misreading. Fault: code using <= 24. Failure: a cancellation exactly 24 hours ahead is charged $15 instead of $5.

  2. List the boundary values for an age field valid from 16 to 99 inclusive.

    Show answer

    15, 16, 17 and 98, 99, 100.

  3. Which kind of test double is an in-memory list pretending to be a shift database?

    Show answer

    A fake.

  4. Why should a unit test not call datetime.now() through the code under test?

    Show answer

    The result changes over time, making the test non-repeatable; inject a clock and stub it.

  5. Write a pytest test that checks a function raises ValueError for an empty name.

    Show answer

    def test_empty_name_rejected(): with pytest.raises(ValueError): register("")

  6. What does the red step of TDD prove?

    Show answer

    That the test can fail, so it is actually testing something, and that the behavior is not yet implemented.

  7. Give two reasons the test pyramid has few end-to-end tests.

    Show answer

    They are slow and brittle, and failures are hard to diagnose.

  8. What is a flaky test and why is it harmful?

    Show answer

    A test that passes and fails without code changes; people learn to ignore failures, so real ones are missed.

  9. Explain the difference between classicist and mockist testing in one sentence each.

    Show answer

    Classicist: use real objects where practical and check resulting state. Mockist: use mocks to specify and verify interactions between objects.

  10. What does FIRST stand for?

    Show answer

    Fast, Independent, Repeatable, Self-validating, Timely.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz