---
title: 'Test Frameworks'
source: 'https://academia.sh/en/courses/python-projects/test-frameworks'
course: 'Python Projects: Packaging and Testing'
language: en
updated: '2026-08-17T18:10:32+00:00'
license: 'CC BY-SA 4.0'
---

# Test Frameworks

A framework does four jobs, and only one of them produces the verdict: the same three cases give 1 distinct outcome across two writing styles, while the same file gives 3 distinct outcomes in found-test terms across three discovery rules, and the report stays the same across all three.

The previous lesson made the environment and dependencies singular: the
interpreter version got pinned, the lock file wrote down the
resolution's decision, and the same declaration now gives a single
version set. No question is left to ask on the setup side. But the
setup becoming singular does not mean the code running on top of it
gives the same verdict every time.

This lesson's question: does the code itself give the same result on
every run, and who tells us that? What tells us is a **test
framework**. A framework is not a product, it is a **class of tool**; no
product name appears in this lesson. A framework's jobs get modeled
within the lesson, while the framework the standard library provides
gets used directly.

## The Framework's Four Jobs

A test framework looks like it does one job — "runs the tests" — but it
actually does four separate jobs, and only one of them decides the
outcome.

**Discovery.** Decides which callables count as tests. This decision
rests on a naming convention, a directory pattern, or a marker, and
**does not look at the test's content**. The rule's source can be the
framework's default, or the project's setting; either way, the rule
sits outside the test.

**Run.** Calls every discovered test, decides the order, and inserts
setup and teardown steps in between when needed. Order itself is a
decision too, and it is what the next lesson measures.

**Assertion.** Compares the expected against the actual inside the
test. In Python, the plainest form of this is the `assert` statement;
when the statement comes out false, it raises `AssertionError`, and the
framework counts this exception as a failed test. Using an exception as
flow control was built in the Python Fundamentals course; here the same
mechanism gets used for reporting.

**Report.** Writes how many tests ran, how many passed, and the names
of the ones that failed; the reader takes the verdict from these lines.

Of these four jobs, only **assertion** says anything about correctness.
Discovery, run, and report are **configuration decisions** — and what
this lesson measures is what configuration decisions do to the outcome.

All four sit together, so they look like one job. Yet only assertion is
written in the project's source files; the discovery rule and run order
sit, in most setups, in a separate configuration file or in the run
command's options. Part of the decision is inside the code, part is
outside — and when the outside part changes, no trace stays in the code
files. For this course's question, this split is exactly what is being
sought: **same source, separate configuration, how many distinct
outcomes.**

## The Assertion Needs an Oracle

An assertion compares two things: the value the code produces, against
the **expected** value. Where the expected value comes from is not
visible inside the test; whoever wrote it got it from somewhere. This
source is called an **oracle**, built as a concept in the Quality and
Test Fundamentals course of the Software Quality and Testing
curriculum. Theory is not repeated here; what the oracle **adds to the
measurement** is.

In our shared setup, the oracle is us: we wrote the correct discount
behavior, the clean environment, and the correct version set. This
lesson's test cases, though, are written not from the oracle, but from
the function's **observed behavior**. That is, the expected values were
obtained by writing down whatever the function gives today. A test
written this way verifies that the function's behavior has **not
changed**; it does not verify that the behavior is **correct**. What
this difference costs gets measured in this topic's last lesson; for
now, it is enough to note: **a test passing does not say the expected
value is correct.**

How the assertion is written is a choice too. When the `assert`
statement's sentence comes out false, it raises `AssertionError`, and
the report is left with only "the statement at this line came out
false." The framework's comparison methods, though, capture both values
and write them to the report. This difference shortens diagnosis time
but does not change the verdict; the measurement's upper table shows
exactly this.

## A Test Without an Assertion Passes Silently

The modeled runner's rule is a simple one: call the test, count it as
passed if `AssertionError` does not come. This rule has a direct
consequence — **a test containing no assertion at all always passes.**
The runner does not count the assertion, it counts the exception; if no
assertion is ever written, there is no exception to raise either.

This is not the framework's defect, it is its **limit**. The framework
does not look at the test's body; if it did, it would have to know
which call counts as an assertion, and that knowledge is
application-specific. `helper_amount` in the measurement is a concrete
example: it carries no assertion, so it would count as passed even if
discovered. The same holds for a test whose assertion was accidentally
commented out, or left inside a condition that never gets evaluated.

The consequence: a passing test may have passed for at least **two
separate reasons** — the assertion held, or there was no assertion at
all. The report does not tell the two apart. To tell them apart, the
place to look is not the report, it is the test itself; and a mechanism
that measures this over the code gets built in this topic's last
lesson.

## The Same Three Cases, Two Writing Styles

A unit test has two common writing styles. In the first, every test is
a function, and the assertion is written with the `assert` statement;
the runner calls the function and counts the test as failed if it
catches an exception. In the second, tests are a class's methods, and
the assertion is written with the framework's comparison methods; the
standard library's `unittest` framework uses this style.

