Skip to content

rtb-test-support

rtb-test-support is the promoted test-side constructor for [rtb_app::App]. It provides TestAppBuilder gated behind a crate-private sealed trait whose only implementor is TestWitness. Downstream crates depend on this from [dev-dependencies] to get a consistent test-helper API.

Overview

Production App construction goes through rtb_cli::Application::builder — which also installs logging, miette hooks, panic hooks, signal handlers, and command registration. Bypassing that pipeline in production is a hazard; forgetting a hook install silently swallows errors or skips cancellation propagation.

For unit and integration tests, however, a full Application is overkill. rtb-test-support is the bypass path — opt-in via [dev-dependencies], signalled in Cargo.toml, visible to audit.

Design rationale

  • Sealed-trait TestWitness. The bypass builder takes a value of a crate-private Sealed trait. Only this crate can construct a TestWitness. Downstream crates that depend on rtb-app but not on rtb-test-support cannot call the bypass builder.
  • [dev-dependencies] placement. Production binaries that only depend on rtb-app + rtb-cli do not compile rtb-test-support in, and cannot reach TestAppBuilder at all.
  • Honest caveat (partly closed in 0.4.1). rtb_app::App had pub fields at v0.1, so any crate depending on rtb-app could construct an App via struct literal directly. Since 0.4.1 the type-erased config and typed_config_ops fields are pub(crate) (per the v0.4.1 scope addendum, A2 resolution); the remaining pub fields (metadata / version / assets / shutdown / credentials_provider) stay public for callsite ergonomics. The seal is still primarily a speed-bump + visibility signal rather than watertight access control.

Core types

TestWitness

pub struct TestWitness(());

impl TestWitness {
    pub const fn new() -> Self;
}
// Sealed: only rtb-test-support implements `sealed::Sealed` for it.

TestAppBuilder

#[must_use]
pub struct TestAppBuilder<W: sealed::Sealed> { /* ... */ }

impl TestAppBuilder<TestWitness> {
    pub const fn new(witness: TestWitness) -> Self;

    pub fn tool(self, name: &str, version: &str) -> Self;   // name + semver string
    pub fn metadata(self, m: ToolMetadata) -> Self;         // override just metadata
    pub fn version(self, v: VersionInfo) -> Self;           // override just version

    // Typed-config wiring (since 0.4.1). `config` matches the
    // production `Application::builder().config<C>(...)` for full
    // fidelity; `config_value` is the ergonomic shortcut that
    // wraps `c` in `Config::<C>::with_value(c)`.
    pub fn config<C>(self, config: Config<C>) -> Self
    where C: Serialize + DeserializeOwned + JsonSchema + Send + Sync + 'static;
    pub fn config_value<C>(self, c: C) -> Self
    where C: Serialize + DeserializeOwned + JsonSchema + Send + Sync + 'static;

    pub fn build(self) -> App;                              // panics on missing required
}

API surface

Item Kind Since
TestWitness struct 0.1.0
TestAppBuilder<W> struct (generic, sealed) 0.1.0
TestAppBuilder::{new, tool, metadata, version, build} methods 0.1.0
TestAppBuilder::{config, config_value} methods (typed-config wiring) 0.4.1

Usage

In a downstream crate's Cargo.toml:

[dev-dependencies]
rtb-test-support = { path = "../rtb-test-support" }

In a test:

use rtb_test_support::{TestAppBuilder, TestWitness};

#[tokio::test]
async fn my_test() {
    let app = TestAppBuilder::new(TestWitness::new())
        .tool("mytool", "1.2.3")
        .build();

    // `app` has default (empty) config, default (empty) assets,
    // a fresh shutdown CancellationToken.
    let result = my_command.run(app).await;
    assert!(result.is_ok());
}

Relationship to App::for_testing

rtb_app::App::for_testing is the existing #[doc(hidden)] pub fn helper used by tests within rtb-app itself. It remains in place for those internal tests. New downstream-crate tests should use rtb-test-support's TestAppBuilder — it's the promoted, more ergonomic path and its sealed-trait signature is the clearer indicator of test-only intent.

Post-0.1 work:

  1. Make App's fields pub(crate) + accessor methods.
  2. Remove App::for_testing in favour of TestAppBuilder exclusively.
  3. At that point the seal becomes actual access control.

Testing

2 acceptance criteria:

  • builder_produces_an_appTestAppBuilder::new().tool("mytool", "1.2.3").build() yields a valid App with the expected metadata and version.
  • child_token_cancellation_cascades — cancelling app.shutdown cancels tokens derived via app.shutdown.child_token().

Spec and status