Skip to content
VLSI Mentor

Python Part II · Module 33 · Testing and Debugging Automation

What Can Be Tested, and What Needs the Tool

The usual objection to testing flow code is that it needs a simulator, a licence and forty minutes, and for one part of a flow that is true. The useful question is how large that part is — and it is measurable rather than arguable. This chapter takes a complete runner of the kind Module 25 built, sorts every function by what it reads, and counts the lines on each side of the line. The answer here is a third, concentrated in a single function, and the reason it is only a third comes down to one parameter.

Intermediate20 min readPythonPart IIAutomationTestingDesign

Part II · Module 33 · Chapter 33.2 · What Can Be Tested, and What Needs the Tool

1. The Question, Made Answerable

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
QUESTIONS = [
    ("does this function read the filesystem?", "no", "testable"),
    ("does it launch a process?", "no", "testable"),
    ("does it read the clock or the environment?", "no", "testable"),
    ("does it do any of those?", "yes", "needs a seam or a tool"),
]

print(f"{'ask of every function':<44} {'answer':<8} verdict")
for q, a, v in QUESTIONS:
    print(f"{q:<44} {a:<8} {v}")

print()
print("the test is mechanical:", "what does this function READ?")
print("which is also 32.5's test:", "applied to a Python function instead of a tool command")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
ask of every function                        answer   verdict
does this function read the filesystem?      no       testable
does it launch a process?                    no       testable
does it read the clock or the environment?   no       testable
does it do any of those?                     yes      needs a seam or a tool

the test is mechanical: what does this function READ?
which is also 32.5's test: applied to a Python function instead of a tool command

Chapter 32.5 asked what does this work read to decide whether it belonged inside a tool. The same question sorts Python functions into testable and not, and the symmetry is not a coincidence: both are asking whether a piece of work can be given its inputs by hand.

2. The Annotation

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
# testable: parse_log_text(text) -> dict
# not testable in isolation: subprocess.run(["vcs", ...])
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
def parse_log_text(text):
    """Pure: text in, dict out."""
    errors = sum(1 for l in text.splitlines() if l.startswith("UVM_ERROR"))
    fatals = sum(1 for l in text.splitlines() if l.startswith("UVM_FATAL"))
    finished = "TEST DONE" in text
    return {"errors": errors, "fatals": fatals, "finished": finished}


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

print("parse_log_text(SAMPLE) =", parse_log_text(SAMPLE))
print()
print("inputs it needs      :", "one string")
print("files it touches     :", 0)
print("processes it launches:", 0)
print("licences it needs    :", 0)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
parse_log_text(SAMPLE) = {'errors': 2, 'fatals': 0, 'finished': True}

inputs it needs      : one string
files it touches     : 0
processes it launches: 0
licences it needs    : 0

The annotation's two comments are both right, and the interesting word is in the second one: "in isolation". subprocess.run(["vcs", ...]) is not untestable in principle — Section 6 shows three things its tests can assert — but it cannot be exercised end to end without the thing it launches.

3. The Word "Thin" Is a Claim

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
CLAIMS = [
    ("pure functions can be tested in milliseconds", "measured in §7"),
    ("only the tool-calling layer cannot", "measured in §4"),
    ("that layer is thin", "a number, not an adjective — §4"),
]

for c, how in CLAIMS:
    print(f"  {c:<46} {how}")

print()
print("claims in the canonical purpose:", len(CLAIMS))
print("claims that are checkable       :", len(CLAIMS))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  pure functions can be tested in milliseconds   measured in §7
  only the tool-calling layer cannot             measured in §4
  that layer is thin                             a number, not an adjective — §4

claims in the canonical purpose: 3
claims that are checkable       : 3

"Thin" is the load-bearing word, and it is a design property rather than a fact of nature — a flow can easily be written so that most of it needs a tool. The next section measures a well-shaped one and Section 5 shows what makes the difference.

4. Counting a Real Runner

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import inspect
import subprocess
from pathlib import Path


def build_command(sim, test, seed, run_dir):
    return [sim, "-f", "files.f", f"+UVM_TESTNAME={test}", f"+seed={seed}",
            "-l", str(Path(run_dir) / "sim.log")]


