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

Weft The book

Testing a node

Tests live beside the node, in a tests.rs in its own folder. An access node has none: the access_node! macro is its whole body, so there is nothing of yours to test.

tests.rs:

pub fn tests() -> Vec<NodeTest> {
    vec![NodeTest::fake("counts_words", counts_words)]
}

async fn counts_words(rig: FakeRig) -> WeftResult<()> {
    let outcome = rig.run(&WordCountNode, json!({ "text": "one two three" })).await.ok()?;
    assert_eq!(outcome.outputs["count"], json!(3.0));
    Ok(())
}

mod.rs bridges it in:

#[cfg(feature = "node-tests")]
mod tests;

#[async_trait]
impl Node for WordCountNode {
    #[cfg(feature = "node-tests")]
    fn tests(&self) -> Vec<weft::NodeTest> {
        tests::tests()
    }
    // ...
}

There is no registry and no list to maintain. The runner walks the nodes the binary already has and asks each one for its tests.

weft test-node WordCount        # one node
weft test-node slack            # one package
weft test-node slack WordCount  # several, built together
weft test-node                  # everything

Three tiers

TierWhat it getsCosts
basicNothing. A plain function testing your own logicNothing
fakeCanned HTTP, in-memory storage, canned signals, fake connectionsNothing
liveThe real runtime: real connections, real calls, real meteringReal money

Basic and fake run by default. Live never runs unless you ask, and the runner confirms before it does.

Basic and fake run right there with cargo: they need no Docker and no runtime, and only the packages you targeted get built.

Two things to know. A basic test is a plain sync function and the runner is already inside an async runtime, so building one inside it panics. And for a trigger or an infra node, fake is the top tier: the live rig drives a plain run body, so a live test on those is refused.

A fake test that never finishes fails by name after 30 seconds, and tells you where to look:

test 'reads the stream' did not finish within 30s. Something in it is waiting
for what it never gets: a cursor reading a bus nothing closes, a caller nobody
attaches, a signal nobody answers.

The fake rig

You wantCall
Run the noderig.run(&node, json!({...}))
Run its trigger setup, or its infrarig.run_setup_trigger(...), rig.run_provision_infra(...)
Canned HTTPrig.respond(method, path, body), rig.respond_status(...), rig.respond_raw(...), rig.respond_with_headers(...) for an answer that lives partly in a header (a created id, an ETag)
A server it cannot reach at allrig.fail_connection(method, path): the call errors with no status and no body
Check what it sentrig.requests(), rig.assert_sent(method, path)
What it saved, and a resumerig.saved_state(&node) is the state its body last saved. Run the node a second time on the same rig and it picks the firing up the way a resumed run does: the body gets that state back, a wait it already made hands back its answer, and a stream input skips what it took. So a publish that must happen once is something you can prove: two runs, one request. Each node keeps its own, so two nodes on one rig never pick up each other’s
A connectionrig.access("slack"), rig.connection_value(...), rig.connection_permissions(...)
A trigger’s eventrig.wake(payload), rig.signal(payload)
A live callerrig.attach_caller(conn)
A bus going inrig.seed_bus(opts), giving you a writer and a marker
A bus it emittedrig.bus(&outcome.outputs["channel"])?, then read it from its oldest entry with .cursor_from_start()? and cursor.next(&mut HarnessRead) (weft::context::HarnessRead, for test code only: a node body handing it to a read fails)
A stored filerig.store_file(filename, mime, bytes) going in; rig.stored_bytes(key), rig.stored_meta(key) and rig.stored_files(scope) for what the node stored; rig.file_edits() for every in-place change, with the file, the versions and the diff
An infra endpointrig.declare_endpoint(name, url), rig.answer_endpoint(...), rig.refuse_endpoint(...)
What it asked forrig.registered_signals(), rig.awaited_signals(), rig.logs(), rig.run_tags(), rig.stops()
A port’s type, for a node that takes added portsrig.output_type(port, ty), rig.input_type(port, ty)
An output something downstream readsrig.wire_output(port). Nothing is wired unless you say so. rig.wire_output("_error") makes a failure come out on _error, as it would in a run, and outcome.output("_error") reads it
A server that is there and will not talk, for a node that opens its own socketrig.hang_up_server(), a local port that hangs up on every connection. It is for raw socket clients (a database driver, a mail client): web calls in a fake test never reach the network, so for those use rig.fail_connection. If you want a refused connection, use this rather than a closed port: on some machines a closed port hangs until a timeout

