Skip to content
VLSI Mentor

Python Part II · Module 33 · Testing and Debugging Automation

Known Input, Expected Output: Testing a Parser

The technique this module is built on fits in three lines and the canonical annotation is those three lines. It is also, as written, a test that tells you less than it could when it fails — and since a test's entire value is realised at the moment it fails, that is worth more attention than it usually gets. This chapter measures what three different spellings of the same assertion report when the parser breaks, and then looks at how many facts a single five-line fixture is actually capable of pinning down.

Intermediate20 min readPythonPart IIAutomationTestingParsers

Part II · Module 33 · Chapter 33.3 · Known Input, Expected Output: Testing a Parser

1. Three Lines

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
STEPS = [
    ("a fixed input", "SAMPLE", "a string literal, checked into the repo"),
    ("the result you expect", "errors == 2, status == FAIL", "written by a human"),
    ("an assertion", "assert", "fails loudly when the behaviour changes"),
]

print(f"{'step':<24} {'in the annotation':<30} what it is")
for step, where, what in STEPS:
    print(f"{step:<24} {where:<30} {what}")

print()
print("lines of test code:", 3)
print("the second step is the only one a machine cannot supply:", True)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
step                     in the annotation              what it is
a fixed input            SAMPLE                         a string literal, checked into the repo
the result you expect    errors == 2, status == FAIL    written by a human
an assertion             assert                         fails loudly when the behaviour changes

lines of test code: 3
the second step is the only one a machine cannot supply: True

The middle step is the whole value. A fixed input is cheap and an assertion is syntax; the expected result is a human claim about correct behaviour, and that claim is what the test preserves after everybody who understood the parser has moved on.

2. The Annotation

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
def test_counts():
    r = parse_sim_log_text(SAMPLE)
    assert r["errors"] == 2 and r["status"] == "FAIL"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
def parse_sim_log_text(text):
    lines = text.splitlines()
    errors = sum(1 for l in lines if l.startswith("UVM_ERROR"))
    fatals = sum(1 for l in lines if l.startswith("UVM_FATAL"))
    finished = "TEST DONE" in text
    first = next((l for l in lines if l.startswith(("UVM_ERROR", "UVM_FATAL"))), None)
    if fatals or errors:
        status = "FAIL"
    elif not finished:
        status = "NO_FINISH"
    else:
        status = "PASS"
    return {"errors": errors, "fatals": fatals, "finished": finished,
            "first_failure": first, "status": status}


SAMPLE = ("UVM_INFO tb.sv(42) @ 0: reset released\n"
          "UVM_ERROR drv.sv(88) @ 141900: bad handshake\n"
          "UVM_ERROR drv.sv(91) @ 158200: bad handshake\n"
          "UVM_INFO tb.sv(90) @ 158300: finishing\n"
          "TEST DONE\n")


def test_counts():
    r = parse_sim_log_text(SAMPLE)
    assert r["errors"] == 2 and r["status"] == "FAIL"


