Testing Strategy

The eight testing layers that back this crate: schema drift guards, golden corpus, snapshots, property tests, fuzzing, cross-implementation compatibility, doctests, and the feature matrix.

rubo4e uses eight distinct testing layers. Each layer has its own purpose, test corpus, and command to run.

πŸ”—Test Layer Summary

LayerPurposeFeature flagsLocationApprox. runtime
0. Drift guardCommitted codegen matches the schemasversionedtests/generated_contract.rs< 1 s
1. GoldenWire compatibilityjson, versionedtests/golden/< 5 s
2. SnapshotSerialization stabilityschemarstests/snapshots/< 5 s
3. PropertyIdentifier invariants(dev dep only)tests/proptest_roundtrips.rs30–60 s
4. FuzzPanic safetynightly + jsonfuzz/minutes (CI: 1M runs)
5. CompatCross-impl interopjson, versionedtests/compat/< 10 s
6. DoctestDocumentation is executableallsrc/** rustdoc comments~50 s
7. Feature matrixEvery feature builds warning-free(per combination)CI job / just lint-featuresminutes

Compiling a feature combination is not the same as running it. time and decimal replace field types, so the generator emits a second declaration for each affected field, and --all-features compiles only the primary one. just test-schema-fallback runs the suite with those two off β€” the only configuration where the fallback declarations exist. It is part of just ci.

Two Criterion benches sit alongside them, measured rather than asserted: benches/json_perf.rs for the three serialization modes and benches/timeseries_perf.rs for the coverage audit at a day, a month and a settlement year, across clean, gappy, duplicated and reversed series.

πŸ”—Layer 0 β€” Schema Drift Guards

src/generated/ is committed, so nothing at build time forces it to agree with generator/schemas/. These tests read both sides and compare.

Run:

cargo test --features versioned --test generated_contract

What the tests check:

  • Every schema emitted exactly one Rust module, and no module is left over
  • Every struct stamps the _typ its schema pins with a const β€” BOs and COMs
  • Every struct stamps the _version its schema declares as a default
  • BoTyp / ComTyp variants are named after the structs they discriminate
  • No two BO4E wire values collapse onto one Rust enum variant

just check-docs-drift complements this by regenerating into a scratch copy and diffing, which catches changes these assertions do not name.

The pinned schema tag is not written out anywhere: every test, recipe, workflow step, and the site config derive it from the single committed snapshot directory under generator/schemas/. pinned_tag.rs fails the build if one starts pinning a literal, and checks the same for the MSRV. A within-series bump therefore touches the snapshot, the codegen, and the changelog β€” not a scattering of strings.

More guards of the same kind sit alongside them, in tests/:

TestCatches
prelude_surface.rsan identifier type reachable via rubo4e::identifiers:: but missing from the prelude β€” and any identifier whose Borrow<str> disagrees with its Hash / Ord, which silently breaks HashMap::get(&str)
extension_round_trip.rsthe snake_case key transform renaming keys inside extension data
hash_keys.rsgenerated types deriving Hash without Eq, which no HashMap key can use
json_strictness.rsa JSON entry point that stops at the end of the first value and ignores the rest, or one that skips the nesting-depth cap β€” either makes this crate accept a payload serde_json rejects
site_examples.rsa README or site snippet that stopped compiling, or stopped meaning what the surrounding prose says
interval_conventions.rsa validator or accessor reading an interval bound the opposite way to the schema it came from
pinned_tag.rsa schema tag or MSRV written out in a workflow, recipe, or the site config instead of derived
lokationsbuendel.rsa bundle audit that stops seeing a departure from the structure it declares β€” and every published structure's own minimum being rejected by it
intervals.rsLastgang, Zeitreihe and Energiemenge disagreeing on the energy they carry, or a round trip through IntervalReading losing something the schema has a field for
zusatz_attribut.rsa generated type losing the namespaced accessors, or a namespaced attribute not surviving the BO4E wire format
modell2.rsany of the six BK6-20-160 Modell-2 answers going stale β€” including the two negative ones: that Zeitreihentyp is exactly the DE7111 Summenzeitreihen list, and that a mobile Marktlokation still validates

πŸ”—Layer 1 β€” Golden Schema Tests

Deserialize official BO4E JSON payloads and re-serialize; compare field values.

Run:

cargo test --features json,versioned --test golden

Corpus location: tests/golden/

tests/golden/
β”œβ”€β”€ vertrag_minimal.json        # only _typ + _version
β”œβ”€β”€ vertrag_typical.json        # common fields populated
β”œβ”€β”€ marktlokation_minimal.json
β”œβ”€β”€ marktlokation_typical.json
β”œβ”€β”€ messlokation_minimal.json
β”œβ”€β”€ messlokation_typical.json
β”œβ”€β”€ netzlokation_minimal.json
β”œβ”€β”€ netzlokation_typical.json
β”œβ”€β”€ rechnung_minimal.json
β”œβ”€β”€ rechnung_typical.json
β”œβ”€β”€ lastgang_minimal.json       # only the required zeitIntervallLaenge
β”œβ”€β”€ lastgang_typical.json       # a clean quarter-hourly hour in kW
β”œβ”€β”€ zeitreihe_minimal.json
└── zeitreihe_typical.json      # the same hour as kWh readings

Files are not nested in a version subdirectory β€” all live directly under tests/golden/. The schema version is encoded in each file’s "_version" field.

What the test checks:

  • Deserialization does not return an error
  • Re-serialized output with to_json_german() deserializes back to a value equal to the original (field values identical; key ordering not required to match)
  • Unknown fields in the payload are preserved in _additional and survive the round-trip

The two time-series fixtures carry a second assertion beyond the round-trip: the Lastgang must audit clean (is_usable(), full coverage, 450 kWh integrated) and the Zeitreihe must sum to the same 450 kWh. That pins them as the reference shape a producer should emit, not merely as something that survives a round-trip β€” see Time Series & Units.

πŸ”—Layer 2 β€” Snapshot Serialization Tests

Verify that canonical and German serialization output does not change unexpectedly. Uses insta for snapshot management.

Run:

cargo test --features schemars --test schemars_snapshots

Update snapshots after intentional changes:

cargo insta review

Snapshots are committed to the repository. A changed snapshot in CI is a CI failure that requires explicit review and acceptance with cargo insta accept.

πŸ”—Layer 3 β€” Property-Based Tests

Verify identifier round-trip invariants, serde correctness for date types, and enum Display/FromStr for all generated variants.

Run:

cargo test --all-features --test proptest_roundtrips

Note: proptest is a plain dev-dependency, and the Arbitrary impls for identifier types are #[cfg(test)] only β€” not available to external crates.

Properties covered:

// Identifier: Display ↔ FromStr round-trip
proptest! {
    fn malo_id_display_from_str_roundtrip(s in valid_11digit()) {
        let id = MaloId::new(&s).unwrap();
        prop_assert_eq!(id.to_string().parse::<MaloId>().unwrap(), id);
    }
}

// Serde round-trip for required time::Date
proptest! {
    fn required_date_serde_roundtrip(date in any_date()) {
        // serializes as "YYYY-MM-DD", deserializes back to the same Date
    }
}

// Enum: Display ↔ FromStr round-trip over all known variants (strum)
proptest! {
    fn sparte_display_from_str_roundtrip(variant in any_sparte()) { … }
}

Also covered: opt_date_serde None/Some round-trips, JSON null β†’ None deserialization, ISO 8601 wire-format assertion ("YYYY-MM-DD").

πŸ”—Layer 4 β€” Fuzz Testing

Feed arbitrary bytes to the deserialization path and verify no panics occur. Requires nightly Rust.

Setup:

cargo install cargo-fuzz

Run (CI β€” 1 million iterations):

cargo +nightly fuzz run fuzz_deserialize_vertrag -- -runs=1000000

Run (continuous β€” local development):

cargo +nightly fuzz run fuzz_deserialize_vertrag

Targets:

fuzz/fuzz_targets/
β”œβ”€β”€ fuzz_deserialize_marktlokation.rs  β€” identifiers, Ortsangabe exclusivity
β”œβ”€β”€ fuzz_deserialize_vertrag.rs        β€” date-time ordering
β”œβ”€β”€ fuzz_deserialize_rechnung.rs       β€” multi-Betrag arithmetic, currency agreement
β”œβ”€β”€ fuzz_deserialize_kosten.rs         β€” Kostenposition line-total arithmetic, two levels down; the `Value` reader and both recursive walks
β”œβ”€β”€ fuzz_deserialize_bilanzierung.rs   β€” nested temporal ranges
β”œβ”€β”€ fuzz_deserialize_lastgang.rs       β€” large Zeitreihenwert arrays; depth and budget limits; the coverage audit
β”œβ”€β”€ fuzz_deserialize_zeitreihenwert.rs β€” the hot path in batch market-data processing
└── fuzz_parse_identifiers.rs          β€” every identifier parser, the duration and time-of-day parsers

Each BO target runs three separate code paths over the same bytes β€” serde_json::from_slice, the hardened German reader, the hardened snake_case reader β€” and validates whatever decoded. The Lastgang target additionally runs the coverage audit: it parses every startuhrzeit off the wire, joins each with its date, sorts the results and accumulates time::Durations over them β€” and Duration addition panics rather than saturating, so the accumulation is fuzzed rather than reasoned about. The Kosten target runs the two recursive walks β€” extension_paths and unknown_enum_paths β€” because collect_extension_paths is the one place a JSON-path is assembled from bytes the payload chose rather than from the schema, and it runs the serde_json::Value reader, which is a fourth deserializer over the same input. The validators do Decimal arithmetic over wire values, and rust_decimal panics rather than errors on several of its constructors, so a validator that aborts on a decodable payload is as exploitable as a deserializer that does. Hence validate in the fuzz build alongside time and decimal.

What constitutes a fuzz failure:

  • Any panic (including unwrap, expect, index out of bounds)
  • Stack overflow
  • Memory safety violation

An Err return from from_slice is not a failure β€” malformed input is expected to return an error, not panic.

Reproducing a crash:

cargo +nightly fuzz run fuzz_deserialize_vertrag fuzz/artifacts/fuzz_deserialize_vertrag/<id>

πŸ”—Layer 5 β€” Cross-Implementation Compatibility

Verify that rubo4e correctly deserializes payloads produced by the Python and Go reference implementations.

Run:

cargo test --features json,versioned --test compat

Corpus location:

tests/compat/
β”œβ”€β”€ README.md           β€” how to regenerate vectors
β”œβ”€β”€ python/
β”‚   β”œβ”€β”€ marktlokation.json
β”‚   β”œβ”€β”€ messlokation.json
β”‚   β”œβ”€β”€ rechnung.json
β”‚   └── vertrag.json
└── go/
    β”œβ”€β”€ marktlokation.json
    β”œβ”€β”€ messlokation.json
    β”œβ”€β”€ rechnung.json
    └── vertrag.json

What the test checks:

  • Deserialization does not error
  • Specific field values are asserted (not just "no error") β€” at least 3 fields per payload

Regenerating vectors: See tests/compat/README.md for instructions on how to regenerate when either reference implementation releases a new version.

πŸ”—Layer 6 β€” Doctests

Every code block in a rustdoc comment is compiled and run. There are no rust,ignore blocks anywhere in the crate: an example that cannot be executed is written as a ```text block so it is never mistaken for verified code.

Run:

cargo test --all-features --doc

This matters more here than in most crates because the majority of the public API is generated. The per-enum iter_known and from_wire examples are emitted by the generator with real assertions β€” including a positive wire β†’ variant mapping taken from the schema β€” so a generator change that breaks the documented behaviour fails the build instead of silently producing wrong documentation.

Examples that need a feature the doctest harness may not have are wrapped rather than ignored, so they compile in every configuration:

/// ```
/// # #[cfg(feature = "json")] {
/// // …example needing `json`…
/// # }
/// ```

Examples needing external resources (a live database) use no_run: they are still type-checked, just not executed.

πŸ”—Layer 7 β€” Feature Matrix

--all-features is not sufficient to prove the crate builds. It cannot catch:

  • code that is dead unless an optional dependency is enabled,
  • bindings left unread when a feature compiles a function body away,
  • a feature that does not build at all on its own.

Every feature is therefore checked in isolation and in realistic combinations, with warnings denied.

Run:

just lint-features

In CI this is a matrix job, so a failing combination names itself in the job list.

πŸ”—Keeping the fuzz targets alive

fuzz/ declares its own [workspace], so cargo check --workspace does not see it. just check-fuzz (and a CI step) type-checks the targets on stable; only running them needs nightly.

The targets build with time and decimal enabled: those two features replace String fields with time::OffsetDateTime, time::Date, and rust_decimal::Decimal β€” three parsers over attacker-controlled text that are not compiled in at all without them.

πŸ”—CI Safety Notes

When piping test output through tee in CI scripts, enable set -o pipefail (or check PIPESTATUS) to prevent a failing test command from appearing to succeed:

set -o pipefail
cargo test --features json,versioned --test golden 2>&1 | tee test-output.log

Without pipefail, a non-zero exit from cargo test is masked by tee's success.

πŸ”—Running the Full Suite

# All unit and integration tests (default features)
cargo test --workspace

# All tests with all features
cargo test --workspace --all-features

# Just golden corpus tests
cargo test --features json,versioned --test golden

# Identifier + serde + enum property tests (no extra feature flag needed)
cargo test --all-features --test proptest_roundtrips

# Cross-impl compatibility
cargo test --features json,versioned --test compat

# schemars snapshot tests
cargo test --features schemars --test schemars_snapshots

# Validation integration tests
cargo test --all-features --test validation

# Doctests only
cargo test --all-features --doc

# Every feature combination, warnings denied
just lint-features

# Fuzz (nightly, 1M iterations)
cargo +nightly fuzz run fuzz_deserialize_vertrag -- -runs=1000000

# Everything CI runs
just ci