Skip to content

Testing with Rust

Rust tests use the Embedded engine’s server form over the REST API, like the TypeScript, Python and Go tests. The example needs two crates from crates.io as dev-dependencies: ureq, a small blocking HTTP client, and serde_json.

An in-process Rust crate, which runs the engine inside your test binary with no port, is planned. It is not available yet.

The runnable project is testing/rust in the examples download.

From the release archive for your platform (downloads are listed in Embedded mode); on loopback it needs no credential:

Terminal window
./tinyconductor-0.1.0-linux-x86_64/tinyconductor --mode embedded

Or from the container image:

Terminal window
export TINYCONDUCTOR_TOKEN=test-token
docker run --rm -d --name tinyconductor-test -p 127.0.0.1:8080:8080 \
-e CONDUCTOR_AUTH_CREDENTIALS_JSON='[{"token":"test-token","subject":"tests","tenantId":"default","kind":"service","actions":["*"]}]' \
registry.tinyfactory.ai/tinyblox/tinyconductor:0.1.0 --mode embedded

The test reads TINYCONDUCTOR_URL (default http://127.0.0.1:8080) and the optional TINYCONDUCTOR_TOKEN. The REST calls are listed in the Java guide.

[dev-dependencies]
ureq = { version = "3", default-features = false }
serde_json = "1.0"

Without its default features ureq speaks plain HTTP only, which is all a test engine on your machine or in a CI service container needs. Turn the defaults back on for HTTPS.

The heart of the example (its version adds error messages and the optional token):

use serde_json::{Value, json};
const BASE_URL: &str = "http://127.0.0.1:8080";
fn agent() -> ureq::Agent {
ureq::Agent::config_builder().http_status_as_error(false).build().into()
}
fn answer(mut response: ureq::http::Response<ureq::Body>) -> Value {
let status = response.status();
let text = response.body_mut().read_to_string().unwrap();
assert!(status.is_success(), "{status}: {text}");
if text.is_empty() { Value::Null } else { serde_json::from_str(&text).unwrap() }
}
fn post(path: &str, body: Value) -> Value {
answer(agent().post(format!("{BASE_URL}{path}"))
.header("content-type", "application/json")
.send(body.to_string()).unwrap())
}
/// Deploys one sample file from the top of the examples download.
fn deploy(file_name: &str, content_type: &str) {
let content = std::fs::read(format!("{}/../../{file_name}", env!("CARGO_MANIFEST_DIR"))).unwrap();
let boundary = "tinyconductor-example-boundary";
let mut body = format!("--{boundary}\r\nContent-Disposition: form-data; name=\"resource\"; \
filename=\"{file_name}\"\r\nContent-Type: {content_type}\r\n\r\n").into_bytes();
body.extend_from_slice(&content);
body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
answer(agent().post(format!("{BASE_URL}/v1/deployments"))
.header("content-type", format!("multipart/form-data; boundary={boundary}"))
.send(&body[..]).unwrap());
}
fn complete_job(job_type: &str, variables: Value) {
let answer = post("/v1/jobs/activation",
json!({"type": job_type, "timeout": 30000, "maxJobsToActivate": 1}));
let job = &answer["jobs"][0];
let key = job["jobKey"].as_str().unwrap(); // keys are JSON strings
post(&format!("/v1/jobs/{key}/completion"),
json!({"leaseEpoch": job["leaseEpoch"], "variables": variables}));
}
#[test]
fn order_completes_after_the_review_period() {
// Every test starts from an empty engine at the fixed epoch.
answer(agent().post(format!("{BASE_URL}/v1/embedded/reset")).send_empty().unwrap());
deploy("order.form", "application/json");
deploy("order-decisions.dmn", "application/xml");
deploy("order.bpmn", "application/xml");
let instance = post("/v1/process-instances", json!({
"processDefinitionId": "order-process",
"variables": {"orderId": 4711, "amount": 99.9}}));
let key = instance["processInstanceKey"].clone();
complete_job("charge-card", json!({"charged": true}));
// The 24-hour review timer fires inside this call; no sleeping or polling.
post("/v1/embedded/clock/advance-by", json!({"milliseconds": 86_400_000}));
complete_job("send-invoice", json!({}));
let tasks = post("/v1/user-tasks/search", json!({"processInstanceKey": key}));
let task = &tasks["items"][0];
assert_eq!(task["elementId"], "confirm-shipment");
let task_key = task["userTaskKey"].as_str().unwrap();
post(&format!("/v1/user-tasks/{task_key}/completion"), json!({"variables": {"shipped": true}}));
let records = answer(agent().get(format!("{BASE_URL}/v1/embedded/records")).call().unwrap());
let completed: Vec<&str> = records["records"].as_array().unwrap().iter()
.filter(|r| r["valueType"] == "PROCESS_INSTANCE" && r["intent"] == "ELEMENT_COMPLETED"
&& r["value"]["processInstanceKey"] == key)
.filter_map(|r| r["value"]["elementId"].as_str())
.collect();
for element in ["order-process", "charge-card", "send-invoice", "confirm-shipment"] {
assert!(completed.contains(&element), "{element} completed");
}
}

Run it:

Terminal window
cd tinyconductor-0.1.0-examples/testing/rust
cargo test

Keys stay JSON strings end to end, so they are never rounded. Tests that share one engine must not run in parallel, since each begins with a reset: run a suite with several such tests with cargo test -- --test-threads=1, or give each test binary its own engine on its own port through TINYCONDUCTOR_URL.