The outcome gives you .ok() for the happy path, .failure() for the error string, .paused() for a run that paused in the body, .output(port) for one port, .infra_spec() for an infra run, and .outputs for the map. The rig refuses a value over 100 KB with the same error a run gives, so a test catches an output no wire would carry.

Note the last group: in a fake test nothing is really stopped and nothing is really tagged. The rig records what your node asked for, and that is what you assert on.

In a node test, every state.wait takes its answer from what you queued with rig.signal, in the same call. If you want to check what a resume does, run the node twice on one rig. This one waits for a click, then posts, and the second run finds both in its saved state:

rig.respond("POST", "/api/send", json!({"id": "post-1"}));
rig.signal(json!({"ok": true}));
let inputs = json!({"account": rig.access("probe")});

rig.run(&AskThenPost, inputs.clone()).await.ok().expect("waits, is answered, posts");
let again = rig.run(&AskThenPost, inputs).await.ok().expect("picks up from its state");

assert_eq!(again.outputs["reply"], json!({"id": "post-1"}));
assert_eq!(rig.requests().len(), 1, "one post across the two runs");
assert_eq!(rig.awaited_signals().len(), 1, "one wait across the two runs");

If you want to prove the node carries on across a pause without doing again what it did before, queue rig.signal_paused(): the node’s next wait pauses the run there, as production does once every node waits, and outcome.paused() says so. The next rig.run of the node picks the firing up at that wait and takes the next answer you queued. A body that does something else first (reads a bus, yields, saves) is refused with the same message as in production:

rig.respond("POST", "/api/send", json!({"id": "post-1"}));
rig.signal_paused();
rig.signal(json!({"ok": true}));
let inputs = json!({"account": rig.access("probe")});

let first = rig.run(&PostThenAsk, inputs.clone()).await;
assert!(first.paused(), "paused on its wait");
rig.run(&PostThenAsk, inputs).await.ok().expect("picks up at the wait");
assert_eq!(rig.requests().len(), 1, "posted once across the pause");

A node that creates a bus gets the same bus back on its second run, as a resumed body does.

Rig reference

If you need a rig call’s exact signature, or the fields of what it hands back, it is here.

The fake rig

Setting the run up:

SignatureWhat it does
respond(&self, method: &str, path: &str, body: Value)Answers a matching request with 200 and body as JSON. path may carry a query; parameter order and encoding do not matter
respond_status(&self, method: &str, path: &str, status: u16, body: Value)The same with your status
respond_raw(&self, method: &str, path: &str, status: u16, content_type: &str, body: impl Into<Bytes>)Raw bytes with a content type (XML, CSV, a picture, an event stream)
respond_with_headers(&self, method: &str, path: &str, status: u16, content_type: &str, headers: &[(&str, &str)], body: impl Into<Bytes>)The same plus response headers. The content type is its own argument, never a header here
fail_connection(&self, method: &str, path: &str)A matching request fails before any response, like a server that is down
signal(&self, payload: Value)Queues the answer to the node’s next state.wait, in order
signal_paused(&self)Queues a pause instead: the node’s next state.wait pauses the run there (outcome.paused() is true), and the next rig.run of the node picks it up at that wait with the next queued answer
signal_given_up(&self)Queues a wait given up instead: the node’s next state.wait fails with WeftError::WaitGaveUp, the way it does when a run that cannot pause holds the wait past its holdSecs
wake(&self, payload: Value)The next run’s ctx.wake: what a firing trigger received
instance(&self, id: &str)Makes every run on the rig a run for that instance
attach_caller(&self, conn: Arc<FakeCallerConnection>)Puts a scripted live caller on the runs, what a Route or Socket run carries
seed_bus(&self, opts: BusOptions) -> WeftResult<(BusHandle, Value)>A bus you write into, and its marker to put on a Bus input
access(&self, service: &str) -> ValueA connection marker for an access input
connection_value(&self, service: &str, name: &str, value: &str)A value the opened connection answers (conn.value(name))
connection_permissions(&self, service: &str, granted: &[&str])What the connection has granted, checked against what the input requires
published_connection(&self, service: &str, values: &[(&str, &str)])Says the node already published this connection on an earlier run
output_type(&self, port: &str, ty: WeftType)Declares the type of an output, the way a program does on a narrowable output (-> (response: Verdict)) or on one it adds. If the compiler would refuse that type (an output that is not narrowable takes only its own), the run fails with the case declares output ...
input_type(&self, port: &str, ty: WeftType)Declares an added input port and its type
wire_output(&self, port: &str)Wires an output downstream. Nothing is wired unless you say so
hang_up_server(&self) -> u16A local port that hangs up on every connection, for raw socket clients
declare_endpoint(&self, name: &str, url: &str)Where the node’s own infrastructure endpoint name answers
declare_public_url(&self, name: &str, url: &str)Makes a declared endpoint public at url
answer_endpoint(&self, endpoint: &str, method: EndpointMethod, path: &str, answer: Value)The next call to that endpoint and path answers answer
refuse_endpoint(&self, endpoint: &str, method: EndpointMethod, path: &str, status: u16, body: &str)The next call to that endpoint and path is refused
answer_program_call(&self, name: &str, value: Value)What the next program call of that name answers (weft.infra.status, …), in order
store_file(&self, filename: &str, mime_type: &str, bytes: impl Into<Vec<u8>>) -> ValueStores a file in this run’s scope and gives its value, for a file input
store_file_in(&self, scope: &StorageScope, filename: &str, mime_type: &str, bytes: impl Into<Vec<u8>>) -> ValueThe same in another scope

