ironflow_engine/testing/mod.rs
1//! In-memory test harness for workflow handlers.
2//!
3//! [`TestEngine`] runs a real [`Engine`](crate::engine::Engine) against a real
4//! [`InMemoryStore`](ironflow_store::memory::InMemoryStore): the run, the steps,
5//! the FSM transitions and the persistence are the production ones. Only the
6//! outside world is swapped out -- shell commands, HTTP requests, agent
7//! invocations, approval gates and human inputs are answered from closures instead of
8//! spawning processes, opening sockets or waiting for a human.
9//!
10//! What it does *not* start: no HTTP server, no background worker, no Postgres.
11//! A run executes inline, in the calling task, and finishes before
12//! [`run`](TestEngine::run) returns.
13//!
14//! # Examples
15//!
16//! ```no_run
17//! use ironflow_engine::prelude::*;
18//! use ironflow_engine::testing::{ApprovalOutcome, MockShellOutput, TestEngine};
19//! use ironflow_store::models::RunStatus;
20//! use serde_json::json;
21//!
22//! # struct Deploy;
23//! # impl WorkflowHandler for Deploy {
24//! # fn name(&self) -> &str { "deploy" }
25//! # fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
26//! # Box::pin(async move { ctx.shell("deploy", ShellConfig::new("./deploy.sh")).await?; Ok(()) })
27//! # }
28//! # }
29//! # async fn example() -> Result<(), EngineError> {
30//! let result = TestEngine::new()
31//! .with_handler(Deploy)
32//! .with_mock_shell(|_cfg| Ok(MockShellOutput::ok(r#"{"version":"1.2.3"}"#)))
33//! .with_mock_approval(ApprovalOutcome::Approved)
34//! .run(json!({"env": "prod"}))
35//! .await?;
36//!
37//! assert_eq!(result.status(), RunStatus::Completed);
38//! assert_eq!(result.step("deploy").step_output().stdout(), r#"{"version":"1.2.3"}"#);
39//! # Ok(())
40//! # }
41//! ```
42//!
43//! # What the harness covers
44//!
45//! | Step | How it is mocked |
46//! |------|------------------|
47//! | `ctx.shell` | [`TestEngine::with_mock_shell`] |
48//! | `ctx.http` | [`TestEngine::with_mock_http`] |
49//! | `ctx.agent` | [`TestEngine::with_mock_agent`] or [`TestEngine::with_recorded_agent`] |
50//! | `ctx.approval` | [`TestEngine::with_mock_approval`], or [`TestEngine::resume`] |
51//! | `ctx.human_input` | [`TestEngine::with_mock_human_input`], or [`TestEngine::resume`] after writing the answer on the step |
52//! | `ctx.parallel`, `ctx.workflow`, `on_error` | the mocks above apply to the steps inside them |
53//!
54//! # Limitations
55//!
56//! * Custom operations ([`ctx.operation`](crate::context::WorkflowContext::operation))
57//! are not intercepted. Mock one by passing a test-double
58//! [`Operation`](crate::operation::Operation) to the handler.
59//! * [`ctx.delay`](crate::context::WorkflowContext::delay) is not intercepted: a
60//! non-zero delay still suspends the run with
61//! [`RunStatus::Sleeping`](ironflow_store::models::RunStatus::Sleeping).
62//! * [`ctx.decision`](crate::context::WorkflowContext::decision) needs a real
63//! [`DecisionProvider`](ironflow_core::decision::DecisionProvider), wired with
64//! [`TestEngine::with_decision_provider`].
65
66mod engine;
67mod mocks;
68mod result;
69
70pub use engine::TestEngine;
71pub use mocks::{
72 AgentMock, HttpMock, HumanInputMock, MissingAgentProvider, MockAgentProvider, MockHttpResponse,
73 MockInterceptor, MockShellOutput, ShellMock,
74};
75pub use result::{TestResult, TestStep};
76
77// Re-exported so test code has a single import path for everything the harness
78// needs.
79pub use crate::executor::{ApprovalOutcome, HumanInputOutcome};