Skip to content
VLSI Mentor

Python Part II · Module 33 · Testing and Debugging Automation

Test the Test: Break the Parser on Purpose

Every chapter so far has written tests and watched them pass. A passing suite is the least informative thing a test run can produce: it is equally consistent with a suite that checks everything and a suite that checks almost nothing, and nothing in the output distinguishes them. This chapter gets a number instead — break the parser on purpose, count how many breakages the suite notices, and read what the survivors say. Two of ours survived a six-test suite, and the two had completely different causes.

Advanced24 min readPythonPart IIAutomationTestingMutation Testing

Part II · Module 33 · Chapter 33.9 · Test the Test: Break the Parser on Purpose

1. A Green Suite Is Not Evidence

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
OBSERVATIONS = [
    ("the suite passes", "the code is right", "or the tests check nothing"),
    ("the suite passes", "the tests are good", "or they assert trivia"),
    ("8 tests pass", "8 behaviours are pinned", "or 8 tests pin the same one"),
    ("coverage is 100%", "every line ran", "lines ran — nothing was checked"),
]

print(f"{'what you see':<20} {'what it suggests':<26} what it is also consistent with")
for seen, suggests, also in OBSERVATIONS:
    print(f"{seen:<20} {suggests:<26} {also}")

print()
print("distinguishable from the output of a run?", False)
print("what would distinguish them:", "a run where the code is WRONG")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
what you see         what it suggests           what it is also consistent with
the suite passes     the code is right          or the tests check nothing
the suite passes     the tests are good         or they assert trivia
8 tests pass         8 behaviours are pinned    or 8 tests pin the same one
coverage is 100%     every line ran             lines ran — nothing was checked

The last row is Chapter 29.1 Section 5's point arriving in a test suite: coverage records that a line executed, and a line can execute inside a test that asserts nothing about it.

So a green run carries no information about the suite. The only experiment that does is the one where the code is broken and you watch what happens.

2. The Annotation

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
# remove the UVM_FATAL branch, then run the suite
# expected: at least one test fails — if none does, fix the tests

Here is the parser the whole chapter measures, and the smallest suite someone might plausibly write for it:

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

PARSER_SRC = '''
import re

PAT = re.compile(r"^(UVM_ERROR|UVM_FATAL|UVM_WARNING)\\s+(\\S+)\\((\\d+)\\)")

def classify(text):
    errors = fatals = warnings = 0
    for line in text.splitlines():
        m = PAT.match(line)
        if not m:
            continue
        sev = m.group(1)
        if sev == "UVM_ERROR":
            errors += 1
        elif sev == "UVM_FATAL":
            fatals += 1
        elif sev == "UVM_WARNING":
            warnings += 1
    if fatals:
        status = "FATAL"
    elif errors:
        status = "FAIL"
    else:
        status = "PASS"
    return {"errors": errors, "fatals": fatals,
            "warnings": warnings, "status": status}
'''

CLEAN = "UVM_INFO top.sv(10) start\nTEST DONE\n"
ONE_ERR = "UVM_INFO a\nUVM_ERROR drv.sv(88) bad\nTEST DONE\n"


def load(src):
    """Exec a parser source string and hand back its namespace."""
    ns = {}
    exec(compile(src, "parser.py", "exec"), ns)
    return ns


def weak_suite(mod):
    class WeakSuite(unittest.TestCase):
        def test_clean_log_passes(self):
            self.assertEqual(mod["classify"](CLEAN)["status"], "PASS")

        def test_one_error_fails(self):
            r = mod["classify"](ONE_ERR)
            self.assertEqual(r["status"], "FAIL")
            self.assertEqual(r["errors"], 1)
    return WeakSuite


def run(case):
    suite = unittest.TestLoader().loadTestsFromTestCase(case)
    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("baseline:", run(weak_suite(load(PARSER_SRC))))

# the annotation's mutation, applied literally
BRANCH = '        elif sev == "UVM_FATAL":\n            fatals += 1\n'
assert BRANCH in PARSER_SRC, "the mutation target must exist"
mutated = PARSER_SRC.replace(BRANCH, "", 1)

print("mutated :", run(weak_suite(load(mutated))))
print()
print("the annotation expected:", "at least one test fails")
print("what happened          :", "no test failed")
print("so the annotation says :", "fix the tests")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
baseline: {'run': 2, 'failures': 0, 'errors': 0, 'ok': True}
mutated : {'run': 2, 'failures': 0, 'errors': 0, 'ok': True}

the annotation expected: at least one test fails
what happened          : no test failed
so the annotation says : fix the tests

