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:
| Type | Role |
|---|---|
AppHostTopology | Deployment descriptor with node_count() == 0. The outer scenario manages no nodes. |
AppHostEnv | Null environment: NodeClient = (), and build_node_client always errors. Clients come from app handles instead. |
AppHostScenarioBuilder | Alias for ScenarioBuilder<AppHostEnv>. |
AppHostDeployer | Backend-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:
- Clones your app (this is why the factory requires
Clone) and builds a freshDeployContext. - Runs the root deployment’s
deploy, which may deploy and expose child apps. - Auto-exposes the returned root handle if the deployment did not expose one of that type itself (
!ctx.contains::<A::Handle>()). - Transfers the handle registry and cleanup stack into an
AppRuntimeextension.
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>:
| Method | Returns |
|---|---|
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
- AppDeployment and DeployContext: implementing the deployment itself.
- One Binary: LocalProcessApp and Uniform Clusters: ClusterApp and ClusterHandle: the two built-in building blocks.
- Backend Scope: what each backend provisioner supports.