Chapter 1 — Why unittest and pytest Fall Short for Automation
Application unit tests verify a function in isolation. Integration tests verify two collaborators in isolation. Automation tests verify a system in motion — devices waking, credentials being fetched, configurations being merged, jobs being scheduled across hosts. The tools that ship with Python were never designed for the third thing. This chapter is about what's missing, and why patching
pytestto fill the gap eventually fights the framework rather than helping it.
unittest and pytest are excellent tools. Across millions of
Python codebases they verify the correctness of functions, classes,
modules, and small graphs of objects. The codecraftai-* portfolio
uses both — pytest runs on the framework's own unit tests,
unittest is the foundation of unittest.mock's spec-aware
mocking, and the discipline of writing testable functions started
in those communities long before automation engineering picked it
up.
This volume is not a polemic against pytest. It is an explanation
of why a particular kind of test — the kind that runs against a
system rather than a function — needs a different framework.
That framework is codecraftai-testbase, and the rest of Volume 1
walks through it. This chapter establishes why testbase exists
by listing the constraints automation testing places on a runner,
then asking which of those constraints pytest was designed to
satisfy.
The answer turns out to be: not many. That's not a criticism of pytest. It's a statement about scope.
The shape of an automation test
An automation test, in this volume's sense, is a test where:
- The system under test is not in the test process. The system under test is a device, a fleet of devices, a service running on another machine, or a constellation of services coordinated by an orchestration layer.
- The test must acquire and release real resources — SSH sessions, credentials, license tokens, devices that other tests in the suite may also need, network namespaces.
- The test must survive failures it can't reproduce locally. A flaky network on host 7 of 12 is part of the production environment; the framework has to tell you what happened on host 7 with enough detail that you don't need to reproduce.
- The test must stream results while it runs, not write them all at the end. A four-hour suite that crashes in hour three should still tell you what passed in hours one and two.
- The test must be machine-readable — by CI dashboards, replay tools, AI agents extracting failure patterns. Stdout and a green-or-red exit code don't carry enough information.
- The test must describe its environment — which devices it needs, which credentials, which configuration profile — so that a runner on another machine can reproduce or refuse the run.
The first three of these are what most engineers mean when they say "automation testing is hard." The last three are what most engineers don't notice until they try to scale a test suite past about five hundred tests across more than one host. Automation test frameworks live or die on the last three.
What pytest does well
It is worth being precise about pytest's strengths before explaining why testbase exists, because dismissing pytest is a mistake too — many of testbase's design choices are pytest's choices made differently for a different problem.
Pytest does the following extremely well:
- Test discovery is implicit. Functions whose names start
with
test_in files whose names matchtest_*.pyare tests. No registration ceremony. - Fixtures compose cleanly. A fixture can depend on other fixtures, and pytest works out the dependency order.
- Parametrisation is first-class.
@pytest.mark.parametrizegenerates test variants by inspection of the test function's parameters. - Plugins are first-class. Reporting, parallelism, randomisation, coverage, hypothesis, xdist — the plugin ecosystem is the framework's strongest feature.
- Failure reports are diagnostic. Pytest's assertion
rewriting gives you the values of the operands of a failed
assertion without having to write
assertEqual(a, b, "...").
codecraftai-testbase keeps the first three properties. Tests are
plain functions whose names start with test_, found by
inspecting modules under a test root (see Chapter 2). Fixtures
compose; they are just expressed differently. Parametrisation
exists in two forms — explicit data-driven (akin to pytest's
parametrize) and property-based (akin to Hypothesis).
What testbase doesn't keep is the assumption that the framework runs in the same process as the system under test.
What automation tests need that pytest doesn't ship
There are seven gaps. The remainder of Volume 1 is, in effect, an extended explanation of how testbase fills them.
1. A lifecycle wider than the test function
A pytest test has fixtures with function, class, module,
session scope. That's enough when the system under test lives
inside the Python process. When the system under test is outside
the Python process, you need lifecycle hooks for things that don't
map onto fixtures:
- Attaching to the framework: registering integration couplings, binding the test runner to a results pipeline.
- Attaching to the environment: connecting to devices, opening SSH sessions, fetching credentials.
- Activating configuration: telling the landscape that this particular run wants this particular profile.
- Activating integration: bringing online the cross-host collaboration the suite depends on.
- Activating operations: starting the side processes (log shippers, metric agents) that other tests will rely on.
In codecraftai-testbase, these are five fire_* hooks on TestJob:
fire_attach_to_framework, fire_attach_to_environment,
fire_activate_configuration, fire_activate_integration,
fire_activate_operations (file:
codecraftai-testbase/source/packages/ccai/testbase/testjob.py:379–408).
On a single-host run all five are no-op stubs — they exist
because Volume 2's InteropLibraryTestJob (in codecraftai-interop,
source/packages/ccai/interop/testing/testjob.py:22) overrides
them to drive landscape activation. The hooks are the place
where testbase's single-host runner and Volume 2's distributed
runner meet.
A pytest fixture cannot model "I need the entire test session to
have woken up the lab's twelve devices and pre-fetched their
credentials before the first test starts, and put them to sleep
after the last test ends" without contortions. A fire_* hook
on a job-spanning singleton can.
2. Tests that declare what they need, not what they import
A pytest test imports its fixtures. An automation test cannot always do that, because the resource it needs may not be present on the host the test author is editing on. In testbase the resource declaration is a separate decoration:
@testbase.attach.parameter(some_device, identifier="dev")
def test_device_responds(dev):
"""
Verify that the lab device responds to a probe.
"""
response = dev.probe()
rtnval = response.is_ok()
assert rtnval
return
The @testbase.attach.parameter(source, identifier=...) form
(codecraftai-testbase/source/packages/ccai/testbase/declaration/attachments.py)
uses an originator to produce the parameter, with constraints
on lifespan and assigned scope. The originator can come from a
different package than the test, registered via the namespace
package extension model from Volume 0 chapter 7. A test author
on a laptop without the device declares the dependency without
having to import a module that would fail at import time.
3. Results that stream, not aggregate
Pytest writes its summary at the end. Plugins exist to write JUnit XML or other formats, but the framework's mental model is "run all the tests, then report." An automation suite that takes hours to run cannot afford to lose its history when the runner crashes.
Testbase produces three result artefacts during a run:
testsummary.json— the high-level summary of pass/fail counts.testresults_stream.jsos— a stream file written as tests finish, one record per event, prefixed by an ASCII record separator character (codecraftai-testbase/source/packages/ccai/testbase/results/recorders/jsonresultrecorder.py:90–183).testresults.json— the consolidated final document, written at the end.
The stream file is the one that matters for hours-long runs. A
CI dashboard, a local tail process, or an AI agent reading
failures (Chapter 6) can begin extracting failures while the
suite is running. A crashed run still leaves a partial stream
that tells you what passed and what hadn't started. Chapter 5
walks the JSOS format in detail.
4. Property-based testing with a CI fast path
Hypothesis is excellent at finding edge cases and shrinking them.
But Hypothesis defaults — generating examples until either the
test passes a budget or finds a failure — are calibrated for
running locally with attention. A 3,700-test suite that triggers
Hypothesis's default max_examples=100 on every property test
will run for hours without finding anything new.
Testbase ships an environment-variable cap:
TESTBASE_MAX_EXAMPLES_CAP
(codecraftai-testbase/source/packages/ccai/testbase/properties/propertyrunner.py:47–79)
caps the per-test max_examples regardless of what the test
declared. CI runs at TESTBASE_MAX_EXAMPLES_CAP=10 to keep the
fast path under an hour; full-sweep runs leave the cap unset.
The cap is a runtime knob, not a test-author knob — exactly the
right shape for a build-engineer tuning the suite without editing
hundreds of test files.
Chapter 4 is the full treatment.
5. A machine-readable surface
Modern CI dashboards extract test summaries by parsing stdout. That works until the dashboard wants to do something more ambitious — diff the failing tests against last week's, group failures by which device they touched, drive an AI agent that decides whether a flake is real. Stdout doesn't survive that.
Testbase ships an agents CLI subcommand
(codecraftai-testbase/source/packages/ccai/testbase/cli/cmdtree/agents/__init__.py:18–28)
with three groups: summary, testresult, config. Each
returns structured data — extracted from the JSOS stream by a
parser shared across the subcommands
(codecraftai-testbase/source/packages/ccai/testbase/cli/cmdtree/agents/jsosparser.py)
— that an agent or dashboard can consume directly. The
agent-readable surface is the same surface a human reads;
Chapter 6 walks the contract.
6. A landscape boundary
The most subtle gap. An automation test runs in an environment — a "landscape" in the portfolio's vocabulary. The landscape includes devices, services, credentials, network reachability. The test framework needs to be aware that the landscape exists, without dragging the entire landscape implementation into the unit-test process.
Testbase solves this with a contract: TestJob exposes the
five fire_* hooks as no-op stubs. InteropLibraryTestJob
(Volume 2) overrides them to drive landscape activation. A
single-host test using TestJob directly never needs the
landscape; a distributed test using DistributedTestJob (also
Volume 2) does. The contract sits exactly at the boundary, so
that single-host test authors don't pay landscape's
import-time cost and distributed test authors get landscape
hooks where the lifecycle actually fires.
Chapter 9 carries the single-host half of this contract; Volume 2 carries the distributed half.
7. Single actor, multiple observers — tandem testing, not parallel
The least-obvious gap, and the one that most cleanly distinguishes automation testing from application testing. In a distributed environment where the machines under test interact with each other, a test is only deterministic when one actor causes effects in the environment at a time. When the controller (the test) interacts with a machine, the test must wait for the effect to propagate to all the other machines in the environment before checking that the effect took hold. Often there are multiple checks — a poll loop watching for a state, or an event subscription waiting for a state change. If more than one actor is allowed to cause effects concurrently, the ordering of events stops being deterministic, and the test stops being deterministic with it.
This is not a parallelism problem the framework can solve by adding locks. It is a determinism property of the system under test. Application testing avoids it by running the system under test inside the test process, where the test is the only controller; automation testing has to enforce it explicitly, because the system under test is outside the process and nothing prevents another actor from reaching it.
Pytest's plugin ecosystem includes pytest-xdist for parallel
test execution. That plugin is exactly the wrong tool for an
automation suite where the tests share an environment. Two
parallel tests against the same fleet will race on shared
state and the suite will turn flaky.
Testbase enforces single-actor execution by treating the test itself as the actor and providing a separate mechanism for observers:
- The test is the single actor.
- Observers are
Validatorinstances (subclassingValidatorCoupling) that the framework attaches to the test scope viaattach_to_test(testscope, suffix). They watch for effects, run in tandem with the test, and produce their own per-observer outcomes recorded alongside the test's result.
This is tandem testing: one actor + N attached observers. The mental model is a tandem bicycle: one driver pedals and steers; one or more observers ride along watching the world go by. The driver is the only one steering — that's the single-actor commitment — but the observers are full participants in the ride, with their own perspective and their own report at the end.
File: codecraftai-testbase/source/packages/ccai/testbase/tandem/validators.py.
Where parallelism does live: at the job level, across independent Pods. A Pod is a set of devices configured to interoperate with each other (Volume 6 covers Pod formation in detail). Within one Pod, jobs run sequentially because a Pod is a shared environment. Across Pods, jobs run in parallel because Pods are independent — effects in one Pod cannot propagate to another. A CI orchestration that wants more horizontal scale provisions more Pods, not more parallel tests within a Pod. Volume 3 covers the orchestration side; Volume 2 covers the distributed test side; the two volumes meet at the Pod boundary.
Where unittest still lives in the portfolio
There is a second, quieter reason this chapter is not a polemic
against the standard library: testbase does not try to replace
unittest everywhere. It replaces it almost everywhere — and
the handful of packages it deliberately leaves on unittest are
the ones that make this honesty possible.
Earlier in the portfolio's life, the set of packages that had to
stay on plain unittest was large, because testbase dragged in a
chain of dependencies — configuration, the runtime foundation,
this-app — that many low-level packages could not import without a
cycle. That chain is being severed. testbase is becoming
near-standalone: its intended dependencies are exactly
codecraftai-arcli, codecraftai-foundations (which consolidated
codecraftai-errors, codecraftai-interfaces and codecraftai-waiting), and
codecraftai-xmodules — no configuration tier,
no runtime foundation, no this-app. The core runner already lives
on that floor: TestJob reads its handful of environment knobs
directly via the dependency-free ccai.testbase.environment
module, and it resolves output and run metadata through injected
seams (IOutputContext / IRunMetadata) rather than importing the
runtime. The presence-folder results layout, the
JSOS-to-disk-with-HTML summary, and the CI/build metadata that used
to be testbase's business are now the job of IntegratedTestJob in
codecraftai-integration-runtime; plain testbase records to stdout and
runs without activation. Chapter 2 and Volume 2 walk that seam.
This is partly in-flight. The runner core is off the old chain, but
testbase's CLI and bootstrap (ccai.testbase.initialize, the
cli/ command tree, the result server) still import
ccai.runtime.* and ccai.thisapp.*, and pyproject.toml
still lists configuration, runtime, and this-app among its
dependencies. Finishing that decoupling — and trimming the
dependency list to the five-package floor — is the remaining step;
the architecture below describes the floor the package is converging
on.
The practical consequence is that testbase can now be used to test
almost every other package in the portfolio — the device
abstractions, the interop layer, the orchestration tiers, the REST
clients. A package no longer has to choose unittest just because
it sits below the framework's old dependency chain.
What remains on unittest is a small, permanent exclusion set:
codecraftai-foundations(formerly the separate errors, interfaces and waiting packages)codecraftai-xmodulescodecraftai-arclicodecraftai-extension
The rationale is a single rule with one product decision attached.
The first five are testbase's own dependencies — a framework
cannot test the packages it is built on without a bootstrap cycle,
so those stay on unittest by necessity. (testbase depends on
arcli for its own CLI, which is why arcli is on this list even
though it is otherwise a general-purpose toolkit.) The sixth,
codecraftai-extension, is the product decision: it underpins the
namespace-package extensibility model that IntegratedTestJob
registers through (the same model Volume 0 chapter 7 introduced),
and keeping it on unittest avoids a circularity between the
extension machinery and the framework that consumes it.
Everything outside that set has shifted — or can shift — to testbase. The split is not "framework versus standard library as a matter of taste"; it is a precise dependency-floor boundary, and the list above is the floor.
That shift is no longer hypothetical. Across the portfolio's
unittest-bearing packages — the configuration and this-app
foundation tiers, the credentials and networking device layers,
the integration-runtime reporting package, the timetravel-pod
tracer, the storage-database and dataprofiles libraries, and the
sysman-common tier — the tests have been converted to testbase at
the source level. Each package's tests now live as plain def test_*
functions under source/testroots/ccai/tests/… (discovered by the
runner via --includes) where they had been unittest.TestCase
methods under source/tests/. The conversion is one-to-one: every
original test method becomes exactly one test function; setUp /
tearDown state is replicated per test through module-level helper
functions (testbase tests have no class lifecycle); unittest.mock is
retained as-is; duplicate method names across sibling TestCase
classes are disambiguated rather than collided; and
IsolatedAsyncioTestCase tests are wrapped with asyncio.run. The
migration keeps both suites in parallel for safety — the legacy
unittest files remain authoritative, and the merge gate keeps
running them, until each converted suite is verified green under the
testbase runner and the package's CI is cut over to testbase testing run. (Two of the converted tiers, this-app and configuration,
are a special case: testbase still declares them among its own
dependencies, so they cannot take a testbase test-dependency — and
thus cannot run under the testbase runner — until testbase finishes
shedding that chain. Their test sources are converted; their
cutover waits on the decoupling described above.)
What this volume builds
Volume 1's ten chapters walk testbase from the outside in:
- Chapter 2 covers the architecture — the pieces that make a run go.
- Chapter 3 covers resource factories — how tests acquire and release the things they need.
- Chapter 4 covers
@givenand the property-based testing model with the CI cap. - Chapter 5 covers JSOS streaming results.
- Chapter 6 covers the
agentsmachine-readable CLI. - Chapter 7 covers testbase's own mocking primitives.
- Chapter 8 covers
arcli, the declarative CLI toolkit underneath everycodecraftai-*CLI. - Chapter 9 carries the single-host half of the landscape contract — the public API Volume 2 will extend.
- Chapter 10 is a case study: the 3,700-test single-host suite
in
codecraftai-agent-kit, withTESTBASE_MAX_EXAMPLES_CAP=10, finishing in a CI window short enough that engineers run it voluntarily.
By the end, a senior SDET should be able to read a testbase test file and know exactly which framework concept each line is using. That fluency is the prerequisite for Volume 2 (the distributed extension) and Volume 3 (orchestration-level invocation) to make sense.
A note on continuity
Every chapter in Volume 1 assumes the reader has read Volume 0
— or knows the foundation tier well enough to skip it. When a
chapter says "the explicit-return discipline" or "the
SemanticError rule" or "the Aspects pattern," it is referring
to material from Volume 0 chapters 1–4. Volume 0 anchored those
ideas; Volume 1 uses them.
When a chapter forward-references the distributed extension, it will say so explicitly: "Volume 2 chapter N covers the distributed-half of this contract." The single-host text never relies on the distributed text — but it carefully marks the seams, because Volume 2 is the volume that walks across them.
What we have not written
This volume is not a tutorial on Python testing. It assumes you can write a passing pytest test, you understand parametrisation, you have used a fixture. The volume's job is to show you how an automation-grade test framework is different — and why those differences are necessary, not preferences.
This volume is not a comparison shop. We do not survey
Robot Framework, behave, pytest-bdd, or any other Python
testing framework. Those tools have their reasons; testbase has
ours. The volume's job is to show why testbase is the shape it
is, so that a reader who needs to extend it knows where the
seams are.
This volume does not cover distributed test execution. That is Volume 2. Where the contract that distributed extends sits inside testbase, this volume marks the contract. Where the implementation lives, Volume 2 picks up.
What we have committed to
By the end of Volume 1 you will:
- Know the five
fire_*lifecycle hooks ofTestJoband what each does on a single-host run. - Know the resource-factory model and how originators register with the injection registry from Volume 0 chapter 7.
- Know the JSOS streaming format and how the
agentsCLI consumes it. - Know
arcli'sCommand/ArgField/OptionFieldmodel and the validator-and-parser hardening pattern from PR #13. - Be able to read a testbase test file and identify, line by line, which framework concept each construct represents.
That fluency is what Volume 2 builds on. And Volume 3. And parts of Volumes 6, 7, and 8. This is the substrate.
The next chapter walks the architecture itself.