Testing a Change¶
What the test suite has to prove, and what a change has to bring to it. The owner
set the direction on #1203: 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, and so is one whose parsed values differ:
from_data(info).info equals info, for a cut header too (#2761).
Coverage is enumerated from the registries rather than hand-picked, so a code registered tomorrow gets a case tomorrow. The 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 |
What an unrelated change adds to these suites lands in a file of its own or on a line
of its own, so that concurrent changes do not edit the same lines: a family goes in a
module of its generator’s family directory, and a pcapkit.const registry is a
line in its package’s module under tests/const/census/. Where a table stays one
file, as NOT_A_COPY and UNRELATED in
tests/project/test_rfc_constants_one_home_1739_unit.py and
CRAWLERS_WITH_CONSTANT_MISS_LINES in
tests/vendor/test_vendor_missing_body_unit.py do, it is sorted. A sweep holds
what it finds to these records by name rather than to a pinned count – split under
#2615.
The rules those tables carry:
Every registered code has a case.
test_every_registered_code_has_a_casefails otherwise, and so doestest_every_option_registry_has_a_familyortest_every_dispatch_table_has_a_familyon a registry map no family exercises, found from the tree’s own maps rather than from the family modules. The option suite’sSKIPtable, the merge of each family module’s ownSKIP, is the only exit, and each of its 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: the function, class or method that causes it, by name, and the issue that tracks it. Routing a case around a defect hides it: a generator that puts two parameters in every HIP packet never constructs the unrepresentable length of a lone one (#651), which is why the generator’sHIP_COPIESis one.KNOWN_DEGRADEDentries have a different shape – the fragments of the probe’s'error'value the entry accepts, any one of which may match, and a reason – and they record why the probe’s placeholder payload cannot reach the target, not a defect to fix, so the name-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 function, class or method that 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. Diff the committed fix from the
merge base, with three dots: a two-dot git diff origin/main also carries the commits
origin/main has and the branch lacks, as reverse deltas, and reverse-applying it
undoes that work in the tree.
git diff origin/main...HEAD -- 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 the test job’s Python 3.15 cell in
.github/workflows/unit-tests.yml, which runs the ALL tier, and each half
of the LATEST tier in its test-latest-tier job, with branch coverage over
pcapkit configured by .github/coverage.toml; its coverage job
combines them, and .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, whosetestjob spreads the unit tier over workers with-n logical --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.A class whose tests each need their own import purges in
setUpinstead of calling the helper, and says why in a comment there:ConstEnumRegisterFallbackTestsintests/const/test_const_enum_builtin_parity.pydoes, because a lookup in one test grows the registries’ tables the next one reads. Calling the helper and then purging the whole package, later insetUpor intearDown, throws the class’s import away while reading as if it kept it;tests/project/test_per_class_import_guard.pyfails on that (#1538).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 --half 1 python util/run_unittest_leg.py protocols --exclude protocols/internet --half 2 python util/run_unittest_leg.py project --half 1 python util/run_unittest_leg.py project --half 2
The leg list lives in the workflow, not the script. Every
tests/subdirectory is a leg excepttests/integration/, which holds no unit-tier module and runs under pytest in theintegrationjob;tests/project/test_unittest_ordering_shards.pyfails on a subdirectory left out (#2115).
Adding a Protocol, Option or Dumper¶
What each must bring in tests, 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 where the
|
a protocol |
A unit-tier |
a dumper |
Unit tests under |