Running it:

SignatureWhat it does
run(&self, node: &dyn Node, inputs: Value) -> RigOutcome (async)Runs the node’s body with inputs (a JSON object of input name to value). A second call for the same node picks its firing up from the state it saved
run_setup_trigger(&self, node: &dyn Node, inputs: Value) -> RigOutcome (async)Runs a trigger’s setup
run_provision_infra(&self, node: &dyn Node, inputs: Value) -> RigOutcome (async)Runs an infra node’s provision_infra

Looking at what happened:

SignatureWhat it gives you
requests(&self) -> Vec<SentRequest>Every web request the node sent, in order, answered or not
assert_sent(&self, method: &str, path: &str)Panics, listing what was sent, unless one request matched
stored_bytes(&self, key: &str) -> WeftResult<Bytes>The content stored under a key
stored_meta(&self, key: &str) -> WeftResult<StoredFileMeta>What storage records for it: its deadline (expires_at_unix, keep_ttl_secs), the size
stored_files(&self, scope: &StorageScope) -> WeftResult<Vec<StoredFileMeta>>Every file stored in a scope
file_edits(&self) -> Vec<FileEdit>Every change made to a stored file in place, in order: the file, the versions, the diff
bus(&self, marker: &Value) -> WeftResult<BusHandle>The bus behind a marker the node emitted, as a handle of the test’s own
endpoint_calls(&self) -> Vec<EndpointCall>Every call to the node’s own infrastructure, in order
registered_signals(&self) -> Vec<(SignalSpec, Value)>What a trigger’s setup registered
awaited_signals(&self) -> Vec<SignalSpec>Every signal the node parked on
saved_state(&self, node: &dyn Node) -> Option<Value>The state the node’s body last saved (ctx.keep), as JSON; None before it saved anything
logs(&self) -> Vec<(LogLevel, String)>Every ctx.log line
run_tags(&self) -> Vec<Vec<String>>The tags, one list per ctx.tag_run
stops(&self) -> Vec<(String, StopSelf)>Every ctx.stop_tagged asked for
program_calls(&self) -> Vec<(ProgramCall, StopSelf)>Every program call made
minted_tokens(&self) -> Vec<MintedToken>Every instance token minted: instance, expires_in_secs, id
published_values(&self, service: &str) -> Option<BTreeMap<String, String>>What the node published as that service’s connection. A publish is held to the same rule as on an install: a value the service does not declare, a required one missing, or one the service’s identity names left blank fails the run. The service’s recipe is found in the whole catalog, as a project build finds it, so a node can publish a service another package declares; if no package declares it, the test build stops with an error naming it
fresh(&self) -> FakeRigA second, empty rig that knows the same services, for a test that needs two

