Skip to content

Why test construction is sealed but not locked

What production construction installs

rtb_cli::Application::builder().build() does more than assemble an App. It sets up logging, installs the miette diagnostic hook, installs a panic hook, wires signal handlers to the shutdown token, and registers commands from BUILTIN_COMMANDS.

Skipping that pipeline in production is a genuine hazard, and the failure is quiet: without the error hook, diagnostics render as bare Debug output or vanish; without the signal wiring, Ctrl-C does not propagate cancellation and subsystems keep running.

So the framework wants construction to be deliberate — obvious in review, obvious in a Cargo.toml.

What a test actually needs

A unit test does not want any of that. It wants an App with a name, a version and maybe a config, so it can call a command's run and assert on the result. Installing global hooks from a test is worse than useless: the hooks are process-wide, tests run in parallel, and whichever test installed first wins.

Hence a second, smaller constructor — and the question of how to stop it leaking into production.

The mechanism, and what it prevents

TestAppBuilder::new takes a TestWitness, a zero-sized type that implements a crate-private Sealed trait. Only rtb-test-support can implement Sealed, so only a crate that depends on rtb-test-support can produce the witness.

Two things follow, and both are real:

  • Depending on the bypass is visible. It is a named line in a Cargo.toml, not an import buried in a file nobody opens.
  • [dev-dependencies] placement is enforceable by convention. A production binary depending only on rtb-app and rtb-cli does not compile rtb-test-support at all, and cannot name TestAppBuilder.

What it does not prevent

The seal is a speed bump, and the crate says so rather than overclaiming. Two ways around it remain, both fully supported by the public API:

  • App::new is pub. It takes five arguments and builds a complete App. There is nothing test-only about it — rtb-test-support itself calls it — and nothing stops production code doing the same and skipping the whole rtb-cli pipeline.
  • App::for_testing is pub. It is #[doc(hidden)], so it does not appear in the rendered API docs, but it is not cfg(test)-gated and there is no Cargo feature guarding it. Any crate depending on rtb-app can call it.

Three of App's fields are pub(crate)config and typed_config_ops as of 0.4.1, trailing_args as of 0.7.0 — so an App can no longer be assembled by struct literal. That closed part of the gap. The rest of the fields stay public for call-site ergonomics, and the two public constructors remain.

Where this is heading

The direction of travel is that the seal becomes actual access control rather than a signal: App::for_testing goes away in favour of TestAppBuilder, and the remaining public fields move behind accessors. Until that happens, treat the seal as what it is — a clear statement of intent that a reviewer can see, not a boundary the compiler enforces.

Being explicit about that matters more than the mechanism. A seal described as watertight, that is not, is worse than a speed bump described as a speed bump.