def run_dir_for(test, seed):
    return Path("runs") / f"{test}_{seed}"


def parse_log_text(text):
    errors = sum(1 for l in text.splitlines() if l.startswith("UVM_ERROR"))
    fatals = sum(1 for l in text.splitlines() if l.startswith("UVM_FATAL"))
    return {"errors": errors, "fatals": fatals, "finished": "TEST DONE" in text}


def classify(parsed, returncode, timed_out):
    if timed_out:
        return "TIMEOUT"
    if returncode is not None and returncode < 0:
        return "KILLED"
    if parsed["fatals"] or parsed["errors"]:
        return "FAIL"
    if not parsed["finished"]:
        return "NO_FINISH"
    return "PASS"


def aggregate(results):
    out = {}
    for r in results:
        out[r["status"]] = out.get(r["status"], 0) + 1
    return out


def render_row(r):
    return f"| {r['test']} | {r['seed']} | {r['status']} | {r['errors']} |"


def exit_code_for(summary):
    if summary.get("KILLED") or summary.get("TIMEOUT"):
        return 4
    return 1 if summary.get("FAIL") else 0


def execute(sim, test, seed):
    """The impure layer: builds, launches, writes, reads back."""
    rd = run_dir_for(test, seed)
    rd.mkdir(parents=True, exist_ok=True)
    cmd = build_command(sim, test, seed, rd)
    (rd / "cmd.txt").write_text(" ".join(cmd) + "\n", encoding="utf-8")
    try:
        p = subprocess.run(cmd, stdout=subprocess.PIPE,
                           stderr=subprocess.STDOUT, text=True, timeout=5)
        out, rc, to = p.stdout, p.returncode, False
    except FileNotFoundError:
        return {"test": test, "seed": seed, "status": "INFRA", "errors": 0}
    except subprocess.TimeoutExpired:
        out, rc, to = "", None, True
    (rd / "sim.log").write_text(out, encoding="utf-8")
    parsed = parse_log_text(out)
    return {"test": test, "seed": seed,
            "status": classify(parsed, rc, to), "errors": parsed["errors"]}


FUNCS = [build_command, run_dir_for, parse_log_text, classify, aggregate,
         render_row, exit_code_for, execute]
IMPURE = {"execute"}

print(f"{'function':<18} {'lines':>6}  {'pure?':<7} reads")
total = pure_lines = 0
for f in FUNCS:
    n = len(inspect.getsource(f).rstrip().splitlines())
    total += n
    impure = f.__name__ in IMPURE
    if not impure:
        pure_lines += n
    reads = "the filesystem and a process" if impure else "its arguments"
    print(f"{f.__name__:<18} {n:>6}  {str(not impure):<7} {reads}")

print()
print(f"functions          : {len(FUNCS)}  pure {len(FUNCS) - len(IMPURE)}")
print(f"lines              : {total}  pure {pure_lines}")
print(f"pure fraction      : {100.0 * pure_lines / total:.0f}%")
print(f"needs a tool       : {total - pure_lines} lines, in {len(IMPURE)} function")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
function            lines  pure?   reads
build_command           3  True    its arguments
run_dir_for             2  True    its arguments
parse_log_text          4  True    its arguments
classify               10  True    its arguments
aggregate               5  True    its arguments
render_row              2  True    its arguments
exit_code_for           4  True    its arguments
execute                18  False   the filesystem and a process

functions          : 8  pure 7
lines              : 48  pure 30
pure fraction      : 62%
needs a tool       : 18 lines, in 1 function

Seven of eight functions and 62% of the lines are pure, and everything needing a tool is in one function.

The shape is the point rather than the exact percentage. execute is the only function that touches the world, and it does four things — makes a directory, builds a command, launches it, writes a file — none of which involves a decision. Every decision in the runner (which directory, what command, what the log means, what the status is, what the exit code should be) lives in a function that takes its inputs as arguments.

5. The Seam Is One Parameter

What makes that split achievable is a habit visible in two function signatures:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from pathlib import Path