The UVM_FATAL branch is gone and the suite is still green. The parser now counts a fatal as nothing at all — the one severity that most reliably means the run is worthless — and two tests pass exactly as before.

That is not a contrived failure. Those two tests are what a careful engineer writes first: one clean log, one log with an error, status and count asserted on both. They are good tests. They just do not touch the branch.

3. The Loop

One mutation, one measurement

A seven-stage mutation testing loop: confirm the baseline suite passes, pick one mutation, apply it to a copy of the source, run the suite, read the result as killed if the suite fails or survived if it passes, classify every survivor as either a test gap or an equivalent mutant, and restore the source before the next mutation.01BASELINE MUST BE GREENa failing baseline scores every mutant killed02PICK ONE MUTATIONone change, not two — §503APPLY IT TO A COPYassert the target string existed04RUN THE SUITEthe same suite, unchanged05SUITE FAILS = KILLEDsuite passes = survivor06CLASSIFY EACH SURVIVORtest gap or equivalent mutant — §707RESTORE, THEN REPEATa left-behind mutation poisons the rest
The baseline must be green before the loop starts (§9), and the mutation must be restored before the next one — a mutation run on a failing baseline scores every mutant as killed and measures nothing.

Step 6 is the one that distinguishes a useful mutation run from a tedious one, and Section 7 is about nothing else. Step 1 is the one that silently invalidates the whole run when skipped, which is Section 9.

4. Eight Mutations

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
MUTATIONS = [
    ("drop the UVM_FATAL branch", "the annotation's own"),
    ("fatals no longer force FATAL", "status logic"),
    ("off-by-one in the error count", "arithmetic"),
    ("anchor removed from the pattern", "regex"),
    ("warnings counted as errors", "severity mapping"),
    ("line number group made optional", "regex"),
    ("PASS and FAIL swapped", "status logic"),
    ("whitespace made optional", "regex"),
]

print(f"{'#':>2}  {'mutation':<34} kind")
for n, (name, kind) in enumerate(MUTATIONS, 1):
    print(f"{n:>2}  {name:<34} {kind}")

print()
from collections import Counter
for kind, n in sorted(Counter(k for _, k in MUTATIONS).items()):
    print(f"  {kind:<18} {n}")
print()
print("chosen to be:", "the mistakes a tired engineer actually makes")
print("NOT chosen to be:", "random character edits")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
 #  mutation                           kind
 1  drop the UVM_FATAL branch          the annotation's own
 2  fatals no longer force FATAL       status logic
 3  off-by-one in the error count      arithmetic
 4  anchor removed from the pattern    regex
 5  warnings counted as errors         severity mapping
 6  line number group made optional    regex
 7  PASS and FAIL swapped              status logic
 8  whitespace made optional           regex

  arithmetic         1
  regex              3
  severity mapping   1
  status logic       2
  the annotation's own 1

chosen to be: the mistakes a tired engineer actually makes
NOT chosen to be: random character edits

That last distinction matters more than it sounds. A tool that mutates random characters produces mostly syntax errors, which every suite "kills" trivially and which teach nothing. These eight are plausible edits — a dropped branch, an off-by-one, a loosened quantifier — so a survivor among them is a gap that a real change could walk through.

5. The Harness

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

PARSER_SRC = '''
import re

PAT = re.compile(r"^(UVM_ERROR|UVM_FATAL|UVM_WARNING)\\s+(\\S+)\\((\\d+)\\)")

def classify(text):
    errors = fatals = warnings = 0
    for line in text.splitlines():
        m = PAT.match(line)
        if not m:
            continue
        sev = m.group(1)
        if sev == "UVM_ERROR":
            errors += 1
        elif sev == "UVM_FATAL":
            fatals += 1
        elif sev == "UVM_WARNING":
            warnings += 1
    if fatals:
        status = "FATAL"
    elif errors:
        status = "FAIL"
    else:
        status = "PASS"
    return {"errors": errors, "fatals": fatals,
            "warnings": warnings, "status": status}
'''

