Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

AppHost and with_app

AppHost creates a scenario whose system under test is supplied by application deployments instead of an outer managed node topology.

The core scenario engine models one Application and a uniform cluster of its nodes. For a composed stack containing a binary, an additional cluster, or several applications, start from AppHost::scenario() and register the stack with .with_app(...). Workloads, expectations, run duration, and teardown follow the lifecycle in Scenario Model and Lifecycle.


The Zero-Node Scenario

AppHost::scenario() returns a ScenarioBuilder<AppHostEnv> seeded with AppHostTopology:

TypeRole
AppHostTopologyDeployment descriptor with node_count() == 0. The outer scenario manages no nodes.
AppHostEnvNull environment: NodeClient = (), and build_node_client always errors. Clients come from app handles instead.
AppHostScenarioBuilderAlias for ScenarioBuilder<AppHostEnv>.
AppHostDeployerBackend-neutral runner assembly for app scenarios. deploy(&scenario) prepares every registered application and returns the Runner; the app provisioners own the resources.

Because the outer topology is empty, app deployments create the processes and clusters used by the run.

use testing_framework_app::{AppHost, AppHostDeployer, AppScenarioBuilderExt, ClusterApp};

let mut scenario = AppHost::scenario()
    .with_app(ClusterApp::<KvEnv>::new(KvTopology::new(3)))
    .with_run_duration(Duration::from_secs(5))
    .with_workload(KvClusterAccessible::new(3))
    .build()?;

let runner = AppHostDeployer.deploy(&scenario).await?;
runner.run(&mut scenario).await?;

with_app provisions clusters on local processes by default. with_app_using selects a backend provisioner per app instead — the same deployment moves to Docker Compose or Kubernetes without changing the rest of the scenario:

let mut scenario = AppHost::scenario()
    .with_app_using(
        ClusterApp::<KvEnv>::new(KvTopology::new(3)),
        ComposeProvisioner::default(),
    )
    .build()?;

let runner = AppHostDeployer.deploy(&scenario).await?;
runner.run(&mut scenario).await?;

The app declaration remains backend-neutral: it receives published endpoints for test clients, internal endpoints for service dependencies, and portable per-service lifecycle handles.

The runnable kvstore_basic_convergence binary uses this structure:

cargo run -p kvstore-examples --bin kvstore_basic_convergence

How with_app Runs

AppScenarioBuilderExt::with_app(app) wraps your AppDeployment in an AppDeploymentFactory and registers it as a runtime extension factory, the same lifecycle hook covered in Runtime Extensions. Going through the extension mechanism ties managed deployment cleanup to the scenario lifetime and makes exposed handles available during the run.

flowchart LR
    B["with_app(app)"] --> F[AppDeploymentFactory]
    F -->|prepare| C[DeployContext]
    C -->|"deploy(root app)"| H["handles + cleanup"]
    H --> R[AppRuntime extension]
    R -->|require_app| W[Workloads]
    H:::hd
    R:::hd
    W:::sc
    classDef hd stroke:#4caf7d,stroke-width:2.5px;
    classDef sc stroke:#9b6dd6,stroke-width:2.5px;

During scenario preparation the factory:

  1. Clones your app (this is why the factory requires Clone) and builds a fresh DeployContext.
  2. Runs the root deployment’s deploy, which may deploy and expose child apps.
  3. Auto-exposes the returned root handle if the deployment did not expose one of that type itself (!ctx.contains::<A::Handle>()).
  4. Transfers the handle registry and cleanup stack into an AppRuntime extension.

If any step fails, the partially built context is dropped and every resource deployed so far is released (see Handle Ownership and Teardown).

A scenario accepts multiple with_app registrations: every AppDeploymentFactory produces the same extension type (AppRuntime), and the runtime merges those registrations into one handle registry, chaining their cleanups in registration order. What must stay unique are the handles themselves — two apps exposing the same handle type and name fail during preparation with a duplicate-handle error, so distinct apps either expose distinct types or use named handles. Larger stacks whose parts depend on each other are still clearest as one root AppDeployment that deploys children in order, as shown in Composing Heterogeneous Stacks.


Retrieving Handles in Workloads

Workloads never see the deploy context. They retrieve exposed handles through AppRunContextExt, implemented on RunContext<E>:

MethodReturns
app::<T>()Option<T> — default handle for T, if exposed
app_named::<T>(name)Option<T> — named handle for T
require_app::<T>()Result<T, DynError> — errors if missing
require_app_named::<T>(name)Result<T, DynError> — errors if missing
use testing_framework_app::AppRunContextExt;

async fn start(&self, ctx: &RunContext<AppHostEnv>) -> Result<(), DynError> {
    let cluster = ctx.require_app::<ClusterHandle<KvEnv>>()?;
    cluster.restart_node("node-0").await?;
    cluster.wait_node_ready("node-0").await?;
    Ok(())
}

Workloads normally use the require_* variants so that a missing handle produces a typed error containing the requested handle type.

Every retrieval clones the handle. Handles are normally small access values backed by Arc; scenario cleanup still determines managed resource lifetime.


Where to Go Next