def parse_log(path):
    """Untestable without a filesystem."""
    return parse_log_text(Path(path).read_text(encoding="utf-8"))


def parse_log_text(text):
    """Testable with a string literal."""
    return {"errors": sum(1 for l in text.splitlines()
                          if l.startswith("UVM_ERROR"))}


SAMPLE = "UVM_ERROR a\nUVM_ERROR b\nTEST DONE\n"

print("parse_log_text needs:", "a str")
print("result              :", parse_log_text(SAMPLE))
print()
print("parse_log needs     :", "a path that exists")
try:
    parse_log("no/such/file.log")
except FileNotFoundError as exc:
    print("calling it with a missing path:", type(exc).__name__)

print()
print("difference between them:", "one line, and one parameter type")
print("tests the text version needs:", "a string literal")
print("tests the path version needs:", "a temporary directory — 33.7")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
parse_log_text needs: a str
result              : {'errors': 2}

parse_log needs     : a path that exists
calling it with a missing path: FileNotFoundError

difference between them: one line, and one parameter type
tests the text version needs: a string literal
tests the path version needs: a temporary directory — 33.7

One parameter moves a function across the line. parse_log(path) cannot be tested without a filesystem; parse_log_text(text) is tested with a literal, and parse_log becomes a two-line wrapper whose only behaviour is reading a file.

The same transformation applies to every impure input:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
SEAMS = [
    ("a path", "the file's text", "parse_log_text(text)"),
    ("the current time", "a timestamp argument", "stamp(now)"),
    ("os.environ", "a dict argument", "resolve(env)"),
    ("a subprocess result", "returncode and output", "classify(parsed, rc, to)"),
    ("a directory listing", "a list of names", "pick(names)"),
]

print(f"{'instead of reading':<24} {'take':<28} example")
for impure, pure, ex in SEAMS:
    print(f"{impure:<24} {pure:<28} {ex}")

print()
print("seams listed:", len(SEAMS))
print("what they all do:", "move the reading out and leave the deciding in")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
instead of reading       take                         example
a path                   the file's text              parse_log_text(text)
the current time         a timestamp argument         stamp(now)
os.environ               a dict argument              resolve(env)
a subprocess result      returncode and output        classify(parsed, rc, to)
a directory listing      a list of names              pick(names)

seams listed: 5
what they all do: move the reading out and leave the deciding in

Move the reading out and leave the deciding in. That one sentence produces the 60% from Section 4, and the fourth row is the one that matters most here — classify(parsed, returncode, timed_out) takes the results of a subprocess call rather than making one, so the whole status-classification logic of Chapter 25.6 is testable with three literals.

6. What the Impure Layer's Tests Can Still Assert

"Not testable in isolation" does not mean "no tests". Three things are checkable without the tool:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import io
import unittest
from pathlib import Path


def build_command(sim, test, seed, run_dir):
    return [sim, "-f", "files.f", f"+UVM_TESTNAME={test}", f"+seed={seed}",
            "-l", str(Path(run_dir) / "sim.log")]


def run_dir_for(test, seed):
    return Path("runs") / f"{test}_{seed}"


def classify(parsed, returncode, timed_out):
    if timed_out:
        return "TIMEOUT"
    if returncode is not None and returncode < 0:
        return "KILLED"
    if parsed["fatals"] or parsed["errors"]:
        return "FAIL"
    if not parsed["finished"]:
        return "NO_FINISH"
    return "PASS"