MUTATIONS = [
    ("drop the UVM_FATAL branch",
     '        elif sev == "UVM_FATAL":\n            fatals += 1\n', ''),
    ("fatals no longer force FATAL",
     '    if fatals:\n        status = "FATAL"\n    elif errors:',
     '    if errors:'),
    ("off-by-one in the error count", 'errors += 1', 'errors += 2'),
    ("anchor removed from the pattern", 'r"^(UVM_ERROR', 'r"(UVM_ERROR'),
    ("warnings counted as errors",
     '        elif sev == "UVM_WARNING":\n            warnings += 1',
     '        elif sev == "UVM_WARNING":\n            errors += 1'),
    ("line number group made optional", r'\((\d+)\)', r'(\((\d+)\))?'),
    ("PASS and FAIL swapped",
     '        status = "FAIL"\n    else:\n        status = "PASS"',
     '        status = "PASS"\n    else:\n        status = "FAIL"'),
    ("whitespace made optional", r'\s+(\S+)', r'\s*(\S+)'),
]

print(f"{'#':>2}  {'mutation':<34} target found  one change only")
for n, (name, old, new) in enumerate(MUTATIONS, 1):
    found = old in PARSER_SRC
    count = PARSER_SRC.count(old)
    print(f"{n:>2}  {name:<34} {str(found):<12} {count == 1}")

print()
print("every target string present:",
      all(old in PARSER_SRC for _, old, _ in MUTATIONS))
print("every target unique        :",
      all(PARSER_SRC.count(old) == 1 for _, old, _ in MUTATIONS))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
 #  mutation                           target found  one change only
 1  drop the UVM_FATAL branch          True         True
 2  fatals no longer force FATAL       True         True
 3  off-by-one in the error count      True         True
 4  anchor removed from the pattern    True         True
 5  warnings counted as errors         True         True
 6  line number group made optional    True         True
 7  PASS and FAIL swapped              True         True
 8  whitespace made optional           True         True

every target string present: True
every target unique        : True

Both columns must be True before any number from this harness means anything, and that is not ceremony. A mutation whose target string is absent changes nothing, so the suite passes, so it reports as a survivor — a mutation that was never applied is indistinguishable in the results from one the tests failed to catch. Checking that old in src is the difference between measuring a suite and measuring a typo.

The uniqueness column matters for the same reason in reverse. str.replace with no count replaces every occurrence, so a target appearing twice makes two changes at once and a kill no longer tells you which one the suite caught. Passing 1 as the count and asserting count == 1 keeps one mutation to one edit.

6. Two Suites, The Same Eight Mutations

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

PARSER_SRC = '''
import re

PAT = re.compile(r"^(UVM_ERROR|UVM_FATAL|UVM_WARNING)\\s+(\\S+)\\((\\d+)\\)")

def classify(text):
    errors = fatals = warnings = 0
    for line in text.splitlines():
        m = PAT.match(line)
        if not m:
            continue
        sev = m.group(1)
        if sev == "UVM_ERROR":
            errors += 1
        elif sev == "UVM_FATAL":
            fatals += 1
        elif sev == "UVM_WARNING":
            warnings += 1
    if fatals:
        status = "FATAL"
    elif errors:
        status = "FAIL"
    else:
        status = "PASS"
    return {"errors": errors, "fatals": fatals,
            "warnings": warnings, "status": status}
'''

MUTATIONS = [
    ("drop the UVM_FATAL branch",
     '        elif sev == "UVM_FATAL":\n            fatals += 1\n', ''),
    ("fatals no longer force FATAL",
     '    if fatals:\n        status = "FATAL"\n    elif errors:',
     '    if errors:'),
    ("off-by-one in the error count", 'errors += 1', 'errors += 2'),
    ("anchor removed from the pattern", 'r"^(UVM_ERROR', 'r"(UVM_ERROR'),
    ("warnings counted as errors",
     '        elif sev == "UVM_WARNING":\n            warnings += 1',
     '        elif sev == "UVM_WARNING":\n            errors += 1'),
    ("line number group made optional", r'\((\d+)\)', r'(\((\d+)\))?'),
    ("PASS and FAIL swapped",
     '        status = "FAIL"\n    else:\n        status = "PASS"',
     '        status = "PASS"\n    else:\n        status = "FAIL"'),
    ("whitespace made optional", r'\s+(\S+)', r'\s*(\S+)'),
]

CLEAN = "UVM_INFO top.sv(10) start\nTEST DONE\n"
ONE_ERR = "UVM_INFO a\nUVM_ERROR drv.sv(88) bad\nTEST DONE\n"


def load(src):
    ns = {}
    exec(compile(src, "parser.py", "exec"), ns)
    return ns


