Testing
This section describes how to test routes with the lean camel test boot and with route interception. Interception rewrites to: send points at compile time. It supports isolated unit tests without mock: lines in production routes.
Route interception
Use route interception to replace or copy a send without changing the route. Rules run at compile time. Validation happens once, in InterceptRules::new; the compiler consults the frozen rules at each send point.
Two actions exist:
SkipToreplaces the original send. The exchange goes only to themock:target.DivertCopyTocopies the exchange to amock:target and then runs the real producer. The copy uses WireTap semantics: detached when the bound (20) admits it, inlineCallerRunswhen saturated.
Targets must be mock: URIs. InterceptRules::new rejects other targets at build time. The match is exact URI, first-match-wins.
Rules freeze at first successful route registration or at context start. After freeze, set_intercept_rules returns CamelError::Config. Use CamelContextBuilder::with_intercept_rules before freeze.
SkipTo example
use camel_core::intercept::{InterceptAction, InterceptRule, InterceptRules};
use camel_core::{CamelContext, RouteDefinition};
use camel_core::route::BuilderStep;
let rules = InterceptRules::new(vec![InterceptRule {
uri: "seda:out".into(),
action: InterceptAction::SkipTo { uri: "mock:tap".into() },
}])?;
let mut ctx = CamelContext::builder()
.with_intercept_rules(rules)
.build()
.await?;
ctx.add_route_definition(
RouteDefinition::new("direct:in", vec![BuilderStep::To("seda:out".into())])
.with_route_id("send-route"),
)
.await?;
The send to: seda:out never reaches seda:. The exchange goes to mock:tap only. The seda: producer is not resolved.
DivertCopyTo example
use camel_core::intercept::{InterceptAction, InterceptRule, InterceptRules};
use camel_core::{CamelContext, RouteDefinition};
use camel_core::route::BuilderStep;
let rules = InterceptRules::new(vec![InterceptRule {
uri: "kafka:orders".into(),
action: InterceptAction::DivertCopyTo { uri: "mock:orders-copy".into() },
}])?;
let mut ctx = CamelContext::builder()
.with_intercept_rules(rules)
.build()
.await?;
// Consumer route still receives the real message.
ctx.add_route_definition(
RouteDefinition::new("kafka:orders", vec![BuilderStep::To("mock:arrival".into())])
.with_route_id("consumer"),
)
.await?;
ctx.add_route_definition(
RouteDefinition::new("direct:in", vec![BuilderStep::To("kafka:orders".into())])
.with_route_id("send"),
)
.await?;
The exchange goes to mock:orders-copy and to the real kafka:orders producer. A failure in the copy does not change the real outcome.
For processor composition, camel_processor::compose_divert builds the same divert from a WireTapService copy stage and a BoxProcessor real stage. The runtime owns the lifecycle: WireTapLifecycle::start reopens admission with a fresh token and tracker after restart.
Further detail lives in crates/camel-core/CONTEXT.md and crates/camel-processor/CONTEXT.md. The contract is defined in ADR-0064.
Declarative camel test
camel test loads each *.test.yaml document. The document selects route files, injects direct: inputs, and asserts mock: expectations. An optional intercepts block adds route interception without editing production routes. camel run ignores test documents and never parses the intercepts block.
Intercepts
Declare intercepts as a map from source URI to an action object. The object holds exactly one key: skipTo or divertCopyTo. The value must be a mock: URI.
intercepts:
kafka:orders:
skipTo: mock:orders
seda:audit:
divertCopyTo: mock:audit
skipTo replaces the original send before the compiler resolves the source component. The real component does not need to be in the lean set, and the exchange never reaches it. divertCopyTo copies the exchange to the mock: target and then runs the real producer. The real component must be in the lean set, because the compiler still resolves it. Divert uses WireTap semantics: detached when the bound admits it, inline CallerRuns when saturated. A failure in the copy does not change the real outcome.
Target and expectation share the endpoint name. skipTo: mock:orders and expects: {mock:orders: {count: 1}} both resolve to endpoint orders on the mock: component. Use the same name in both places to collect the intercepted exchange.
Matching uses the full URI verbatim. Query parameters are part of the key. kafka:orders does not match kafka:orders?x=1. List the exact URI that the route sends to.
Failure handling stays unchanged. Parse errors in the intercepts map and route-load errors from interception (for example, a divertCopyTo whose source has no registered component) are document errors. camel test reports them on stderr and exits with code 2. No endpoint result counts toward passed or failed in that case.
The contract lives in ADR-0064 and the route-interception spec (openspec/specs/route-interception/spec.md in the repository — outside the rendered book).
camel lint warns R-MOCK-IN-PRODUCTION on inline to: mock: and endpoints: mock: sends in route files. The warning is exempt for tests/fixtures/ paths and *.test.yaml documents. Migrate the send to an intercepts: block in a *.test.yaml document, as described above.
Bean stubs
A beans: block declares stub beans for the bean: steps in the routes. A stub bean is an in-process processor registered in the bean registry before the context boots. The bean: step resolves against it, so the test runs without a real bean implementation. The block maps a bean name to a declaration.
beans:
validator:
kind: echo
enricher:
kind: setBody
config:
body: enriched
Each declaration has a kind and an optional methods list and config map. The kind selects the stub behavior.
| Kind | Config | Behavior |
|---|---|---|
echo | none | Passes the exchange through untouched. |
setBody | body (required) | Replaces the input body with the configured string. |
fail | message (optional) | Fails with the configured message. Without message, it fails with exactly fail bean <name>. |
echo accepts no config keys. setBody requires body and rejects any other key. fail accepts only message. A config key that does not fit the kind is a document error.
The methods list is an allowlist. When omitted, the stub accepts every method the routes invoke on it. When present, the runner cross-validates it against the methods the routes call before boot. A route that calls a method outside the list is a document error and exits with code 2.
A fail stub surfaces as a document error. The runner reports it on stderr and exits with code 2. Settling and evaluation are skipped. The default message fail bean <name> uses the declared bean name.
The stub beans mirror the bean: step. The step looks up a bean by name and calls a method on it. The stub supplies that lookup in the test. See Bean for the step contract. The example pair lives in examples/yaml-dsl/config/beans-demo.yaml and beans-demo.test.yaml.
Endpoint expectations
expects maps a mock: endpoint name to an expectation object. The object may hold count, minCount, maxCount, a bodies list, and a headers map. count is mutually exclusive with minCount and with maxCount. minCount together with maxCount means the inclusive range [minCount, maxCount]; minCount above maxCount is a document error. An explicit maxCount: 0 asserts absence: the endpoint must receive no exchanges during the settle window. Example: expects: {mock: out: {minCount: 1, maxCount: 2}} passes with 1 or 2 exchanges and fails with 3.
bodies uses strict grammar. Each entry is a bare string or a single-key matcher map. A bare string is exact equality (equals). A map with one recognized body-matcher key selects that matcher. Any other form is a document error. camel test exits with code 2 and names the field and the key.
Body matchers in v1: equals, regex, contains, startsWith, endsWith, exists, jsonSubset. exists takes null and takes no argument. jsonSubset takes a JSON object. A regex value must be a valid pattern. The runner rejects an invalid pattern at parse time and exits with code 2.
headers values use dual grammar. Any literal JSON value stays exact structural equality (equals). A map whose sole key is equals, regex, or exists selects that matcher. Any other value stays a literal. jsonSubset on a header is a document error. camel test exits with code 2.
expects:
mock:result:
count: 2
bodies:
- regex: "^order-[0-9]+$"
- jsonSubset: {status: "ok"}
headers:
X-Trace: { regex: "^[a-f0-9]{8}$" }
mode: {batch: 1, predicate: "raw"}
jsonSubset requires a JSON object pattern. The received body may be Body::Json or Body::Text that parses as JSON. A text body that does not parse fails the matcher. The received top-level JSON must be an object. Objects match recursively. Every pattern key must exist with a matching value. Nested objects match by subset. Arrays compare exactly by length, order, and element equality. Extra fields in the received object do not fail the assertion.
A sole predicate key is reserved. camel test rejects it with predicate matchers are not supported and exits with code 2. This applies in every matcher position. A multi-key object that contains predicate stays a literal in dual positions. It does not select a matcher.
Matcher mismatches are assertion failures. camel test prints a FAIL line that names the matcher, its pattern, and the received value. The received value is rendered whole. The document exits with code 1. Parse-time errors (invalid regex, non-object jsonSubset, wrong key count) exit with code 2.
Migration note: only literals whose single key is a matcher key change meaning in dual positions (expectReply.body, expects.headers values, expectReply.headers values). For example expectReply: {body: {equals: "x"}} previously meant literal equality of {"equals": "x"}. With matchers it selects equals "x". Wrap the literal to keep the old meaning: body: {equals: {equals: "x"}}. expects.bodies entries were strings before, so matcher maps there add no migration. A sole predicate key, or a sole jsonSubset key on a header, parsed as a literal before; it now fails at parse with exit 2.
sequence asserts cross-endpoint arrival order. It is a top-level list of mock: endpoint refs in the order the arrivals must have happened. The list needs at least two entries; fewer is a document error and camel test exits with code 2. Duplicates are allowed: the same endpoint may appear for consecutive arrivals. Each entry must carry the mock: scheme; the prefix is stripped to the bare endpoint name exactly as for an expects key, so mock:probe-a addresses the endpoint probe-a.
The assertion projects the arrivals at the listed endpoints in global arrival order and requires the projection to equal the declared list exactly. Arrivals at unlisted endpoints are ignored, so the assertion narrows to the probes that matter while expects handles the rest. A mismatch is an assertion failure: camel test prints a FAIL line naming the first divergence — the position, the expected endpoint, and the actual endpoint — and exits with code 1. Parse errors (fewer than two entries, an entry that is not a mock: URI or names an empty endpoint path) exit with code 2.
Probe pattern
Observe an intermediate route send by diverting a copy to a probe endpoint, then assert the order of those observations with sequence:. divertCopyTo copies the exchange to the probe before the real send continues, so the route is unchanged.
intercepts:
seda:audit: {divertCopyTo: mock:probe-a}
seda:persist: {divertCopyTo: mock:probe-b}
expects:
mock:probe-a: {count: 1}
mock:probe-b: {count: 1}
sequence: [mock:probe-a, mock:probe-b]
Cross-endpoint order is deterministic only between causally-ordered sends — sequential route steps, reply chains. Two concurrent branches that race to different probes produce a happened-order that sequence: faithfully reports but that is nondeterministic between runs. Assert order only over sends the route orders; concurrent branches assert happened-order only.
Reply assertions
An input may declare expectReply to assert against the reply message the direct: producer returns. The block holds two optional keys: body and headers. At least one must be present. An empty expectReply is a document error.
inputs:
- to: "direct:enrich"
body: "plain"
expectReply:
body: "enriched"
expectReply.body uses dual grammar. Every bare scalar (string, number, boolean, null) and every array is literal equals. A string becomes Body::Text. Other scalars and arrays become Body::Json. An object with one recognized body-matcher key selects that matcher. Any other object is literal equals with structural equality. expectReply.headers values use the same dual header grammar as expects.headers. The reply must satisfy every expected header. Extra headers on the reply do not fail the assertion.
The reply message is the route output when the route set one. Otherwise it is the final input message. Nothing in the lean camel test component set sets the output today. The reply pairs with the input by delivery order. Inputs deliver strictly sequentially, so reply[i] matches the i-th input.
Each asserted input produces one result row labeled reply[i] <input.to>. A mismatch is an assertion failure. It surfaces as a FAIL line and counts toward failed. The document exits with code 1. A delivery error is a document error. It exits with code 2 and skips reply evaluation.
A document may omit expects when at least one input declares expectReply. The reply assertions then drive the outcome. A document with neither endpoint expectations nor any expectReply still fails to parse.
The example pair lives in examples/yaml-dsl/config/reply-demo.yaml and reply-demo.test.yaml.
Repository stubs
A repositories: block declares in-memory stubs for the named repositories that cache:, idempotent:, and claimCheck: steps resolve against. The block maps a registry kind to a map of repository name to stub target. The only valid target in v1 is the literal memory.
repositories:
cache:
persistent: memory
idempotent:
dedupe: memory
claimCheck:
store: memory
Three registry kinds exist: cache, idempotent, and claimCheck. Each maps repository names to the stub target. The runner registers a fresh memory backend under each declared name before the routes load. The steps then resolve at compile time. Only the memory target is supported. Any other target is a document error.
The built-in name memory is not stubbable. Registering it would collide with the built-in repository, so the runner rejects it. Blank repository names are rejected too. An undeclared name still fails route load. A stub resolves only its explicitly declared name. A typo hits the same compile-time ComponentNotFound gate as production. An unknown registry kind is a document error that lists the three supported kinds.
Stubs are lossy. The R-REPOSITORY-STUB warning on stderr names each stubbed registry and repository and lists the semantics the memory backend does not exercise: for cache, prefix purge, TTL/stale timing, disk offload, and stats; for idempotent and claimCheck, persistence; for all, backend failure. Cover these in the integration tier.
The example pair lives in examples/yaml-dsl/config/repositories-demo.yaml and repositories-demo.test.yaml.
Env fixtures
An optional env: map declares string fixture values for the unit-tier interpolation seams. Route files, inline routes: sources, and the doc-side identifier fields (repository and bean stub keys, intercept sources and targets, mock: references, inputs[].to) consult the map before their inline :-default. The map is the only resolution source beyond the defaults: the ambient process environment is never read, so runs stay hermetic.
env:
CACHE_REPO_NAME: faststub
repositories:
cache:
"${env:CACHE_REPO_NAME:-persistent}": memory
The placeholder on the stub key resolves to faststub. A route file carrying the matching reference resolves through the same map, so the step asks for the repository the document stubbed:
- cache:
repository: "${env:CACHE_REPO_NAME:-persistent}"
key: k
Both sides name faststub; the stub registers and the route loads. Without the env: map the same placeholder pair would resolve to the default persistent on both sides — the stub key and the step would still agree, but the fixture steers the name without editing either file.
Every env: value must be a string. An integer, boolean, or null value is a document error. Values are data, never re-interpolated: the value text is substituted verbatim, so a value that itself looks like a placeholder stays literal. Typing follows the route-side contract: a substituted leaf keeps string typing, so an integer- or boolean-typed field carrying a placeholder fails the document exactly as camel run rejects it, even when the env: map supplies the value. Numeric-typed knobs are tracked separately.
Identifier interpolation has a fixed grammar and scope. The doc-side identifier fields resolve ${env:NAME:-default} through the same scanner as the route sources. A stub key and its route reference thus always name the same value. Interpolation runs after deserialization and before the other document checks. The scheme, blank-name, and built-in memory guards see the resolved value. A placeholder without a default and without an env: entry fails document validation at exit 2. The message names the variable and the field position. Assertion data and path fields stay literal. Input body and headers values, expectReply blocks, matcher contents, bean methods and config values, repository stub targets, settle, and the routeFiles and routeFilesFromRoot paths are never interpolated. The scope follows the mock-testkit spec requirement "Doc-side identifiers interpolate through the document env layer for name-match parity with route sources" (openspec/specs/mock-testkit/spec.md in the repository, outside the rendered book). The regression that motivated the requirement is tracked as bd rc-4hexo.
CI output and filters
camel test accepts five flags for CI use: --junit, --filter-file, --filter-endpoint, --unit, and --integration.
--junit <FILE> writes a JUnit XML report after the run. The report holds one testsuite per attempted document, named by the document path as displayed in stdout. Each suite carries a <property name="tier"> row with the derived tier (lean or full) when the tier is known. Each assertion row becomes one testcase with the same label as its PASS/FAIL line (endpoint name, reply[i] <to> reply label, <settle>). A failing row carries a <failure> element. A document-level error (unreadable file, parse error, boot failure, route load failure, input delivery failure) becomes one <error> testcase named <document> in that document's suite. An expansion-level error (unreadable directory entry, zero-document directory) becomes one synthetic suite named by the path in the error, with a single <error> testcase named <expansion>. The report is written on exit-0, exit-1, and exit-2 runs alike. It is not written when a filter flag fails validation (see below). A report write failure prints to stderr and exits 2.
--filter-file <GLOB> narrows the expanded document set to documents whose entire displayed-path string matches the glob. The glob follows glob-crate semantics: * does not cross /, and ** does. The match happens before reading, so filtered-out documents are never read or parsed. Directory arguments display the paths as collected: a . argument yields ./-prefixed paths, and an absolute argument yields absolute paths. Patterns must account for the prefix. For example, --filter-file './sub/**' matches the ./sub/-prefixed paths a . argument produces.
--filter-endpoint <NAME> narrows the set to file-admitted documents whose expects map contains the given name. The match is exact against the bare endpoint name (the URI suffix after mock:). Scenario documents declare no expects, so an endpoint filter excludes them. Select scenario documents with --filter-file or by naming them on the command line. A file-admitted document that fails to parse still reports its error and sets exit 2, regardless of the endpoint filter.
--unit and --integration are symmetric tier filters. --unit runs only documents that derive the lean tier. --integration runs only documents that derive the full tier. The tier is content-derived, so the filter applies after parsing and tier derivation. A nonmatching document found through directory expansion is excluded silently. A nonmatching document named explicitly on the command line fails with tier-filter-collision and exits 2. Supplying both flags together is misuse. camel test rejects it before any document is read and exits 2.
Every executed document prints one tier annotation line before its PASS/FAIL rows: [lean] for the unit tier, [full] for the integration tier. CI parsers that consume stdout must account for these lines.
Exit codes follow a fixed contract. Verdict failures exit 1: expectation mismatch, settle timeout, reply assertion failure, scenario receive-timeout, and validation-mismatch. Apparatus failures exit 2: runtime scenario-var-unresolved (an unset variable is an authoring bug, not a product failure), action-transport-failure, partner-startup-failure, shutdown-failure, and infra-unavailable. Document validation failures exit 2: unreadable file, parse error, boot failure, and harness wiring errors. Precedence is 2 over 1 over 0.
Scenario documents run through one of two execution paths. The build selects the path.
| Build | Endpoint schemes | Execution path |
|---|---|---|
| default | fake: only | No-boot smoke path. Any other scheme reports infra-unavailable, names the adapter, and exits 2. |
integration-http | fake:, direct:, http: | Embedded full boot. Real composition root, real wire, harness partner listeners. Any other scheme reports infra-unavailable, names the adapter, and exits 2. |
integration-sql | no scenario endpoint references required; sql: actions | Embedded full boot. An sql:-only document runs without integration-http. |
The default build provides only the in-memory fake: partner adapter. A scenario whose endpoints are all fake: runs the no-boot smoke path. A fake:-only scenario keeps that path in any build.
The integration-http feature is enabled by default in camel-cli
since 2026-09-05. The build boots the real composition root. A scenario whose endpoints are all fake:, direct:, or http: qualifies. Each http: endpoint binds a harness partner listener on 127.0.0.1:0. A direct: send stimulates the booted context through its own producer path. The document runs over the real wire.
The integration-sql feature is independent of integration-http. It is on by default in camel-cli. An sql:-only document boots the same composition root without integration-http. The integration-sql CI job proves this independence: it builds camel-cli with --no-default-features --features integration-sql,itest-e2e and runs the scenario e2e suite.
Filters combine as AND across kinds and OR within repeats of one kind. The tier filter counts as a kind. When at least one filter is given and no document survives, camel test prints a misuse error naming the filters and exits 2. An invalid glob pattern prints to stderr and exits 2 before any document runs.
Split a large suite across CI jobs with --filter-file. Each job runs one shard and writes its own report. Example: a job that runs only the shard-1 documents:
camel test . --junit shard-1.xml --filter-file './src/**/shard-1*'
Annotating pull requests from the report requires the CI platform's JUnit publisher or report-ingest integration. On GitHub Actions, upload the report as an artifact and pass it to a JUnit-annotation action of your choice.
Scenario documents
A scenario: document is the integration-tier contract of ADR-0069. The document declares an action list. The runner executes four actions in order: send, receive, sleep, and validate. A send takes an optional method field, for example method: PUT. The field is uppercased at load. Without the field, a body implies POST and no body implies GET. The README of the camel-integration-test crate is the grammar reference.
A scenario document may declare a partners: section to script the responses a harness partner serves. The section is a map from the declared endpoint string to a sequence of script entries. The same document interpolates ${name} in endpoint strings, body string leaves, and header values. bindVar fills a scenario variable with the partner's bound authority. The crate README documents the partners: shape, the interpolation surface, and the two-layer bindVar rule.
A scenario can script failure paths. A partners: entry may declare delay, fault: close, and times. The harness holds for the delay before it serves the response or commits the fault. The fault: close drops the connection without an HTTP response. The times repeats an entry for a fixed number of matching requests. A route-level error_handler.retry redials after a fault, so a fault-to-healthy sequence tests retry behavior.
A validate action with a partner target asserts the recorded-request count. The count is exact and non-negative. Optional method and path filters narrow it. A deadline polls until the count settles or the deadline passes. The runnable example pair lives in examples/integration-testing/partner-retry-route.test.yaml and partner-retry.routes.yaml.
A partner target's URI must equal a harness endpoint reference the scenario's own send/receive actions declare, or self-declare one. The object target form self-declares: provisioning: harness on an http endpoint plus a partners: entry naming the URI. The validate's own reference then wires the partner exactly as a send/receive reference does: the driver binds the partner and fills the reference's bindVar with the bound authority.
Proxy routes whose query varies per request need this form. The varying query makes each request dial a different wire path, so no literal arrival lane exists for a receive to name; the recorded traffic is the only assertion surface, and the scenario runs with no sacrificial receive. The route step reads the bound authority and appends the query — for example to: ${env:UPSTREAM}/tiles?bbox=1.2:
scenario:
- send: {to: direct:start}
- validate:
target: {partner: {endpoint: http://upstream/tiles, provisioning: harness, bindVar: UPSTREAM}}
expectation: {count: 1}
deadline: 5s
partners:
http://upstream/tiles:
- path: /tiles?bbox=1.2
response: {status: 200, body: tile}
One declared harness endpoint and one bindVar can serve every path a
route dials. The route's to: URIs share the one authority and differ
only in path, for example http://${MOCK}/orders and
http://${MOCK}/billing. The partners: section scripts each path
under the same declared key, one entry per path with its own response
body. The two-key rule governs resolution: the declared endpoint string
is the provisioning key that binds the listener, and an interpolated
reference such as http://${MOCK}/billing is a dynamic reference the
router resolves to the already-bound partner by authority. Arrivals queue
per request path on that single listener.
Do not declare one endpoint per path. The N-bindVar fan-out provisions
one listener per path for what is one logical partner, and the extra
bindings reassign ports spuriously; this caused the 2026-09-06 pilot
incident. Each dynamic-reference receive names its own path, and each
drains its own arrival lane on the single listener: from: http://${MOCK}/orders drains the orders lane, from: http://${MOCK}/billing drains the billing lane. A dynamic receive must
name a path — a bare authority is an apparatus error. The registered
key remains the adapter-lookup key; the path on the wire picks the lane.
In the client role, standalone roundtrip receives drain their own
path's parked roundtrip oldest-first: two receives naming different
paths of one partner never cross-match (bd rc-cr5yf). The runnable
example pair lives in
examples/integration-testing/partner-multi-path.test.yaml and partner-multi-path.routes.yaml.
A migration note for suites that grew one script per assertion: express
each independent assertion chain as its own scenario document.
Documents run independently, and the runner continues past a failing
document, so one camel test run reports every chain. ADR-0069
section 11 keeps the ordered action list as the pin: the actions inside
one document stay ordered.
Back-to-back send: actions with no intervening receive: dispatch genuinely concurrent requests. The crate README documents the burst-send recipe for asserting the concurrent arrivals. A direct: send may declare expectReply to assert the route's synchronous reply; the README's usage/grammar area details the verb. The scenario-tier field takes matcher verbs directly, unlike the unit tier's expectReply block.
A scenario document may declare one document-level logs: block. The harness captures every tracing event emitted while the document runs, and it evaluates the block after the action list completes. Three clauses exist, and every declared clause must hold. contains lists substrings of captured event messages. regex lists unanchored patterns; each pattern matches against the composite camel-log message. noLevelAbove sets a level cap over the whole document window; the cap spans every target, route processors and harness tasks alike. An unknown level, an invalid pattern, or an unknown key fails the load.
Log capture is process-global. Documents that run concurrently in one process attribute events conservatively: the harness files an event in every open window, so a sibling document's WARN can fail this document's noLevelAbove cap. Serialize log-asserting documents, or keep them on the current-thread itest path, when a document needs strict isolation.
Datasource steering
A scenario reads SQL state through the booted context's datasource catalog. The datasource itself lives in Camel.toml, not in the document: the datasource: field of a sql: action and of a validate sql target names a key from the [datasources] table. That table is a strict interpolation surface. Leaf values resolve ${env:NAME} and ${env:NAME:-default}, and a residual marker fails the load instead of passing through (see crates/camel-config/CONTEXT.md).
[datasources.appdb]
provider = "sqlx"
db_url = "${env:APPDB_URL:-sqlite:file:memdb_demo?mode=memory&cache=shared}"
At boot the placeholder resolves through the same layered source as the route files. The source checks harness-provisioned bindVar values first, then document env: values, then variables listed in envPassthrough:, then the inline default (ADR-0069 section 4). A hermetic document pins the value itself:
env:
APPDB_URL: "sqlite:file:memdb_demo?mode=memory&cache=shared"
The shared cache is a requirement, not a preference, for every :memory: datasource. A bare sqlite :memory: database is per-connection. An INSERT on one pooled connection and a SELECT on another can therefore hit different databases, and a validation can pass against state the document never seeded. The boot rejects sqlite::memory: without cache=shared with the sql-memory-not-shared error (crates/camel-integration-test/CONTEXT.md).
The recipe above shows the recommended shape, the named shared-memory URI. A name such as memdb_demo holds one database, and every pool connection shares it, so the author selects max_connections for the workload. sqlite:file: matches no automatic datasource factory prefix, so the datasource pins provider = "sqlx". The bare sqlite::memory:?cache=shared form is still accepted. Its shared name comes from sqlx-internal naming, and with the Any driver each pooled connection can hold a private database. Pin max_connections = 1 with that form.
A document that needs a real database lists the variable in envPassthrough: and keeps the inline default:
envPassthrough:
- APPDB_URL
The CI job or Compose file then supplies APPDB_URL, for example a service-container Postgres URL. The harness never provisions the database. The address arrives through the variable, so the surrounding infrastructure stays the author's concern (ADR-0069 section 9).
Two laws keep the surface orthogonal. The datasource name (appdb) is an identifier path, and interpolation never touches it. The db_url value is an env leaf path, and the layered source always resolves it. The same laws govern every strict-prefix table (crates/camel-config/CONTEXT.md), so this section describes one instance of a general steering pattern, not a datasource-specific rule (bd rc-l7m7t, bd rc-4hexo).
Isolation and teardown
Each scenario boot owns its datasource catalog and its pools. The boot teardown closes those pools after the context stops. An in-memory sqlite database therefore dies with its boot: a later document booting the same [datasources] alias in the same process starts from an empty database. camel test runs documents sequentially in one process, so this per-boot freshness keeps one document's seeded rows out of the next document's validations. The guarantee is a contract of the teardown seam, not an accident of driver internals (crates/camel-integration-test/CONTEXT.md).
The guarantee is load-bearing for named shared-memory URIs. A datasource pinned to sqlite:file:<name>?mode=memory&cache=shared shares one named database across every connection that uses the name, in any boot. A lingering connection from an earlier boot would carry that database's rows into the later boot; the teardown close is what kills it. The adversarial tests pin this shape directly.
The scenario-tier convention for memory fixtures is the named shared-memory URI, for example sqlite:file:memdb_demo?mode=memory&cache=shared. A named memory URI shares one database across every pool connection. The author therefore selects max_connections for the workload, and the named_shared_memory_uri_probe in the sqlx pool factory pins multi-connection sharing. The bare sqlite::memory:?cache=shared form is still accepted, but with the Any driver each pooled connection can hold a private database there, so that form pins max_connections = 1.
Durable datasources are outside the per-boot guarantee. A file-backed sqlite database, or a service-container Postgres behind envPassthrough:, keeps its rows across boots. No harness mechanism cleans it between documents. Isolation for durable datasources is the document author's responsibility — the same law that governs user-provided infrastructure generally (ADR-0069 section 9): the harness provisions hermetic defaults, never cleanup for resources it does not own.
The authoring convention for durable datasources is the prepare-action clean-first idiom: the first sql: prepare statement deletes the state a previous run may have left, before any INSERT re-seeds it.
scenario:
- sql:
datasource: appdb
prepare:
- DELETE FROM orders # clean first: a prior document's rows
- INSERT INTO orders VALUES ('seed-a')
DELETE FROM (whole-table) or a table-recreating statement are the two clean-first shapes; TRUNCATE applies where the engine supports it. A document that skips the clean-first statement works only as long as it runs alone. The adversarial boot-freshness tests in crates/camel-integration-test pin both directions: a second memory-sqlite boot reads zero, a second file-backed boot reads everything.
Known limitation, parallel mode (bd rc-gcf9n): camel test executes documents sequentially today. When parallel document execution lands, two concurrently booted documents that share one memory name could collide on the same shared memory database. The planned remedy is a per-boot unique suffix in the memory name (memdb_{scenario}_{boot}) minted by the harness for hermetic memory datasources. This note records a plan, not a rule for the current runner. Until parallel lands, documents that share a durable datasource must not run concurrently.