The live rig

SignatureWhat it does
access(&self, service: &str) -> ValueThe test’s connection marker. The service must be the one the test declared
fixture(&self, name: &str) -> WeftResult<String>The value of WEFT_NODE_TEST_<name>, or a failure naming the variable to set
store_file(&self, filename: &str, mime_type: &str, bytes: impl Into<Vec<u8>>) -> WeftResult<Value> (async)Stores a real file and gives its value
connect(&self) -> WeftResult<OpenedConnection> (async)Opens the test’s connection, for setup and cleanup calls of your own around the node
bus(&self, opts: BusOptions) -> WeftResult<(BusHandle, Value)>A real bus you write into, and its marker
wire_output(&self, port: &str)Wires an output downstream
run(&self, node: &dyn Node, inputs: Value) -> RigOutcome (async)Runs the node against the real runtime

LiveRig::new(..) is built by the runner, never by a test.

What comes back

RigOutcome, from every run:

Field or methodWhat it is
result: WeftResult<()>The body’s own result
outputs: serde_json::Map<String, Value>What each port emitted
closed_ports: Vec<String>Ports the body closed with ctx.close_port
infra_spec: Option<InfraSpec>The spec, on a run_provision_infra outcome
ok(self) -> WeftResult<Self>The outcome, or the body’s failure
failure(self) -> WeftResult<String>The failure’s message, or an error when the run succeeded
output(&self, port: &str) -> WeftResult<&Value>One port’s value, or an error naming the ports that did emit
infra_spec(&self) -> WeftResult<&InfraSpec>The spec, or an error when this outcome has none

SentRequest, one per request:

Field or methodWhat it is
method: StringUppercase, "POST"
path: StringThe path, no query
query: Option<String>The raw query string
body_text: Option<String>The body as text, when it was sent whole
body: Option<Value>The body parsed as JSON, when it parses
body_streamed: boolTrue when the body was streamed, which the rig cannot read: body and body_text are then None even though there was a body
headers: Vec<(String, String)>The headers, names lowercased
header(&self, name: &str) -> Option<&str>One header’s first value, any case

EndpointCall, one per call to the node’s own infrastructure:

FieldWhat it is
endpoint: StringThe endpoint’s name
method: EndpointMethodGet or Post
path: StringThe path called
body: Option<Value>The body sent

Live tests

weft test-node SlackSendMessage --tier live

The runner tells you it will spend money and asks. --yes skips that, and it can remember your answer.

A live test runs on your machine’s own install: the runner compiles a test server for the node’s package with your own Rust and starts it there as a program. Pointed at an install that is not on this machine (--on a cloud install, or --dispatcher an address no install here answers at), it refuses, saying live node tests run on this machine's own install. Before it starts a test server, it checks what the tested nodes need while they run, and refuses with the same header and install line as a refused run when something is missing (for those, go and read what nodes need from your machine).

For credentials, --connection slack=<grant-id> uses one you already have, and --key slack takes a throwaway one: each field comes from WEFT_NODE_TEST_SLACK_<FIELD> if it is set, or a prompt, never from the command line where other processes can read it. The grant is deleted afterwards.

Every prompt happens before any credential exists and before any signal handler is armed, so Ctrl+C at a prompt leaks nothing.

If a live test needs a real thing to act on (a chat it may post in, an account it may read), declare it as a fixture: .with_fixture(fixture_spec("CHAT_ID", "Chat", "a chat the bot may post in")), which is a name, a label and a description. Set WEFT_NODE_TEST_CHAT_ID in the shell you run weft test-node from, and the test reads it with rig.fixture("CHAT_ID"). If any fixture is missing, the runner lists every variable to set, with its label, before it runs anything.

If your node waits on a signal (state.wait), a live test keeps the node’s call open and waits for the real answer, the way a run that cannot pause does, except that it always holds for the longest time allowed, 30 days, whatever holdSecs the project sets.

What to write

Ship a fake test for every node. If it talks to a provider, ship a live one on the cheapest real path you can find.

A panic fails that test, carrying its message, and never takes the runner down.