def build(mod, strong):
    class Weak(unittest.TestCase):
        def test_clean_log_passes(self):
            self.assertEqual(mod["classify"](CLEAN)["status"], "PASS")

        def test_one_error_fails(self):
            r = mod["classify"](ONE_ERR)
            self.assertEqual(r["status"], "FAIL")
            self.assertEqual(r["errors"], 1)

    if not strong:
        return Weak

    class Strong(Weak):
        def test_fatal_is_its_own_status(self):
            r = mod["classify"]("UVM_FATAL env.sv(3) boom\n")
            self.assertEqual(r["status"], "FATAL")
            self.assertEqual(r["fatals"], 1)

        def test_warning_is_not_an_error(self):
            r = mod["classify"]("UVM_WARNING a.sv(1) w\n")
            self.assertEqual(r, {"errors": 0, "fatals": 0,
                                 "warnings": 1, "status": "PASS"})

        def test_severity_mid_line_is_not_matched(self):
            r = mod["classify"]("echo UVM_ERROR drv.sv(1) x\n")
            self.assertEqual(r["errors"], 0)

        def test_line_number_is_required(self):
            r = mod["classify"]("UVM_ERROR drv.sv bad\n")
            self.assertEqual(r["errors"], 0)

    return Strong


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


def score(strong):
    base_ok, n = run(build(load(PARSER_SRC), strong))
    assert base_ok, "the baseline must pass — see §9"
    verdicts = []
    for name, old, new in MUTATIONS:
        assert PARSER_SRC.count(old) == 1, name
        src = PARSER_SRC.replace(old, new, 1)
        try:
            ok, _ = run(build(load(src), strong))
        except Exception:
            ok = False          # it did not even import: killed
        verdicts.append((name, "KILLED" if not ok else "SURVIVED"))
    return n, verdicts


nw, weak = score(False)
ns, strong = score(True)

print(f"{'mutation':<34} {'WEAK (%d tests)':<16} STRONG (%d tests)"
      % (nw, ns) if False else
      f"{'mutation':<34} {'WEAK':<10} STRONG")
for (name, w), (_, s) in zip(weak, strong):
    print(f"{name:<34} {w:<10} {s}")

kw = sum(1 for _, v in weak if v == "KILLED")
ks = sum(1 for _, v in strong if v == "KILLED")
print()
print(f"weak suite  : {nw} tests, {kw}/{len(MUTATIONS)} killed "
      f"= {100 * kw // len(MUTATIONS)}%")
print(f"strong suite: {ns} tests, {ks}/{len(MUTATIONS)} killed "
      f"= {100 * ks // len(MUTATIONS)}%")
print()
print("both baselines were green:", True)
print("tests added:", ns - nw, "| mutations newly caught:", ks - kw)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
mutation                           WEAK       STRONG
drop the UVM_FATAL branch          SURVIVED   KILLED
fatals no longer force FATAL       SURVIVED   KILLED
off-by-one in the error count      KILLED     KILLED
anchor removed from the pattern    SURVIVED   SURVIVED
warnings counted as errors         SURVIVED   KILLED
line number group made optional    SURVIVED   KILLED
PASS and FAIL swapped              KILLED     KILLED
whitespace made optional           SURVIVED   SURVIVED

weak suite  : 2 tests, 2/8 killed = 25%
strong suite: 6 tests, 6/8 killed = 75%

both baselines were green: True
tests added: 4 | mutations newly caught: 4

25% against 75%, and both baselines were green. Nothing in a passing test run distinguishes the two-test suite from the six-test suite; the mutation column does, immediately and numerically.

Three details in that table are worth reading carefully.

The weak suite's two kills are the two mutations that change a value it already asserts. errors += 2 breaks assertEqual(r["errors"], 1); swapping PASS and FAIL breaks both status assertions. Everything else lives in code those two logs never reach, or changes a value they never look at.

Four added tests caught exactly four more mutations — a one-to-one return, which is unusual and happens here because each added test was written against a specific unexercised behaviour rather than to raise a coverage number.

And two mutations survived both suites. Those are the interesting ones.

7. A Survivor Is a Question

The reflex is to treat every survivor as a missing test. Measured, our two survivors need two completely different responses:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import re

STRICT = re.compile(r"^(UVM_ERROR|UVM_FATAL)\s+(\S+)\((\d+)\)")
LOOSE = re.compile(r"(UVM_ERROR|UVM_FATAL)\s+(\S+)\((\d+)\)")

CASES = [
    "UVM_ERROR drv.sv(88) bad",
    "echo UVM_ERROR drv.sv(1) x",
    "  UVM_ERROR drv.sv(1) x",
    "xUVM_ERROR drv.sv(1) x",
    "UVM_FATAL env.sv(3) boom",
]

