Skip to content

Build an App in a test

Add the dependency

[dev-dependencies]
rtb-test-support = "0.9"
tokio = { version = "1", features = ["macros", "rt"] }

[dev-dependencies], not [dependencies]. A production binary that only depends on rtb-app cannot reach TestAppBuilder at all, and keeping it here is what makes that true.

Build the simplest possible App

use rtb_test_support::{TestAppBuilder, TestWitness};

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

    assert_eq!(app.metadata.name, "mytool");
    assert_eq!(app.version.version.major, 1);
    assert!(!app.shutdown.is_cancelled());
}

tool sets name and version together. It also sets summary to the literal "test", so do not assert on the summary after using it. If the test cares about other metadata, build the metadata yourself:

let app = TestAppBuilder::new(TestWitness::new())
    .metadata(
        ToolMetadata::builder()
            .name("mytool")
            .summary("does the thing")
            .update_policy(UpdatePolicy::Prompt)
            .build(),
    )
    .version(rtb_app::version_info!())
    .build();

Call tool before metadata if you use both — tool overwrites metadata and version, so calling it second throws away what you just set.

Drive a command

Command::run is async, so the test needs a runtime:

#[tokio::test]
async fn deploy_succeeds() {
    let app = TestAppBuilder::new(TestWitness::new()).tool("mytool", "1.0.0").build();
    Deploy.run(app).await.expect("deploy should succeed");
}

Call run on the concrete type. Going through BUILTIN_COMMANDS works too, but it gives you every command every linked crate registered — including ones other test binaries in the same workspace put there.

Wire typed configuration

#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
struct Settings { host: String, port: u16 }

#[test]
fn command_reads_the_host() {
    let app = TestAppBuilder::new(TestWitness::new())
        .tool("mytool", "1.0.0")
        .config_value(Settings { host: "example.com".into(), port: 8080 })
        .build();

    let settings = app.typed_config::<Settings>().expect("wired");
    assert_eq!(settings.get().host, "example.com");
}

Your config type needs Serialize, Deserialize and JsonSchema here — a stricter set than App::new asks for, because the schema and the rendered value are generated eagerly.

Use config(...) instead of config_value(...) when the test needs layered defaults and overrides rather than a single merged value:

.config(Config::<Settings>::builder()
    .embedded_default("host: localhost\nport: 8080\n")
    .build()?)

Either one makes app.config_schema() and app.config_value() return Some. Calling neither leaves both None.

Test the cancellation path

The shutdown token is a fresh root token. Derive a child and cancel the parent:

let app = TestAppBuilder::new(TestWitness::new()).tool("t", "1.0.0").build();
let child = app.shutdown.child_token();
app.shutdown.cancel();
assert!(child.is_cancelled());

There is no builder method to supply your own token, so a test that needs the App to be a child of an outer scope has to cancel app.shutdown directly.

Attach a credentials provider

The builder has no method for this, but the field is public:

let mut app = TestAppBuilder::new(TestWitness::new()).tool("t", "1.0.0").build();
app.credentials_provider = Some(std::sync::Arc::new(MyConfig::default()));
assert_eq!(app.credentials().len(), 2);

The same applies to trailing args, via App::with_trailing_args(...) on the built App.

What build() panics on

Missing Message
metadata TestAppBuilder: metadata not set
version TestAppBuilder: version not set
an unparseable version string passed to tool parse test version

All three are panics, not Results. That is deliberate for a test helper.