class ImpureLayerTests(unittest.TestCase):
    def test_command_shape(self):
        cmd = build_command("xrun", "axi_burst", 48210, "runs/x")
        self.assertEqual(cmd[0], "xrun")
        self.assertIn("+UVM_TESTNAME=axi_burst", cmd)
        self.assertIn("+seed=48210", cmd)

    def test_command_is_a_list(self):
        cmd = build_command("xrun", "t", 1, "runs/t")
        self.assertIsInstance(cmd, list)
        self.assertTrue(all(isinstance(a, str) for a in cmd))

    def test_run_dir_is_per_run(self):
        a = run_dir_for("t", 1)
        b = run_dir_for("t", 2)
        self.assertNotEqual(a, b)

    def test_status_from_a_given_log(self):
        parsed = {"errors": 2, "fatals": 0, "finished": True}
        self.assertEqual(classify(parsed, 0, False), "FAIL")

    def test_killed_beats_errors(self):
        parsed = {"errors": 0, "fatals": 0, "finished": False}
        self.assertEqual(classify(parsed, -9, False), "KILLED")


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("tests of the tool-calling layer, with no tool:", run(ImpureLayerTests))
print()
COVERED = ["the command it would build", "that the command is a list of str",
           "that run directories are per-run", "the status for a given log",
           "the precedence between KILLED and FAIL"]
NOT_COVERED = ["whether the tool accepts those arguments",
               "whether the tool writes the log where we said",
               "whether the design actually passes"]
print("covered without a tool:")
for c in COVERED:
    print("  +", c)
print("not covered:")
for c in NOT_COVERED:
    print("  -", c)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
tests of the tool-calling layer, with no tool: {'run': 5, 'failures': 0, 'errors': 0, 'ok': True}

covered without a tool:
  + the command it would build
  + that the command is a list of str
  + that run directories are per-run
  + the status for a given log
  + the precedence between KILLED and FAIL
not covered:
  - whether the tool accepts those arguments
  - whether the tool writes the log where we said
  - whether the design actually passes

Five assertions about the tool-calling layer, no tool involved. The pattern is that execute was decomposed in Section 4 into small pure helpers (build_command, run_dir_for, classify) which are testable — so the genuinely untestable residue is the three lines that call subprocess.run and write two files.

This is exactly Chapter 32.5 Section 9's proc-stub harness in Python: the structure is checkable without the executor, and what the executor accepts is not. The honesty is in listing the second column.

7. The Speed Claim, as a Boolean

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


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


class Suite(unittest.TestCase):
    pass


for i in range(200):
    def make(n):
        def t(self):
            self.assertEqual(classify("UVM_INFO x\nTEST DONE\n"), "PASS")
        return t
    setattr(Suite, f"test_{i:03d}", make(i))

start = time.monotonic()
suite = unittest.TestLoader().loadTestsFromTestCase(Suite)
res = unittest.TextTestRunner(stream=io.StringIO(), verbosity=0).run(suite)
elapsed = time.monotonic() - start

print("tests run        :", res.testsRun)
print("all passed       :", res.wasSuccessful())
print("under 1 second   :", elapsed < 1.0)
print("under 10 seconds :", elapsed < 10.0)
print()
print("a single tool invocation is under 1 second:", False)
print("the ratio is therefore at least            :", "three orders of magnitude")
print("(the duration itself is not published — 27's determinism rule)")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
tests run        : 200
all passed       : True
under 1 second   : True
under 10 seconds : True