print("survivor 4 — `^` removed, and the code calls .match()")
print(f"  {'input':<30} {'with ^':<8} {'without':<8} differ")
for c in CASES:
    a, b = bool(STRICT.match(c)), bool(LOOSE.match(c))
    print(f"  {c!r:<30} {str(a):<8} {str(b):<8} {a != b}")

print()
print("  any input distinguishing them under .match():",
      any(bool(STRICT.match(c)) != bool(LOOSE.match(c)) for c in CASES))
print("  any input distinguishing them under .search():",
      any(bool(STRICT.search(c)) != bool(LOOSE.search(c)) for c in CASES))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
survivor 4 — `^` removed, and the code calls .match()
  input                          with ^   without  differ
  'UVM_ERROR drv.sv(88) bad'     True     True     False
  'echo UVM_ERROR drv.sv(1) x'   False    False    False
  '  UVM_ERROR drv.sv(1) x'      False    False    False
  'xUVM_ERROR drv.sv(1) x'       False    False    False
  'UVM_FATAL env.sv(3) boom'     True     True     False

  any input distinguishing them under .match(): False
  any input distinguishing them under .search(): True

No test can kill that mutation, because there is no input on which the two patterns behave differently. re.match anchors at the start of the string by definition — Chapter 19.2 Section 4's distinction between match and search — so a leading ^ in a pattern used only with .match() is redundant. The mutant is semantically identical to the original: an equivalent mutant, and writing a test to catch it is impossible, not merely difficult.

The right response is a decision about the code rather than the tests. Either delete the ^, since it asserts something the call already guarantees; or keep it deliberately, knowing it becomes load-bearing the instant somebody changes .match to .search — and the last line of that output is exactly how much it would then matter. A redundant guard against a future edit is defensible; what is not defensible is believing a test could have caught it.

The other survivor is the opposite:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import re

STRICT = re.compile(r"^(UVM_ERROR|UVM_FATAL|UVM_WARNING)\s+(\S+)\((\d+)\)")
LOOSE = re.compile(r"^(UVM_ERROR|UVM_FATAL|UVM_WARNING)\s*(\S+)\((\d+)\)")

CASES = [
    "UVM_ERROR drv.sv(88) bad",
    "UVM_ERROR  drv.sv(88) bad",
    "UVM_ERRORdrv.sv(88) x",
    "UVM_ERROR_drv.sv(88) x",
    "UVM_ERROR_COUNT drv.sv(1) x",
    "UVM_ERROR_SUMMARY(0) : done",
    "UVM_FATALITY(0)",
]

print("survivor 8 — `\\s+` loosened to `\\s*`")
print(f"  {'input':<32} {'strict':<8} {'loose':<8} {'differ':<7} loose field 2")
for s in CASES:
    a, b = STRICT.match(s), LOOSE.match(s)
    print(f"  {s!r:<32} {str(bool(a)):<8} {str(bool(b)):<8} "
          f"{str(bool(a) != bool(b)):<7} "
          f"{repr(b.group(2)) if b else '-'}")

print()
print("  inputs that distinguish them:",
      sum(1 for s in CASES if bool(STRICT.match(s)) != bool(LOOSE.match(s))))
print("  did any test feed one?      :", False)
print("  so this survivor is         :", "a real gap")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
survivor 8 — `\s+` loosened to `\s*`
  input                            strict   loose    differ  loose field 2
  'UVM_ERROR drv.sv(88) bad'       True     True     False   'drv.sv'
  'UVM_ERROR  drv.sv(88) bad'      True     True     False   'drv.sv'
  'UVM_ERRORdrv.sv(88) x'          False    True     True    'drv.sv'
  'UVM_ERROR_drv.sv(88) x'         False    True     True    '_drv.sv'
  'UVM_ERROR_COUNT drv.sv(1) x'    False    False    False   -
  'UVM_ERROR_SUMMARY(0) : done'    False    True     True    '_SUMMARY'
  'UVM_FATALITY(0)'                False    True     True    'ITY'

  inputs that distinguish them: 4
  did any test feed one?      : False
  so this survivor is         : a real gap

Four inputs distinguish them and no test fed any of them. This survivor is a genuine hole, and the right-hand column shows its consequence is worse than the mutation itself suggests.

Read the last two rows. UVM_ERROR_SUMMARY(0) : done matches the loose pattern: \s* consumes nothing, (\S+) starts at the underscore and captures _SUMMARY, and (0) satisfies the line-number group. So the loose parser reads a summary line reporting zero errors as one error — and UVM_FATALITY(0) is read as a fatal, which forces the whole run's status to FATAL. The severity field no longer means a severity; it means a prefix.