The difference between the two is a **writing** difference. The second
style writes both values to the report on a failing assertion; the
first only says the statement came out false. This is a report detail
and does not change the verdict itself — the measurement shows this.

The class-based style has a second contribution: hooks that run before
and after every test can be defined. These hooks take repetition out of
the test and are the next lesson's subject; here, only three plain
tests are written, so no hook gets used, and the two styles genuinely
do the same job. This was necessary for the comparison to be
meaningful: the two styles can only be set side by side once they run
the same three cases with the same setup.

## What the Discovery Rule Decides

The discovery rule decides which callables in a file count as tests.
When the rule changes, the file's content does not change, the **set of
running tests** does. The file in the measurement carries five
callables: three with a `t_` prefix, one with a `check_` prefix, and
one a helper function.

These three rules model a real situation. As a team grows, tests get
written by more than one person, and not everyone carries the same
naming habit; some use a `test_` prefix, some `t_`, some a
`check_`-like prefix. If the discovery rule looks for only one of
these, the others sit in the file, visible to the eye, tracked in
version control — and **never run**.

What matters here: a test that does not run does not count as failed.
The framework never saw it, so it has no place in the report. The
report says "no failures," and this sentence is the same whether five
tests ran or none did. The discovery rule, for this reason, is not a
detail, it is **part of the verdict**.

The measurement's assumptions:

## The Measurement's Assumptions

- **TE1** — The runner is modeled within the lesson: it calls every
  test, counts it as failed if it catches AssertionError, and does
  nothing else. No framework carrying a product name is used.
- **TE2** — The class-based suite is run with the standard library's
  unittest framework; the report stream is swallowed, and only the
  numbers get read.
- **TE3** — The three test cases are the shared setup's test set, and
  the expected values are written from the function's observed
  behavior, not from an oracle.
- **TE4** — The discount function's behavior is not changed; the lesson
  only adds tests and a discovery rule.
- **TE5** — The discovery rule is a name prefix and does not look at
  the test's body. Three rules get tried: the t_ prefix, the test_
  prefix, and the combination of the t_ and check_ prefixes.
- **TE6** — helper_amount in the file is not a test, it is a helper; no
  rule collects it, and if it did, it would count as passed because it
  carries no assertion.
- **TE7** — Distinct outcome count is the number of mutually different
  result tuples observed across a measurement; duration is never
  measured in a run.
- **TE8** — The report carries only the count of failed tests; a test
  that never runs has no name in the report, and the reader of the
  report cannot see this.

## The Measurement

```python
"""A framework does four jobs: discovery, run, assertion, and report. The
runner is modeled within the lesson.

Part 1 - the same three cases, two writing styles.
Part 2 - the same file, three discovery rules.
"""
import io
import unittest


def discount(amount, member, coupon):
    """The shared setup's three-decision function; its behavior is not changed."""
    rate = 0
    if member:
        rate += 10
    if coupon:
        rate += 15
    if rate > 20:
        rate = 20
    return amount - amount * rate // 100


CASES = (("member_only", (100, True, False), 90),
         ("coupon_only", (100, False, True), 85),
         ("both", (100, True, True), 80))


def runner(tests):
    """Modeled runner: calls every test, counts passed and failed."""
    passed, failed = 0, []
    for name, func in tests:
        try:
            func()
        except AssertionError:
            failed.append(name)
        else:
            passed += 1
    return passed, failed


def plain_suite():
    def make(args, expected):
        def test():
            assert discount(*args) == expected
        return test
    return [(name, make(args, exp)) for name, args, exp in CASES]


class ClassSuite(unittest.TestCase):
    def test_member_only(self):
        self.assertEqual(discount(100, True, False), 90)

    def test_coupon_only(self):
        self.assertEqual(discount(100, False, True), 85)

    def test_both(self):
        self.assertEqual(discount(100, True, True), 80)


def class_run():
    suite = unittest.TestLoader().loadTestsFromTestCase(ClassSuite)
    result = unittest.TextTestRunner(stream=io.StringIO(), verbosity=0).run(suite)
    return result.testsRun - len(result.failures) - len(result.errors), len(result.failures)


def t_member_only():
    assert discount(100, True, False) == 90


def t_coupon_only():
    assert discount(100, False, True) == 85


def t_both():
    assert discount(100, True, True) == 80


def check_two_hundred():
    assert discount(200, True, True) == 160


def helper_amount(amount):
    return amount


FILE = {"t_member_only": t_member_only, "t_coupon_only": t_coupon_only,
        "t_both": t_both, "check_two_hundred": check_two_hundred,
        "helper_amount": helper_amount}

RULES = {"t_ prefix": ("t_",), "test_ prefix": ("test_",),
         "t_ or check_": ("t_", "check_")}


def discover(prefix):
    return [(name, f) for name, f in FILE.items() if name.startswith(prefix)]


print(f"{'writing style':<22s} {'tests':>5s} {'passed':>6s} {'failed':>6s}")
plain_passed, plain_failed = runner(plain_suite())
print(f"{'plain function suite':<22s} {len(CASES):5d} {plain_passed:6d} {len(plain_failed):6d}")
class_passed, class_failed = class_run()
print(f"{'class-based suite':<22s} {len(CASES):5d} {class_passed:6d} {class_failed:6d}")
print(f"same three cases, two writing styles: "
      f"{len({(plain_passed, len(plain_failed)), (class_passed, class_failed)})} distinct outcomes")

print()
print(f"{'discovery rule':<18s} {'found':>8s} {'passed':>6s} {'failed':>6s} {'report':>16s}")
found, report = [], []
for name, prefix in RULES.items():
    tests = discover(prefix)
    passed, failed = runner(tests)
    found.append(len(tests))
    report.append(len(failed))
    print(f"{name:<18s} {len(tests):8d} {passed:6d} {len(failed):6d} "
          f"{'failed ' + str(len(failed)):>16s}")
print(f"same file, three discovery rules: found tests {len(set(found))} distinct outcomes, "
      f"report {len(set(report))} distinct outcomes")
print(f"tests that never run under the narrowest rule: {max(found) - min(found)}; "
      f"the report does not say this")
```