a single tool invocation is under 1 second: False
the ratio is therefore at least            : three orders of magnitude
(the duration itself is not published — 27's determinism rule)

Two hundred assertions inside a second, reported as a boolean against a bound rather than as a measured duration — which is Chapter 27's rule and also makes this example reproducible on a machine ten times slower.

The comparison that matters is not the absolute speed but what it permits. A suite that runs in under a second runs on every save; a suite that needs a forty-minute tool invocation runs when somebody remembers. The pure fraction is therefore the fraction of a flow that can be protected continuously, and the rest is protected by review and by the structural tests of Section 6.

8. The Two Layers

Where the line falls in a flow

Three blocks left to right. The first holds the pure decisions: build command, run directory, classify, aggregate, render and exit code. An arrow labelled literals leads from the test suite into it. The second block is the thin impure layer that calls subprocess and writes files. An arrow labelled needs tool points from it to the third block, the tool itself, which is where a licence and forty minutes are required.Pure decisions7 of 8 functionsThin layersubprocess, filesThe toollicence, minutesresultslaunch12
Left and centre are pure and take their inputs as arguments, so they are tested with literals in milliseconds. Only the right-hand box reads the world — and in the runner measured in Section 4 it was one function of eight and a third of the lines.

9. A Flow Shaped the Other Way

The 60% is a property of how the runner was written, and it is worth seeing the alternative to know what to avoid:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import inspect

MONOLITH = '''
def run_test(sim, test, seed):
    import subprocess
    from pathlib import Path
    rd = Path("runs") / f"{test}_{seed}"
    rd.mkdir(parents=True, exist_ok=True)
    cmd = [sim, "-f", "files.f", f"+UVM_TESTNAME={test}", f"+seed={seed}"]
    p = subprocess.run(cmd, stdout=subprocess.PIPE, text=True)
    (rd / "sim.log").write_text(p.stdout, encoding="utf-8")
    errors = sum(1 for l in p.stdout.splitlines() if l.startswith("UVM_ERROR"))
    if p.returncode < 0:
        status = "KILLED"
    elif errors:
        status = "FAIL"
    elif "TEST DONE" not in p.stdout:
        status = "NO_FINISH"
    else:
        status = "PASS"
    print(f"| {test} | {seed} | {status} | {errors} |")
    return 1 if status != "PASS" else 0
'''

lines = [l for l in MONOLITH.strip().splitlines()]
print("one function, doing everything:")
print("  lines           :", len(lines))
print("  functions       :", 1)
print("  pure functions  :", 0)
print("  pure lines      :", 0)
print("  pure fraction   :", "0%")
print()
DECISIONS = ["which directory", "what command", "what the log means",
             "what the status is", "how to render it", "what to exit with"]
print("decisions inside it:", len(DECISIONS))
for d in DECISIONS:
    print("  -", d)
print()
print("tests possible without a tool:", 0)
print("the same code, same behaviour, 0% testable instead of 62%")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
one function, doing everything:
  lines           : 19
  functions       : 1
  pure functions  : 0
  pure lines      : 0
  pure fraction   : 0%

decisions inside it: 6
  - which directory
  - what command
  - what the log means
  - what the status is
  - how to render it
  - what to exit with

tests possible without a tool: 0
the same code, same behaviour, 0% testable instead of 62%

Identical behaviour, zero per cent testable. Six decisions are present in both versions; in this one every single one of them is reachable only by launching a simulator.

That is the shape most flow code starts in, and it is why the canonical purpose's "thin" is an instruction rather than an observation. The refactor from this version to Section 4's is mechanical — extract each decision as a function over its inputs — and it does not change what the program does.

10. What “Pure” Does Not Mean

The split in this chapter rests on a word, and the word is slipperier than it looks. A function with no I/O, no subprocess and no clock can still return different answers for the same argument:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
THRESHOLD = 100


def over_budget(cycles):
    """No I/O, no clock, no randomness. Is it pure?"""
    return cycles > THRESHOLD


print("over_budget(150) :", over_budget(150))

THRESHOLD = 200                      # somebody else's module-level constant

print("after THRESHOLD is rebound:")
print("over_budget(150) :", over_budget(150))
print()
print("same argument, different answer  :", True)
print("arguments the function declares  :", 1)
print("inputs the function actually has :", 2)
print("the second one is                :", "a module global — invisible at the call site")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
over_budget(150) : True
after THRESHOLD is rebound:
over_budget(150) : False

same argument, different answer  : True
arguments the function declares  : 1
inputs the function actually has : 2
the second one is                : a module global — invisible at the call site

One declared argument, two actual inputs. Nothing here touches a file or a process, so the function passes every informal test for purity, and it still fails the only test that matters: the same input did not produce the same output.

That is not a contrived example. A threshold read from a module-level constant is how almost every flow starts, and the constant gets set from a config file at import time — so the function's answer depends on which config was loaded, which is exactly the kind of dependency a test needs to control and cannot see.

The working definition is therefore narrower than "no side effects":

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
CRITERIA = [
    ("returns the same output for the same arguments", "yes", "the real test"),
    ("writes no file, spawns no process", "yes", "necessary, not sufficient"),
    ("reads no module-level mutable state", "yes", "the one people miss"),
    ("calls no clock, no random, no environ", "yes", "all hidden inputs"),
    ("is short", "no", "length is unrelated"),
    ("has no loops", "no", "unrelated"),
    ("takes only primitives", "no", "a dataclass argument is fine"),
]

print(f"{'criterion':<48} {'required':<10} note")
for c, req, note in CRITERIA:
    print(f"{c:<48} {req:<10} {note}")

required = sum(1 for _, r, _ in CRITERIA if r == "yes")
print()
print("required criteria:", required, "| irrelevant:", len(CRITERIA) - required)
print("the single question:", "same arguments -> same result, always?")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
criterion                                        required   note
returns the same output for the same arguments   yes        the real test
writes no file, spawns no process                yes        necessary, not sufficient
reads no module-level mutable state              yes        the one people miss
calls no clock, no random, no environ            yes        all hidden inputs
is short                                         no         length is unrelated
has no loops                                     no         unrelated
takes only primitives                            no         a dataclass argument is fine

required criteria: 4 | irrelevant: 3
the single question: same arguments -> same result, always?

Four criteria that matter and three that do not. The bottom three are worth listing because they are the ones people use as proxies: a long function with loops over a list of dataclasses can be perfectly pure, and a one-line function reading a global is not.

The fix is the one this chapter already argued for, applied one level down — make the hidden input an argument:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from dataclasses import dataclass


@dataclass(frozen=True)
class Budget:
    cycles: int = 100


def over_budget(cycles, budget):
    """Both inputs are now visible at the call site."""
    return cycles > budget.cycles


print("over_budget(150, Budget(100)):", over_budget(150, Budget(100)))
print("over_budget(150, Budget(200)):", over_budget(150, Budget(200)))
print()
print("same arguments -> same result:", True)
print("testable without any setup   :", True)
print("the config is now            :", "an argument the caller supplies")
print("and the default lives        :", "in one frozen place")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
over_budget(150, Budget(100)): True
over_budget(150, Budget(200)): False

same arguments -> same result: True
testable without any setup   : True
the config is now            : an argument the caller supplies
and the default lives        : in one frozen place

Two calls, two answers, and the function is pure — because the thing that changed is now written at the call site rather than hidden in a module. A test constructs the Budget it wants and needs no setUp, no monkeypatching and no import-order care, which is the same argument this chapter made about the runner's seam arriving at the smallest possible scale.

frozen=True is doing a specific job in that snippet. A mutable config object passed as an argument is still a shared input, so a test that modifies one leaks into the next — Chapter 33.7's isolation problem in miniature.

11. Predict, Run, Observe

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from pathlib import Path


def parse_text(text):
    return len([l for l in text.splitlines() if l.startswith("UVM_ERROR")])


def parse_path(path):
    return parse_text(Path(path).read_text(encoding="utf-8"))


# A
print("A:", parse_text("UVM_ERROR a\nUVM_ERROR b\n"))

# B
try:
    parse_path("missing.log")
    print("B: no error")
except FileNotFoundError:
    print("B: FileNotFoundError")

# C
print("C:", 30, 48, f"{100 * 30 // 48}%", f"{100 * 30 / 48:.1f}%")

# D
print("D:", "what does this function read?")

The verified output:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A: 2
B: FileNotFoundError
C: 30 48 62% 62.5%
D: what does this function read?

A — the text version is tested with a literal and no filesystem.

B — the path version cannot be called at all without a file.

C — the pure fraction from Section 4, recomputed two ways. Integer division reports 62 and the float 62.5, which is a reminder to say which you used when a number reaches a report.

D — the one question that sorts every function.

12. Exercises

Exercise 1 — ask the question. For ten functions in your flow, answer "what does this read?" and sort them.

Exercise 2 — the symmetry. Explain why the same question works for Chapter 32.5's tool commands and for Python functions.

Exercise 3 — your annotation. Write the two-comment version of the annotation for your own runner.

Exercise 4 — "in isolation". Say what that phrase adds to "not testable".

Exercise 5 — count your functions. Report pure and impure counts for one module of your flow.

Exercise 6 — count your lines. Report the pure fraction, and say whether you used integer or float division.

Exercise 7 — where are the decisions? List the decisions in your runner and say which function each lives in.

Exercise 8 — the four things. Confirm that your impure function only makes directories, builds commands, launches and writes.

Exercise 9 — one parameter. Convert one f(path) into f_text(text) plus a two-line wrapper.

Exercise 10 — five seams. Apply the Section 5 table to five impure inputs in your own code.

Exercise 11 — the clock. Find a function that reads the current time and give it a timestamp parameter.

Exercise 12 — the environment. Do the same for os.environ.

Exercise 13 — the subprocess seam. Confirm your classifier takes a returncode rather than making a call.

Exercise 14 — hard to test, hard to find. Pick a decision in your flow that is hard to locate and say whether it is also hard to test.

Exercise 15 — five assertions, no tool. Write them for your own tool-calling layer.

Exercise 16 — the residue. Count the lines in your flow that genuinely cannot be tested without a tool.

Exercise 17 — list what is not covered. Write the honest second column for your own structural tests.

Exercise 18 — the Tcl parallel. Explain how Section 6 relates to Chapter 32.5 Section 9's proc stubs.

Exercise 19 — a boolean, not a duration. Time your suite and report it as a bound rather than a number.

Exercise 20 — what the speed permits. Say what your team would do differently if the suite ran on every save.

Exercise 21 — the monolith. Find a function in your flow that does all six things, and count its decisions.

Exercise 22 — refactor one. Extract one decision from it and write the test that is now possible.

Exercise 23 — zero to sixty. Explain why the refactor does not change behaviour.

Exercise 24 — an instruction, not an observation. Write the one-sentence rule you would put in your project's style guide.

Exercise 25 — the slippery word. Write your own one-sentence definition of pure, and check it against the module-global example.

Exercise 26 — one argument, two inputs. Find a function of yours that reads a module-level constant.

Exercise 27 — the config at import time. Say how that constant gets its value in your flow, and what a test would have to do to control it.

Exercise 28 — four and three. Classify three of your functions using the seven criteria.

Exercise 29 — a long pure function. Find or write one, and say why length was never the question.

Exercise 30 — make it an argument. Convert one hidden input into a parameter with a frozen default object.

Exercise 31 — why frozen. Explain what goes wrong if the config object passed between tests is mutable.

13. Summary

  • One question sorts every function: what does it read? Its arguments means testable; the filesystem, a process, the clock or the environment means not — the same test Chapter 32.5 applied to tool commands.
  • "Not testable in isolation" is narrower than "untestable" — Section 6 asserts five things about the tool-calling layer with no tool.
  • "Thin" is a claim with a number. On the measured runner: 7 of 8 functions and 62% of the lines are pure, and all 18 lines needing a tool are in one function.
  • The impure function makes no decisions — it creates a directory, builds a command, launches it and writes a file. Every decision lives in a function over plain data.
  • One parameter moves a function across the line. parse_log(path) needs a filesystem; parse_log_text(text) needs a string literal.
  • Move the reading out and leave the deciding in, and do it for paths, the clock, the environment, subprocess results and directory listings.
  • A flow that is hard to test is usually one whose decisions are hard to find, and the two problems have one fix.
  • Two hundred assertions ran inside a second, reported as a boolean against a wide bound rather than a duration, per Chapter 27's rule.
  • The pure fraction is the fraction of a flow that can be protected continuously — a sub-second suite runs on every save; a forty-minute one runs when somebody remembers.
  • The same six decisions in a monolithic function give a 0% pure fraction with identical behaviour, which is why "thin" is an instruction rather than an observation.
  • The refactor is mechanical and changes nothing the program does.
  • “Pure” does not mean “no I/O”. A function with no files, no processes and no clock can still return different answers for the same argument if it reads a module-level constant.
  • Four criteria matter and three common proxies do not — length, loops and primitive-only arguments are all unrelated to purity.
  • The only real test is: same arguments, same result, always.
  • Make the hidden input an argument, with a frozen=True default object so a test cannot leak config into the next one.

The next chapter writes the test the whole module is built on: a fixed input, an expected result, an assertion.

Where this fits

Part of the Python curriculum.