test_counts()
print("test_counts() passed")
print()
print("the parsed record:")
for k, v in parse_sim_log_text(SAMPLE).items():
    print(f"  {k:<14} {v!r}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
test_counts() passed

the parsed record:
  errors         2
  fatals         0
  finished       True
  first_failure  'UVM_ERROR drv.sv(88) @ 141900: bad handshake'
  status         'FAIL'

It passes, and it is a genuine test — a real parser over a real log shape with a human-written expectation. The next section breaks the parser two different ways and watches what the test says.

3. assert A and B Reports Neither Side

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
def make_parser(error_prefix="UVM_ERROR", fail_on_errors=True):
    """A parser with two independently breakable parts."""
    def parse(text):
        lines = text.splitlines()
        errors = sum(1 for l in lines if l.startswith(error_prefix))
        fatals = sum(1 for l in lines if l.startswith("UVM_FATAL"))
        finished = "TEST DONE" in text
        if fatals or (errors and fail_on_errors):
            status = "FAIL"
        elif not finished:
            status = "NO_FINISH"
        else:
            status = "PASS"
        return {"errors": errors, "status": status}
    return parse


SAMPLE = ("UVM_INFO started\n"
          "UVM_ERROR drv.sv(88): bad handshake\n"
          "UVM_ERROR drv.sv(91): bad handshake\n"
          "TEST DONE\n")

BREAKAGES = {
    "correct": make_parser(),
    "counts with the wrong prefix": make_parser(error_prefix="UVM_"),
    "errors no longer mean FAIL": make_parser(fail_on_errors=False),
}

for label, parse in BREAKAGES.items():
    r = parse(SAMPLE)
    try:
        assert r["errors"] == 2 and r["status"] == "FAIL"
        print(f"{label:<30} PASS")
    except AssertionError as exc:
        print(f"{label:<30} FAIL  message={str(exc)!r}  "
              f"(actual errors={r['errors']}, status={r['status']!r})")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
correct                        PASS
counts with the wrong prefix   FAIL  message=''  (actual errors=3, status='FAIL')
errors no longer mean FAIL     FAIL  message=''  (actual errors=2, status='PASS')

Two completely different defects and an identical, empty failure message.

One breakage counted three errors because the prefix matched UVM_INFO too; the other stopped treating errors as failures. A reader who sees AssertionError with no message has to go and run the parser by hand to find out which — and the information was available at the moment the test failed.

The and is the cause. Python evaluates it to a single boolean before assert ever sees it, so there is nothing left to report:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
r = {"errors": 3, "status": "FAIL"}

combined = r["errors"] == 2 and r["status"] == "FAIL"
print("what assert receives:", combined)
print("its type            :", type(combined).__name__)
print("information about which side failed:", "none — it was discarded")
print()
print("the two sides separately:")
print("  errors == 2 :", r["errors"] == 2)
print("  status == FAIL:", r["status"] == "FAIL")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
what assert receives: False
its type            : bool
information about which side failed: none — it was discarded

the two sides separately:
  errors == 2 : False
  status == FAIL: True

assert is handed False. It cannot report what it was not given.

4. Three Spellings, Measured

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import io
import unittest


def make_parser(error_prefix="UVM_ERROR", fail_on_errors=True):
    def parse(text):
        lines = text.splitlines()
        errors = sum(1 for l in lines if l.startswith(error_prefix))
        fatals = sum(1 for l in lines if l.startswith("UVM_FATAL"))
        finished = "TEST DONE" in text
        if fatals or (errors and fail_on_errors):
            status = "FAIL"
        elif not finished:
            status = "NO_FINISH"
        else:
            status = "PASS"
        return {"errors": errors, "status": status}
    return parse


SAMPLE = ("UVM_INFO started\nUVM_ERROR a\nUVM_ERROR b\nTEST DONE\n")
BROKEN = make_parser(error_prefix="UVM_")


def form_and(parse):
    r = parse(SAMPLE)
    assert r["errors"] == 2 and r["status"] == "FAIL"


def form_two_asserts(parse):
    r = parse(SAMPLE)
    assert r["errors"] == 2, f"errors: expected 2, got {r['errors']}"
    assert r["status"] == "FAIL", f"status: expected FAIL, got {r['status']}"


class WithAssertEqual(unittest.TestCase):
    parse = staticmethod(BROKEN)

    def test_errors(self):
        self.assertEqual(self.parse(SAMPLE)["errors"], 2)

    def test_status(self):
        self.assertEqual(self.parse(SAMPLE)["status"], "FAIL")


print("--- form 1: one assert with `and`")
try:
    form_and(BROKEN)
except AssertionError as exc:
    print("   AssertionError:", repr(str(exc)))

print("--- form 2: two asserts with messages")
try:
    form_two_asserts(BROKEN)
except AssertionError as exc:
    print("   AssertionError:", str(exc))

print("--- form 3: assertEqual")
suite = unittest.TestLoader().loadTestsFromTestCase(WithAssertEqual)
res = unittest.TextTestRunner(stream=io.StringIO(), verbosity=0).run(suite)
print("   tests run:", res.testsRun, " failures:", len(res.failures))
for test, trace in res.failures:
    print("  ", test.id().split(".")[-1], "->",
          [l for l in trace.strip().splitlines() if "AssertionError" in l][0].strip())
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
--- form 1: one assert with `and`
   AssertionError: ''
--- form 2: two asserts with messages
   AssertionError: errors: expected 2, got 3
--- form 3: assertEqual
   tests run: 2  failures: 1
   test_errors -> AssertionError: 3 != 2

Three spellings of the same claim, and three different amounts of information on failure.

Form 1 says a test failed. Form 2 names the field and both values, because the message was written by hand. Form 3 names the field by the test's name, gives both values automatically, and — the part the first two cannot do — keeps running: test_status still executed and still passed, so the failure is localised to one of the two facts rather than stopping the method.

That last property is the argument for one fact per test rather than one test per function:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
FORMS = [
    ("assert A and B", 1, "nothing", "no"),
    ("assert A, msg; assert B, msg", 1, "the field and values", "no"),
    ("one assertEqual per test method", 2, "the field and values", "yes"),
]

print(f"{'form':<34} {'tests':>6} {'reports':<24} other facts still checked")
for form, n, reports, keeps in FORMS:
    print(f"{form:<34} {n:>6} {reports:<24} {keeps}")

print()
print("the cost of form 3:", "one method per fact")
print("the benefit        :", "a failing suite names every broken fact, not the first")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
form                                tests reports                  other facts still checked
assert A and B                          1 nothing                  no
assert A, msg; assert B, msg            1 the field and values     no
one assertEqual per test method         2 the field and values     yes

the cost of form 3: one method per fact
the benefit        : a failing suite names every broken fact, not the first

5. How Many Facts a Fixture Pins

The annotation asserts two things. The fixture supports more:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
def parse_sim_log_text(text):
    lines = text.splitlines()
    errors = sum(1 for l in lines if l.startswith("UVM_ERROR"))
    fatals = sum(1 for l in lines if l.startswith("UVM_FATAL"))
    finished = "TEST DONE" in text
    first = next((l for l in lines if l.startswith(("UVM_ERROR", "UVM_FATAL"))), None)
    known = ("UVM_INFO", "UVM_ERROR", "UVM_FATAL", "TEST DONE")
    unparsed = sum(1 for l in lines if l.strip() and not l.startswith(known))
    status = "FAIL" if (errors or fatals) else ("PASS" if finished else "NO_FINISH")
    return {"errors": errors, "fatals": fatals, "finished": finished,
            "first_failure": first, "unparsed": unparsed, "status": status}


SAMPLE = ("UVM_INFO tb.sv(42) @ 0: reset released\n"
          "UVM_ERROR drv.sv(88) @ 141900: bad handshake\n"
          "UVM_ERROR drv.sv(91) @ 158200: bad handshake\n"
          "Simulation complete via $finish\n"
          "TEST DONE\n")

r = parse_sim_log_text(SAMPLE)
FACTS = [
    ("errors", 2, "the count"),
    ("fatals", 0, "that fatals and errors are counted separately"),
    ("finished", True, "that the end banner was seen"),
    ("status", "FAIL", "the classification derived from the above"),
    ("first_failure", SAMPLE.splitlines()[1], "which line is reported first"),
    ("unparsed", 1, "that one line matched no known prefix"),
]

print(f"{'field':<15} {'expected':<46} pins")
for field, expected, why in FACTS:
    ok = r[field] == expected
    shown = repr(expected)
    print(f"{field:<15} {shown[:44]:<46} {why if ok else 'MISMATCH'}")

print()
print("facts this one fixture can pin :", len(FACTS))
print("facts the annotation asserts   :", 2)
print("lines in the fixture           :", len(SAMPLE.strip().splitlines()))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
field           expected                                       pins
errors          2                                              the count
fatals          0                                              that fatals and errors are counted separately
finished        True                                           that the end banner was seen
status          'FAIL'                                         the classification derived from the above
first_failure   'UVM_ERROR drv.sv(88) @ 141900: bad handshak   which line is reported first
unparsed        1                                              that one line matched no known prefix

facts this one fixture can pin : 6
facts the annotation asserts   : 2
lines in the fixture           : 5

Six facts from a five-line fixture, and the annotation uses two. The other four are free — the fixture is already written, the parser already computes them, and each assertion is one line.

Two of them are worth singling out. fatals == 0 pins that errors and fatals are counted separately, which is the distinction Chapter 20.7 established and which a single combined counter would silently lose. And unparsed == 1 is Chapter 20.6's counter, which is the only assertion here that would catch a tool upgrade introducing a new message prefix.

6. One Table, Many Cases

Writing a method per case gets tedious before it gets thorough, and the stdlib has an answer:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import io
import unittest


def classify(text):
    if not text.strip():
        return "EMPTY_LOG"
    if "UVM_FATAL" in text:
        return "FAIL"
    if "TEST DONE" not in text:
        return "NO_FINISH"
    if "UVM_ERROR" in text:
        return "FAIL"
    return "PASS"


CASES = [
    ("clean", "UVM_INFO ok\nTEST DONE\n", "PASS"),
    ("one error", "UVM_ERROR x\nTEST DONE\n", "FAIL"),
    ("fatal, no banner", "UVM_FATAL boom\n", "FAIL"),
    ("truncated", "UVM_INFO started\n", "NO_FINISH"),
    ("empty", "", "EMPTY_LOG"),
    ("whitespace only", "   \n\n", "EMPTY_LOG"),
]


class BareLoop(unittest.TestCase):
    def test_all(self):
        for name, text, expected in CASES:
            self.assertEqual(classify(text), expected, name)


class WithSubTest(unittest.TestCase):
    def test_all(self):
        for name, text, expected in CASES:
            with self.subTest(case=name):
                self.assertEqual(classify(text), expected)


def run(case):
    suite = unittest.TestLoader().loadTestsFromTestCase(case)
    res = unittest.TextTestRunner(stream=io.StringIO(), verbosity=0).run(suite)
    return res


print("with the correct classifier:")
for cls in (BareLoop, WithSubTest):
    res = run(cls)
    print(f"  {cls.__name__:<12} run={res.testsRun} failures={len(res.failures)}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
with the correct classifier:
  BareLoop     run=1 failures=0
  WithSubTest  run=1 failures=0

Both pass. Now break the classifier so that three of the six cases are wrong:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import io
import unittest


def classify(text):
    """Broken: the empty and truncated cases are gone."""
    if "UVM_FATAL" in text:
        return "FAIL"
    if "UVM_ERROR" in text:
        return "FAIL"
    return "PASS"


CASES = [
    ("clean", "UVM_INFO ok\nTEST DONE\n", "PASS"),
    ("one error", "UVM_ERROR x\nTEST DONE\n", "FAIL"),
    ("fatal, no banner", "UVM_FATAL boom\n", "FAIL"),
    ("truncated", "UVM_INFO started\n", "NO_FINISH"),
    ("empty", "", "EMPTY_LOG"),
    ("whitespace only", "   \n\n", "EMPTY_LOG"),
]


class BareLoop(unittest.TestCase):
    def test_all(self):
        for name, text, expected in CASES:
            self.assertEqual(classify(text), expected, name)


class WithSubTest(unittest.TestCase):
    def test_all(self):
        for name, text, expected in CASES:
            with self.subTest(case=name):
                self.assertEqual(classify(text), expected)


def failures_of(case):
    suite = unittest.TestLoader().loadTestsFromTestCase(case)
    res = unittest.TextTestRunner(stream=io.StringIO(), verbosity=0).run(suite)
    names = []
    for test, trace in res.failures:
        tag = [l for l in trace.splitlines() if "AssertionError" in l]
        names.append(tag[0].strip() if tag else "?")
    return len(res.failures), names


n1, m1 = failures_of(BareLoop)
n2, m2 = failures_of(WithSubTest)

print("cases that should fail:", 3)
print()
print(f"BareLoop    reported {n1} failure(s)")
for m in m1:
    print("   ", m[:58])
print(f"WithSubTest reported {n2} failure(s)")
for m in m2:
    print("   ", m[:58])
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
cases that should fail: 3

BareLoop    reported 1 failure(s)
    AssertionError: 'PASS' != 'NO_FINISH'
WithSubTest reported 3 failure(s)
    AssertionError: 'PASS' != 'NO_FINISH'
    AssertionError: 'PASS' != 'EMPTY_LOG'
    AssertionError: 'PASS' != 'EMPTY_LOG'

One reported against three. The bare loop stops at the first mismatch, so a reader fixes truncated, re-runs, discovers empty, fixes that, re-runs, discovers whitespace only — three cycles for information that was available in the first run.

subTest makes each row an independently reported case while still being one method, which is the same property Section 4 wanted from one-assertion-per-method, without a method per row.

7. A Test's Name Is Its First Diagnostic

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
NAMES = [
    ("test_counts", "names what it calls", "poor"),
    ("test_parse", "names the function", "poor"),
    ("test_two_errors_gives_fail", "names the behaviour", "good"),
    ("test_truncated_log_is_no_finish", "names the behaviour", "good"),
    ("test_unparsed_line_is_counted", "names the behaviour", "good"),
]

print(f"{'name':<36} {'what it tells a reader':<26} verdict")
for name, tells, verdict in NAMES:
    print(f"{name:<36} {tells:<26} {verdict}")

print()
print("when a suite fails, the reader sees:", "the name, before anything else")
print("so the name should state:", "the expectation, not the call")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
name                                 what it tells a reader     verdict
test_counts                          names what it calls        poor
test_parse                           names the function         poor
test_two_errors_gives_fail           names the behaviour        good
test_truncated_log_is_no_finish      names the behaviour        good
test_unparsed_line_is_counted        names the behaviour        good

when a suite fails, the reader sees: the name, before anything else
so the name should state: the expectation, not the call

The annotation's test_counts is the common case and it is the weaker choice: it says which function is involved and nothing about what should be true. test_two_errors_gives_fail fails with a name that is already half the diagnosis — and combined with assertEqual's 3 != 2, the reader knows what broke before opening the file.

8. The Suite, Assembled

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import io
import unittest


def parse_sim_log_text(text):
    lines = text.splitlines()
    errors = sum(1 for l in lines if l.startswith("UVM_ERROR"))
    fatals = sum(1 for l in lines if l.startswith("UVM_FATAL"))
    finished = "TEST DONE" in text
    first = next((l for l in lines
                  if l.startswith(("UVM_ERROR", "UVM_FATAL"))), None)
    known = ("UVM_INFO", "UVM_ERROR", "UVM_FATAL", "TEST DONE")
    unparsed = sum(1 for l in lines if l.strip() and not l.startswith(known))
    if not text.strip():
        status = "EMPTY_LOG"
    elif errors or fatals:
        status = "FAIL"
    elif not finished:
        status = "NO_FINISH"
    else:
        status = "PASS"
    return {"errors": errors, "fatals": fatals, "finished": finished,
            "first_failure": first, "unparsed": unparsed, "status": status}


TWO_ERRORS = ("UVM_INFO tb.sv(42) @ 0: reset released\n"
              "UVM_ERROR drv.sv(88) @ 141900: bad handshake\n"
              "UVM_ERROR drv.sv(91) @ 158200: bad handshake\n"
              "Simulation complete via $finish\n"
              "TEST DONE\n")


class TwoErrorLog(unittest.TestCase):
    """Every fact the TWO_ERRORS fixture pins down."""

    def setUp(self):
        self.r = parse_sim_log_text(TWO_ERRORS)

    def test_two_errors_are_counted(self):
        self.assertEqual(self.r["errors"], 2)

    def test_fatals_counted_separately(self):
        self.assertEqual(self.r["fatals"], 0)

    def test_end_banner_seen(self):
        self.assertTrue(self.r["finished"])

    def test_two_errors_gives_fail(self):
        self.assertEqual(self.r["status"], "FAIL")

    def test_first_failure_is_the_earliest(self):
        self.assertEqual(self.r["first_failure"],
                         "UVM_ERROR drv.sv(88) @ 141900: bad handshake")

    def test_unknown_line_is_counted_unparsed(self):
        self.assertEqual(self.r["unparsed"], 1)


STATUS_CASES = [
    ("clean", "UVM_INFO ok\nTEST DONE\n", "PASS"),
    ("one error", "UVM_ERROR x\nTEST DONE\n", "FAIL"),
    ("fatal without a banner", "UVM_FATAL boom\n", "FAIL"),
    ("truncated", "UVM_INFO started\n", "NO_FINISH"),
    ("empty", "", "EMPTY_LOG"),
    ("whitespace only", "   \n\n", "EMPTY_LOG"),
]


class StatusTable(unittest.TestCase):
    def test_every_status(self):
        for name, text, expected in STATUS_CASES:
            with self.subTest(case=name):
                self.assertEqual(parse_sim_log_text(text)["status"], expected)


def run(*cases):
    loader = unittest.TestLoader()
    suite = unittest.TestSuite(loader.loadTestsFromTestCase(c) for c in cases)
    res = unittest.TextTestRunner(stream=io.StringIO(), verbosity=0).run(suite)
    return {"run": res.testsRun, "failures": len(res.failures),
            "errors": len(res.errors), "ok": res.wasSuccessful()}


print("suite:", run(TwoErrorLog, StatusTable))
print()
print("test methods        :", 7)
print("facts asserted      :", 6 + len(STATUS_CASES))
print("fixtures            :", 1 + len(STATUS_CASES))
print("lines of fixture    :", len(TWO_ERRORS.strip().splitlines()))
print("frameworks installed:", "the standard library only")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
suite: {'run': 7, 'failures': 0, 'errors': 0, 'ok': True}

test methods        : 7
facts asserted      : 12
fixtures            : 7
lines of fixture    : 5
frameworks installed: the standard library only

Seven methods asserting twelve facts, with every status outcome covered and every field of the record pinned. setUp parses the fixture once per test, which is the stdlib's way of keeping the three-line technique visible: fixed input, expected result, assertion.

Note what this suite does not yet do. It uses invented log text rather than real extracts, it never feeds the parser something malformed, and nothing has checked that the suite is capable of failing. Those are Chapters 33.4, 33.5 and 33.9, in that order.

9. Asserting Too Much

Section 5 counted how many facts a fixture pins. The opposite failure is pinning facts nobody asked for, and it has a measurable cost the first time the parser's return value grows a field:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import io
import unittest


def classify_v1(text):
    errors = sum(1 for l in text.splitlines() if l.startswith("UVM_ERROR"))
    return {"errors": errors, "status": "FAIL" if errors else "PASS"}


def classify_v2(text):
    """Identical answers, plus one field a later chapter wanted."""
    d = classify_v1(text)
    d["parser_version"] = 2
    return d


LOG = "UVM_ERROR a.sv(1) x\n"


def suites(fn):
    class WholeDict(unittest.TestCase):
        def test_failing_log(self):
            self.assertEqual(fn(LOG), {"errors": 1, "status": "FAIL"})

        def test_clean_log(self):
            self.assertEqual(fn(""), {"errors": 0, "status": "PASS"})

    class PerField(unittest.TestCase):
        def test_error_count(self):
            self.assertEqual(fn(LOG)["errors"], 1)

        def test_status(self):
            self.assertEqual(fn(LOG)["status"], "FAIL")

        def test_clean_status(self):
            self.assertEqual(fn("")["status"], "PASS")

    return WholeDict, PerField


def run(case):
    suite = unittest.TestLoader().loadTestsFromTestCase(case)
    res = unittest.TextTestRunner(stream=io.StringIO(), verbosity=0).run(suite)
    return f"{res.testsRun} run, {len(res.failures)} failed"


for label, fn in (("v1", classify_v1), ("v2 — one field added", classify_v2)):
    whole, field = suites(fn)
    print(f"  {label:<22} whole-dict: {run(whole):<16} per-field: {run(field)}")

print()
print("behaviour that changed between v1 and v2:", "none")
print("whole-dict tests that broke             :", 2)
print("per-field tests that broke              :", 0)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  v1                     whole-dict: 2 run, 0 failed  per-field: 3 run, 0 failed
  v2 — one field added   whole-dict: 2 run, 2 failed  per-field: 3 run, 0 failed

behaviour that changed between v1 and v2: none
whole-dict tests that broke             : 2
per-field tests that broke              : 0

Every whole-dict test broke and no behaviour changed. classify_v2 returns the same error count and the same status for every input; it adds a field. The whole-dict assertions fail because assertEqual on dictionaries is exact, so they were never asserting "the count is 1 and the status is FAIL" — they were asserting "the return value has exactly these two keys and nothing else."

That is a promise about the function's shape, made accidentally, by a test whose author was thinking about its values. And the cost is paid later, by whoever adds the field: a one-line change that breaks a dozen tests teaches a team that tests are an obstacle.

The nuance is that over-specification is sometimes exactly right:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
CASES = [
    ("the count for a known log", "one field", "assertEqual(r['errors'], 1)"),
    ("the status for a known log", "one field", "assertEqual(r['status'], 'FAIL')"),
    ("nothing is reported for a clean log", "whole dict",
     "assertEqual(r, {...}) — absence IS the claim"),
    ("the exact keys the API promises", "whole dict",
     "assertEqual(set(r), {...}) — one test, deliberately"),
    ("every field, in every test", "whole dict", "no — this is the mistake"),
]

print(f"{'what you are claiming':<38} {'assert':<12} how")
for claim, kind, how in CASES:
    print(f"{claim:<38} {kind:<12} {how}")

print()
print("whole-dict assertions that are right:", 2)
print("the test for which to use            :",
      "is the ABSENCE of other fields part of the claim?")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
what you are claiming                  assert       how
the count for a known log              one field    assertEqual(r['errors'], 1)
the status for a known log             one field    assertEqual(r['status'], 'FAIL')
nothing is reported for a clean log    whole dict   assertEqual(r, {...}) — absence IS the claim
the exact keys the API promises        whole dict   assertEqual(set(r), {...}) — one test, deliberately
every field, in every test             whole dict   no — this is the mistake

whole-dict assertions that are right: 2
the test for which to use            : is the ABSENCE of other fields part of the claim?

Two of five rows justify a whole-dict assertion, and both have the same character: the absence of something is part of what is being claimed. A clean log must report no errors and no fatals and no warnings, so the complete dictionary is the claim — which is why Chapter 33.5's negative tests use that form deliberately.

Row four is the useful pattern that most suites lack. One test asserting set(result) == {"errors", "fatals", "warnings", "status"} pins the API's shape in exactly one place, so adding a field breaks one test whose name says it is about the shape — and whoever adds the field updates it, reads the promise, and moves on. The alternative is the same promise scattered across twelve value tests that break for a reason their names do not mention.

10. Predict, Run, Observe

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
r = {"errors": 3, "status": "FAIL"}

# A
try:
    assert r["errors"] == 2 and r["status"] == "FAIL"
except AssertionError as exc:
    print("A:", repr(str(exc)))

# B
try:
    assert r["errors"] == 2, f"expected 2, got {r['errors']}"
except AssertionError as exc:
    print("B:", str(exc))

# C
print("C:", r["errors"] == 2 and r["status"] == "FAIL")

# D
print("D:", bool("") , bool("   ".strip()))

The verified output:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A: ''
B: expected 2, got 3
C: False
D: False False

A — the annotation's form, on a broken parser. An empty message.

B — one extra argument, and the failure names both values.

C — what assert actually received: a bare False.

D — why if not text.strip() rather than if not text is the right empty check: a whitespace-only log is empty in every sense that matters, and Chapter 33.5 is about inputs like it.

11. Exercises

Exercise 1 — three lines. Write the fixed input, expected result and assertion for one of your own parsers.

Exercise 2 — the human claim. Say which of the three steps a machine could generate and which it could not.

Exercise 3 — break it two ways. Make two different changes to your parser and check whether your test distinguishes them.

Exercise 4 — the empty message. Run an assert A and B against a broken parser and record what it prints.

Exercise 5 — what assert receives. Explain in one sentence why the and discards the information.

Exercise 6 — add the message. Convert one bare assert to assert cond, msg and show the improved failure.

Exercise 7 — assertEqual. Convert the same check and compare the failure text.

Exercise 8 — independence. Demonstrate that one failing assertion in a method hides the next.

Exercise 9 — two jobs. Say where you would use assert msg and where one-assertion-per-method, and why.

Exercise 10 — count your facts. For one existing fixture, list every fact it could pin.

Exercise 11 — the free four. Add the assertions your fixture supports but your tests omit.

Exercise 12 — separate counters. Confirm your parser counts errors and fatals separately, with a test.

Exercise 13 — the unparsed counter. Add it and the assertion that would catch a new message prefix.

Exercise 14 — a table. Convert four of your tests into one table.

Exercise 15 — the bare loop. Break three rows and confirm your loop reports one.

Exercise 16 — subTest. Wrap the loop body and confirm all three are reported.

Exercise 17 — three cycles into one. Explain the cost of the bare loop in terms of edit-run cycles.

Exercise 18 — rename your tests. Rewrite five test names to state the expectation rather than the call.

Exercise 19 — the first diagnostic. Run a failing suite and say how much you learned from the name alone.

Exercise 20 — setUp. Use it to parse one fixture once per test, and say what it keeps visible.

Exercise 21 — every outcome. List your classifier's outcomes and confirm the table covers each.

Exercise 22 — twelve facts, seven methods. Report those two numbers for your own suite.

Exercise 23 — what is still missing. Say which of real fixtures, malformed input and a self-check your suite lacks.

Exercise 24 — empty versus whitespace. Check whether your empty-log test uses not text or not text.strip().

Exercise 25 — add a field. Add a field to one of your parser's return values and count the tests that break.

Exercise 26 — shape or values. For each broken test, say whether its name is about the shape or the values.

Exercise 27 — the exact-keys assertion. Read one of your assertEqual(r, {...}) tests aloud as a promise about shape.

Exercise 28 — the obstacle. Describe what a team learns when a one-line change breaks a dozen tests.

Exercise 29 — when absence is the claim. Find a test of yours where the complete dictionary is genuinely right.

Exercise 30 — one place for the shape. Add a single set(result) == {...} test and loosen the value tests.

Exercise 31 — the name says why. Name that test so the next person understands why it broke.

12. Summary

  • The technique is three lines — a fixed input, an expected result, an assertion — and the middle one is the only part a machine cannot supply.
  • assert A and B reports neither side. Two completely different parser defects produced an identical, empty AssertionError.
  • Python evaluates the and to a bare False before assert sees it, so there is nothing left to report.
  • Three spellings give three amounts of information: nothing, the field and values, or the field and values plus the other facts still being checked.
  • assert cond, msg is a good test and belongs inside the flow too — Chapter 33.11 uses it as a pre-flight check; one assertion per method belongs in the suite.
  • A five-line fixture pinned six facts and the annotation asserted two. The other four are one line each and already computed.
  • fatals == 0 pins that errors and fatals are counted separately, and unparsed == 1 is the only assertion that would catch a tool adding a new message prefix.
  • A bare loop over a table reported one failure where three existed, costing three edit-run cycles for information available in the first.
  • subTest makes each row independently reported while staying one method.
  • A test's name is its first diagnostic. test_counts names the call; test_two_errors_gives_fail names the expectation.
  • Seven methods asserted twelve facts over one real record and every status outcome, using only the standard library.
  • Use not text.strip(), not not text — a whitespace-only log is empty in every sense that matters.
  • assertEqual on a dictionary is exact, so a whole-dict assertion promises the return value's shape, not only its values.
  • Adding a field broke every whole-dict test and no behaviour changed — the cost is paid by whoever extends the parser.
  • The test is whether the absence of other fields is part of the claim. For a clean log it is; for a count it is not.
  • Pin the API's shape in exactly one test with set(result) == {...}, so a new field breaks one test whose name explains why.

The next chapter replaces the invented fixture with real extracts, and finds the way a fixture directory can make a suite pass while testing nothing.

Where this fits

Part of the Python curriculum.