Testing a Change¶
The pages before this one rule on the library, the repository and the prose. This one
rules on what the test suite has to prove, and what a change has to bring to it. The
owner set the direction on #1203, paraphrased here as on the other pages: every
round trip the library offers must pass and match exactly, the tests must cover all of
them, and the rules belong on a page rather than in review threads. #1202
extended round-trip coverage past the registries, to the pcapkit.corekit fields,
schemas and Info, the pcapkit.foundation reassembly, flow tracing, engines and
registries, and the output formats.
Round-Trip Invariants¶
A round trip either reproduces its input exactly or is a recorded defect. There is no tolerance in between: a cycle that comes back with different octets is a bug, even when both halves are internally consistent.
Coverage is enumerated from the registries rather than hand-picked, so a code registered tomorrow gets a case tomorrow. The two registry-driven suites that carry a known-failure table keep their cases in a generator, beside the captures it also writes:
Suite |
Cases from |
Asserts |
|---|---|---|
|
|
every option, chunk, parameter, message, frame and block code survives construct
-> parse -> construct with identical octets; known failures in
|
|
|
every |
The rules those tables carry:
Every registered code has a case.
test_every_registered_code_has_a_casefails otherwise. The option suite’sSKIPtable is the only exit, and each of its three entries states why a case would assert nothing:Multipath_TCPis the envelope of thetcp-mptcpfamily and would duplicate one of its subtypes, andPadNunder HOPOPT and IPv6-Opts emits the same option-less header asPad1. A code that fails is never skipped.A failing case is an entry, never a changed argument. An
EXPECTED_FAILURESentry records the status, a failure-detail fragment narrow enough to match one site, and the defect: thefile:linethat causes it and the issue that tracks it. Routing a case around a defect hides it: the generator’sHIP_COPIESonce put two parameters in every HIP packet, so the unrepresentable length of a lone one (#651) was never constructed.KNOWN_DEGRADEDentries have a different shape – a fragment and a reason – and its two record why the probe’s placeholder payload cannot reach the target, not a defect to fix, so thefile:line-and-issue rule does not apply to them.A regression is fixed; a pre-existing defect is recorded. A case that a change breaks – including a code it registers that does not close – is fixed in that change, not added to a table. A newly found defect that predates the change gets an entry naming the
file:linethat causes it, as the failure message asks, and an issue.The tables are checked in both directions. In the option suite, an entry whose case starts passing turns
test_round_trip_is_identity_or_a_recorded_gapred, and one naming a case that no longer exists failstest_expected_failures_name_real_cases; the dispatch suite has the same pair. So a fix deletes its entry in the same change.The harness guards its own shape.
test_recorded_gaps_are_a_minorityfails once recorded failures reach half the option cases (the dispatch suite’stest_degraded_cases_are_a_small_minoritydraws the line at a quarter), since that points at the harness rather than the library. Each case runs underCASE_TIMEOUT, 30 seconds in both suites, so a case that never finishes fails by name instead of hanging the run.A closed cycle is not proof of correctness. A writer and a reader that share an error round-trip perfectly: HIP’s parameter padding (#651) and IPv4’s
SIDwidth (#534) both closed the cycle while wrong. So a round trip is paired with an assertion against the specification – the RFC’s own lengths, or a known-good packet’s field values.
A lossless path that is not registry-shaped gets a capture-driven case instead:
rebuild each layer with from_data and require the original octets back, as
tests/protocols/test_declared_length_roundtrip_runtime.py does for the
declared lengths of a truncated capture.
Raising Coverage¶
Every change raises coverage, and each new test is shown to fail without its fix. A test that also passes on the unfixed tree cannot tell the fix from its absence, so it pins nothing. Before opening the pull request, take the fix out, run the new test, put the fix back, and say in the pull request that you did:
git diff origin/main -- pcapkit/ > /tmp/fix.diff
git apply -R /tmp/fix.diff
python -m pytest tests/<path>/test_<name>_unit.py -q # must fail
git apply /tmp/fix.diff
python -m pytest tests/<path>/test_<name>_unit.py -q # must pass
CI measures one leg, the test job’s Python 3.15 cell in
.github/workflows/unit-tests.yml, with branch coverage over pcapkit
configured by .github/coverage.toml; .github/workflows/coverage-comment.yml
posts the totals on the pull request. Locally, measure with coverage run rather than
a pytest plugin, which this project does not install, and narrow at report time –
[tool.coverage.run]’s source in pyproject.toml takes precedence over
--include at run time:
python -m coverage run -m pytest tests/<path>/test_<name>_unit.py -q
python -m coverage report --include='pcapkit/<path>/<name>.py'
Running the Tests¶
One module per process, never the whole suite. Check a change by running the modules it touches, each as its own
python -m pytest <module> -q, and leave the whole tier to CI, which spreads it over workers with-n auto --dist load. Every purge-then-import leaves the previous generation ofpcapkitbehind as garbage, and the whole suite in one plain-unittest process runs out of memory –util/run_unittest_leg.pyrecords the measurement.Read the summary line, not the exit code. pytest’s last line (
N passed, M failed) and unittest’sOKorFAILED (...)are the result; an exit code read through a pipe or a wrapper is the pipe’s or the wrapper’s.Keep to the tiers. The unit tier must pass on a fresh clone with no generated captures, installed as CI’s
testandunittest-orderingjobs install it,pip install -e '.[test,DPKT,crypto,NGAP]';tests/integration/,*_runtime.pyand*_regression.pyare the fixture-dependent tier, run aftermake samples. A unit-tier module builds its own octets and never reads a generated capture –tests/_tiers.pyenforces that at collection and on everysample_pathcall.Import once per class. A test class that needs a fresh
pcapkitcallsreimport_once_per_class(self)fromsetUp, in place of a per-testpurge_modules, which re-imported the package before every test and dominated the suite’s time (#1065). Both live intests/_support.py; passrestore=Truewhen the class must leavesys.modulesas it found it.Check ordering under plain unittest.
tests/conftest.pyheals cross-module import pollution after every test, so pytest alone cannot see it (#981).util/run_unittest_leg.pyruns onetests/subdirectory, then the root-level modules, in one plain-unittest process, which is what CI’sunittest-orderingjob does per leg. Run it on the directory a change touches when the change moves imports or module state:python util/run_unittest_leg.py protocols/internet python util/run_unittest_leg.py protocols --exclude protocols/internet
The leg list lives in the workflow, not the script;
tests/corekit/andtests/integration/are deliberately not legs, for the reasons recorded there.
Adding a Protocol, Option or Dumper¶
What each must bring in tests before it merges, on top of the fail-without-fix proof above:
Adding |
Must bring |
|---|---|
an option-like code |
Registering it gives it a case automatically. Add arguments to the generator’s
tables in |
a protocol |
A unit-tier |
a dumper |
Unit tests under |