```
writing style          tests passed failed
plain function suite       3      3      0
class-based suite          3      3      0
same three cases, two writing styles: 1 distinct outcomes

discovery rule        found passed failed           report
t_ prefix                 3      3      0         failed 0
test_ prefix              0      0      0         failed 0
t_ or check_              4      4      0         failed 0
same file, three discovery rules: found tests 3 distinct outcomes, report 1 distinct outcomes
tests that never run under the narrowest rule: 4; the report does not say this
```

## Reading the Numbers

The upper table gives what the writing style costs: the same three
cases, **1 distinct outcome** across two separate stylings. Both the
plain function suite and the class-based suite pass 3 of 3 tests.
Writing style is a preference, and it does not change the verdict. This
does not mean choosing between frameworks is unimportant — but what the
choice costs is not the outcome, it is the **report's detail**.

The lower table gives what configuration costs, and the number changes.
Same file, three discovery rules: found tests give **3 distinct
outcomes** (3, 0, and 4), the report gives **1 distinct outcome**. In
all three rules, the report says "failed 0."

The second row is this measurement's real finding. The rule looking
for the `test_` prefix finds **no test at all** in this file, zero
tests run, and its report is "failed 0." This report is
**indistinguishable** from the report where three tests passed. A suite
being green is true under two separate conditions: the tests ran and
passed, or no test ran at all.

The third row shows the same fact from the other direction. A test
named `check_two_hundred` sits in the file, its assertion is correct
and it passes — but the default rule, looking only for the `t_`
prefix, never collects it. The difference between the narrowest and
widest rules is **4 tests**, and all of that difference stays outside
the report.

The rule that follows: **a test suite's verdict depends on the
discovery rule as much as on the tests themselves.** The report counts
the tests that ran, not the ones that did not; for this reason, the
report's "failed 0" line is not proof on its own. Proof in full
requires reading the **found test count** too — and, as the measurement
shows, this number can take three separate values without the code
itself ever changing.

Read together, the two tables make the course's measurement axis
visible for the first time here. Two separate questions got asked of
the same input — same three cases, same function, same file. The first
was "how did you write it," and it did not change the outcome: **1
distinct outcome**. The second was "what are you running," and it did
change the outcome: **3 distinct outcomes**. What threatens
reproducibility is not the first, it is the second, because the second
is written not in a file, but in the setting that configures the run,
and it does not get read alongside the code.

This distinction's practical counterpart is clear too. A suite's green
report only carries information once the count of running tests is
known too; a green report read without that number looks the same in a
setup where no test ran at all. For this reason, the running test
count gets kept alongside the report, and its drop counts as an event
as attention-worthy as a failing test.

## Summary

- A test framework does four jobs: discovery, run, assertion, and
  report. Of these, only assertion says anything about correctness; the
  other three are configuration decisions.
- An assertion takes its expected value from an oracle. A test passing
  does not show that expected value is correct.
- The same three cases pass 3/3 with both the plain function suite and
  the class-based suite; the two writing styles give 1 distinct
  outcome, and the difference sits only in the report's detail.
- The same file finds 3, 0, and 4 tests across three discovery rules —
  3 distinct outcomes; the report says "failed 0" in all three and
  stays at 1 distinct outcome.
- A test that never runs does not count as failed; under the narrowest
  rule, 4 tests stay outside the report, and a green report does not
  say this.

## Next Step

The discovery rule decided which tests would run; but the running
tests are not independent one by one. In the shared setup's five-test
suite, one pollutes shared state, and another assumes an unpolluted
state. In a suite like this, which order you call the tests in is a
configuration detail — does that detail change the verdict? The next
lesson runs the same suite in three separate orders, reads the number
of distinct verdicts that come out, and measures two mechanisms that
take repetition out of a test: the **fixture**, which rebuilds shared
state fresh for every test, and **parametrization**, which runs the
same body with more than one input.