The fifth row is the boundary, and it is the part worth understanding rather than memorising. UVM_ERROR_COUNT drv.sv(1) x does not match the loose pattern, even though it begins with the same prefix. (\S+) cannot contain a space, so after capturing _COUNT the pattern needs ( immediately and finds a space instead — and there is no way to backtrack across it. The loose pattern swallows a longer identifier only when a parenthesised number follows it with no space, which is why UVM_ERROR_SUMMARY(0) matches and UVM_ERROR_COUNT drv.sv(1) does not.

That shape is the Chapter 24.3 severity-prefix problem rediscovered from the opposite direction: there, naive counting invented a UVM_FATAL by matching a summary line; here a mutation experiment on a parser's tests pointed at the same class of bug, in code that was still correct.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
SURVIVORS = [
    ("anchor removed", "no input distinguishes them",
     "equivalent mutant", "decide about the CODE"),
    ("whitespace loosened", "'UVM_ERROR_SUMMARY(0)' does",
     "test gap", "add the negative test"),
]

print(f"{'survivor':<22} {'distinguishing input':<32} {'kind':<20} response")
for s, inp, kind, resp in SURVIVORS:
    print(f"{s:<22} {inp:<32} {kind:<20} {resp}")

print()
print("the test that separates the two kinds:")
print("  can you name an input on which original and mutant differ?")
print("  yes -> a test gap, and you have just written the test")
print("  no  -> an equivalent mutant, and no test will ever kill it")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
survivor               distinguishing input             kind                 response
anchor removed         no input distinguishes them      equivalent mutant    decide about the CODE
whitespace loosened    'UVM_ERROR_SUMMARY(0)' does      test gap             add the negative test

the test that separates the two kinds:
  can you name an input on which original and mutant differ?
  yes -> a test gap, and you have just written the test
  no  -> an equivalent mutant, and no test will ever kill it

That question is the whole of the classification, and it is self-rewarding: answering "yes" requires naming the input, and naming the input is writing the missing test. Chapter 29.5 Section 6 reached the same shape for an uncovered coverage bin — the useful question is not "how do I cover it" but "is it reachable at all", and the two answers lead to opposite work.

8. Closing the Real Gap

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


def classify(text):
    pat = re.compile(r"^(UVM_ERROR|UVM_FATAL|UVM_WARNING)\s+(\S+)\((\d+)\)")
    errors = fatals = warnings = 0
    for line in text.splitlines():
        m = pat.match(line)
        if not m:
            continue
        sev = m.group(1)
        if sev == "UVM_ERROR":
            errors += 1
        elif sev == "UVM_FATAL":
            fatals += 1
        else:
            warnings += 1
    status = "FATAL" if fatals else ("FAIL" if errors else "PASS")
    return {"errors": errors, "fatals": fatals,
            "warnings": warnings, "status": status}


class SeverityIsAWholeWord(unittest.TestCase):
    """The test that kills survivor 8 — written from §7's input."""

    def test_no_space_after_severity_is_not_a_match(self):
        self.assertEqual(classify("UVM_ERRORdrv.sv(88) x\n")["errors"], 0)

    def test_a_longer_identifier_is_not_a_severity(self):
        for line in ("UVM_ERROR_SUMMARY(0) : done\n",
                     "UVM_ERRORS_FOUND(3)\n",
                     "UVM_FATALITY(0)\n"):
            with self.subTest(line=line.strip()):
                r = classify(line)
                self.assertEqual(r["status"], "PASS")
                self.assertEqual((r["errors"], r["fatals"]), (0, 0))

    def test_the_real_shape_still_matches(self):
        r = classify("UVM_ERROR drv.sv(88) bad\n")
        self.assertEqual((r["errors"], r["status"]), (1, "FAIL"))


def run(case):
    suite = unittest.TestLoader().loadTestsFromTestCase(case)
    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("gap-closing suite:", run(SeverityIsAWholeWord))
print()
# and prove it kills the mutant it was written for
loose = re.compile(r"^(UVM_ERROR|UVM_FATAL|UVM_WARNING)\s*(\S+)\((\d+)\)")
print("against the MUTANT pattern:")
LINES = ["UVM_ERRORdrv.sv(88) x", "UVM_ERROR_SUMMARY(0) : done",
         "UVM_ERRORS_FOUND(3)", "UVM_FATALITY(0)"]
for line in LINES:
    print(f"  {line!r:<32} matches = {bool(loose.match(line))}")
print()
print("new-test inputs the mutant mis-matches:",
      sum(1 for l in LINES if loose.match(l)))
print("so the new tests fail on the mutant  :", True)
print("which is what 'killed' means         :", "the suite noticed")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
gap-closing suite: {'run': 3, 'failures': 0, 'errors': 0, 'ok': True}

against the MUTANT pattern:
  'UVM_ERRORdrv.sv(88) x'          matches = True
  'UVM_ERROR_SUMMARY(0) : done'    matches = True
  'UVM_ERRORS_FOUND(3)'            matches = True
  'UVM_FATALITY(0)'                matches = True

new-test inputs the mutant mis-matches: 4
so the new tests fail on the mutant  : True
which is what 'killed' means         : the suite noticed

Three tests, written directly from the distinguishing inputs Section 7 named, and the second is now the more valuable of them: a line like UVM_ERROR_SUMMARY(0) is the shape a real UVM log uses to report that there were no errors, so that test defends against a parser that would read a clean summary as a failure — a mistake nobody has made yet, in code one loosened quantifier away from making it.

That is what a mutation run buys. It did not find a bug in the parser — the parser was right. It found that the suite could not tell the right parser from a wrong one, and the test written to fix that now guards a line the original author never considered.

9. The Baseline Rule

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

GOOD = "def width():\n    return 32\n"
MUTATIONS = [("32 -> 64", "32", "64"), ("32 -> 0", "32", "0")]


def load(src):
    ns = {}
    exec(compile(src, "m.py", "exec"), ns)
    return ns


def suite_for(mod, expected):
    class S(unittest.TestCase):
        def test_width(self):
            self.assertEqual(mod["width"](), expected)
    return S


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


for label, expected in (("a CORRECT baseline", 32), ("a BROKEN baseline", 16)):
    base = run(suite_for(load(GOOD), expected))
    killed = 0
    for name, old, new in MUTATIONS:
        if not run(suite_for(load(GOOD.replace(old, new, 1)), expected)):
            killed += 1
    print(f"{label:<22} baseline ok={str(base):<6} "
          f"killed {killed}/{len(MUTATIONS)} = {100 * killed // len(MUTATIONS)}%")

print()
print("the broken baseline scored:", "100%")
print("what it measured          :", "nothing")
print("why                       :", "the suite was already failing")
print("the rule                  :", "assert the baseline passes, then mutate")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
a CORRECT baseline     baseline ok=True   killed 2/2 = 100%
a BROKEN baseline      baseline ok=False  killed 2/2 = 100%

A broken baseline scores a perfect 100%. Every mutant "fails the suite" because the suite was failing before any mutation was applied, and a kill rate computed that way is a measurement of nothing — the best-looking number this chapter produces is the one that means least.

That is why step 1 of the Section 3 loop is an assert, not a note, and why the Section 6 harness carries assert base_ok inside score(). A mutation run without that line reports a number whose most likely cause is that the experiment never ran.

10. Predict, Run, Observe

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

SRC = 'import re\nPAT = re.compile(r"^UVM_ERROR\\s+(\\S+)")\n' \
      'def n(t):\n    return sum(1 for l in t.splitlines() if PAT.match(l))\n'
LOG = "UVM_ERROR a.sv x\nUVM_INFO b\nUVM_ERROR c.sv y\n"


def load(src):
    ns = {}
    exec(compile(src, "m.py", "exec"), ns)
    return ns


def suite(mod):
    class S(unittest.TestCase):
        def test_count(self):
            self.assertEqual(mod["n"](LOG), 2)
    return S


def killed(src):
    s = unittest.TestLoader().loadTestsFromTestCase(suite(load(src)))
    r = unittest.TextTestRunner(stream=io.StringIO(), verbosity=0).run(s)
    return not r.wasSuccessful()


# A
print("A:", killed(SRC))

# B
print("B:", killed(SRC.replace("sum(1", "sum(2", 1)))

# C
print("C:", killed(SRC.replace('r"^UVM_ERROR', 'r"UVM_ERROR', 1)))

# D
print("D:", killed(SRC.replace("NOT_PRESENT", "x", 1)))

The verified output:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A: False
B: True
C: False
D: False

A — the unmutated source: not killed, which is the baseline being green. Section 9's assert is exactly this value.

B — counting 2 per line doubles the result, and the one assertion catches it.

C — the anchor again, surviving again for Section 7's reason: .match() anchors anyway.

D — and here is the trap. The target string does not exist, so replace changed nothing, so the suite passed, so it reports False — reading as a survivor. C and D are indistinguishable in this output, and they mean opposite things: one is an equivalent mutant worth thinking about, the other is a typo in the harness. Only Section 5's assert old in src separates them.

11. Exercises

Exercise 1 — what green means. Write down three things a passing suite is consistent with besides correct code.

Exercise 2 — coverage is not checking. Write a test that executes a function and asserts nothing about its result, and confirm coverage counts the lines.

Exercise 3 — the annotation, literally. Remove one branch from your own parser and run your suite. Record whether anything failed.

Exercise 4 — if none does. If your suite stayed green, write the test that would have caught it.

Exercise 5 — eight plausible mutations. List eight single edits a tired engineer might make to one of your parsers.

Exercise 6 — plausible, not random. Explain in two sentences why random character edits make a poor mutation set.

Exercise 7 — assert the target exists. Add the old in src check to a mutation script and say what it protects you from.

Exercise 8 — one change only. Explain why str.replace without a count can make a kill uninformative.

Exercise 9 — score a weak suite. Run your eight mutations against your current suite and compute the kill rate.

Exercise 10 — add four tests. Write tests for four unexercised behaviours and re-score.

Exercise 11 — one-to-one? Compare tests added against mutations newly caught, and say what a ratio below one would mean.

Exercise 12 — which two died first. For your weak suite, say why its kills were the ones they were.

Exercise 13 — name the input. For each of your survivors, try to name an input on which original and mutant differ.

Exercise 14 — classify. Sort your survivors into test gaps and equivalent mutants.

Exercise 15 — an equivalent mutant of your own. Find a redundant guard in your code whose removal no test could detect.

Exercise 16 — delete or keep. For that guard, decide whether to remove it or document why it stays.

Exercise 17 — match versus search. Audit your patterns for a ^ that only .match() ever sees.

Exercise 18 — the worse consequence. For one real gap, work out what the mutant would do to a real log — not just to a test.

Exercise 19 — the severity prefix. Check whether your parser counts UVM_ERROR_SUMMARY : 0 as an error.

Exercise 20 — close one gap. Write the three-test group that kills one survivor, including a positive test proving the real shape still matches.

Exercise 21 — the broken baseline. Deliberately run a mutation set against a failing suite and record the kill rate.

Exercise 22 — 100% means nothing. Explain in one sentence why that number was perfect.

Exercise 23 — assert it. Add the baseline assertion to your mutation runner.

Exercise 24 — survivor versus typo. Explain how Section 10's C and D produced the same value for different reasons.

Exercise 25 — restore every time. Say what goes wrong if a mutation is left in place before the next one is applied.

Exercise 26 — a kill rate in review. Decide what kill rate you would require before trusting a parser's suite, and justify the number.

12. Summary

  • A passing suite carries no information about the suite. It is equally consistent with tests that check everything and tests that assert trivia.
  • Coverage records that a line executed, which is not the same as a line being checked.
  • The annotation's own mutation survived the smallest plausible suite. Removing the UVM_FATAL branch left two good tests green, so "if none does, fix the tests" is the normal outcome, not a formality.
  • Two suites, the same eight mutations: 25% against 75% — and both baselines were green, so only the kill rate distinguished them.
  • The weak suite killed exactly the mutations that changed a value it already asserted, and nothing else.
  • Four added tests caught four more mutations, a one-to-one return that comes from writing each test against an unexercised behaviour rather than for a coverage number.
  • Mutations must be plausible edits, not random characters — random edits produce syntax errors every suite kills for free.
  • Assert that the target string exists and is unique. An unapplied mutation reports as a survivor, indistinguishable from a real gap.
  • A survivor is a question, not a missing test. Ours split two ways.
  • An equivalent mutant cannot be killed by any test. A leading ^ in a pattern used only with .match() is redundant — no input distinguishes them — so the decision is about the code.
  • A real gap names its own test. Four inputs distinguish \s+ from \s*, and naming one is writing the missing test.
  • The real gap's consequence was worse than the mutation. The loose pattern reads UVM_ERROR_SUMMARY(0) as an error and UVM_FATALITY(0) as a fatal — Chapter 24.3's severity-prefix bug arrived at from the other side.
  • \S+ cannot cross a space, which is why the loose pattern swallows UVM_ERROR_SUMMARY(0) but not UVM_ERROR_COUNT drv.sv(1) — the boundary is worth understanding, not memorising.
  • A broken baseline scores 100%. The best-looking number in this chapter measures nothing, which is why step 1 of the loop is an assertion.

The next chapter turns from proving a script works to recording what it did while it ran.

Where this fits

Part of the Python curriculum.