Test Java workflows and agents
Use deterministic business and agent tests, then verify real server execution and packaged applications separately.
Test business rules and agent decisions without a network, then test the server boundaries separately. A local harness should give a reproducible result; a live run should prove the transport and durable execution behavior you actually deploy.
Choose the test boundary
| Layer | What it proves | What it does not prove |
|---|---|---|
| Business-stage unit test | Validation, result classification and context changes | Server scheduling, leases or delivery |
| Agent harness | Scripted decisions through production agent handlers and native tools | Real model quality, queues, distributed retries or persistence |
| API/transport fixture | Request/response contracts and error handling | A particular deployed server's credentials and network |
| Real server run | Bootstrap, dispatch, execution, state and selected transport | Every possible business failure or provider behavior |
| Packaged consumer | Dependencies and public APIs work outside the SDK reactor | External service availability |
Run deterministic agent scenarios
Download the agent demo, install sdk-runtime,sdk-agent,sdk-testing -am from the matching SDK source, and build the demo as described in agents. Then run:
java -cp 'target/classes:target/dependency/*' example.AgentHarnessExampleNo server, broker, application key or model key is needed. The complete example is:
package example;
import io.pipelogiq.sdk.agent.tools.NativeTool;
import io.pipelogiq.sdk.agent.tools.ToolDefinition;
import io.pipelogiq.sdk.testing.AgentTestHarness;
import io.pipelogiq.sdk.testing.AgentTestResult;
import java.util.Map;
/** Deterministic real-handler exercise: no server, broker, API keys or model-provider calls. */
public final class AgentHarnessExample {
public static void main(String[] args) {
var result = runScenario();
System.out.println("Success: " + result.isSuccess());
System.out.println("Tools: " + result.toolCallsExecuted());
System.out.println("Answer: " + result.finalResponse());
System.out.println("Executed stages: " + result.stageResults().size());
if (!result.isSuccess()) throw new IllegalStateException(result.errorMessage());
}
public static AgentTestResult runScenario() {
return AgentTestHarness.create()
.withNativeTool(new ToolDefinition("service_status", "Read demo service status").mutating(false),
(parameters, context) -> NativeTool.Output.success(Map.of("status", "ready")))
.withScenario(script -> script.strict().thenCallTool("service_status")
.thenFinishWith("The service is ready."))
.run("Check service status", "deterministic-example");
}
}Expected output:
Success: true
Tools: [service_status]
Answer: The service is ready.
Executed stages: <count>The strict script chooses one tool and a final answer. The tool and actual agent handlers execute, with input serialization, context patches and dynamically appended stages. This example exercises a read tool; approval behavior requires a separate scenario.
Check waiting, approval and rejection
Create src/main/java/example/AgentApprovalChecks.java in the agent demo:
package example;
import io.pipelogiq.sdk.agent.model.ToolCall;
import io.pipelogiq.sdk.agent.tools.NativeTool;
import io.pipelogiq.sdk.agent.tools.ToolDefinition;
import io.pipelogiq.sdk.testing.AgentTestHarness;
import java.util.List;
import java.util.Map;
public final class AgentApprovalChecks {
private static AgentTestHarness scenario() {
return AgentTestHarness.create(options -> {
options.useReActMode = false;
options.requireConfirmationForMutations = true;
}).withNativeTool(
new ToolDefinition("reserve_stock", "Simulate a reservation").mutating(true),
(parameters, context) -> NativeTool.Output.success(Map.of("reserved", true)))
.withScenario(script -> script
.withPlan(new ToolCall("reserve_stock", Map.of(), "reservation"))
.withSynthesizedResponse("Reservation handled"));
}
private static void check(boolean value, String message) {
if (!value) throw new AssertionError(message);
}
public static void main(String[] args) {
var waiting = scenario().run("Reserve stock");
check(waiting.isWaitingForApproval(), "Must wait for a decision");
check(waiting.toolCallsExecuted().isEmpty(), "No mutation before approval");
var approved = scenario().withApprovalDecision(true).run("Reserve stock");
check(approved.isSuccess(), approved.errorMessage());
check(approved.toolCallsExecuted().equals(List.of("reserve_stock")),
"Approval should execute one mutation");
var rejected = scenario().withApprovalDecision(false, "Customer cancelled")
.run("Reserve stock");
check(rejected.toolCallsExecuted().isEmpty(), "Rejected mutation must not run");
System.out.println("Approval checks passed: waiting, approved, rejected");
}
}Build and run:
"$PIPELOGIQ_SDK_DIR/mvnw" -q compile dependency:copy-dependencies
java -cp 'target/classes:target/dependency/*' example.AgentApprovalChecksThe harness supplies a test notification route and requires explicit decisions. In production, configure a real notification route and an authorized approval surface; a successful offline scenario does not prove delivery to Telegram or another external channel.
Other harness extension points include withToolPolicy, withCritic, withLifecycleObserver, withSessionStore, withMemoryStore and withPlanner. Inspect stageResults, contextSnapshot, notifications, toolCallsExecuted and finalResponse, or use getContextValue(key, type). A harness can be reused sequentially, but should not be run concurrently.
Test business stages through the execution boundary
For business unit tests, construct the stage and test its business dependencies directly. When you also want to verify SDK input conversion, error classification and context serialization, use HandlerRegistry with a small dispatch fixture. Protocol JSON belongs in this boundary test, not in production pipeline creation.
Example src/test/java/example/OrderStageTest.java, using the getting-started business classes:
package example;
import io.pipelogiq.sdk.core.Json;
import io.pipelogiq.sdk.core.execution.HandlerRegistry;
import io.pipelogiq.sdk.core.execution.StageContext;
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class OrderStageTest {
@Test
void aValidOrderSurvivesSerializedContextAndIsRecorded() {
var handlers = new HandlerRegistry().stages(ValidateOrder.class, RecordOrder.class);
var input = Json.object().put("stageHandlerName", "ValidateOrder")
.put("input", Json.write(new Order("test-1", new BigDecimal("12.00"))));
var validated = handlers.execute(input);
assertTrue(validated.path("isSuccess").asBoolean());
var next = Json.object().put("stageHandlerName", "RecordOrder");
next.set("contextItems", validated.path("contextItems"));
var recorded = handlers.execute(next);
assertTrue(recorded.path("isSuccess").asBoolean());
var context = new StageContext(
Json.object().set("contextItems", recorded.path("contextItems")));
assertEquals(true, context.require("recorded", Boolean.class));
}
@Test
void negativeAmountIsTerminalValidationFailure() {
var handlers = new HandlerRegistry().register(ValidateOrder.class);
var input = Json.object().put("stageHandlerName", "ValidateOrder")
.put("input", Json.write(new Order("invalid", new BigDecimal("-1.00"))));
var result = handlers.execute(input);
assertFalse(result.path("isSuccess").asBoolean());
assertEquals("VALIDATION_ERROR", result.path("errorCode").asText());
assertFalse(result.path("retryable").asBoolean());
}
}Add JUnit to your existing demo POM:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.14.4</version>
<scope>test</scope>
</dependency>Add this plugin alongside the compiler/dependency plugins, then run "$PIPELOGIQ_SDK_DIR/mvnw" test:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.6.0</version>
</plugin>The same approach can assert a handler's retryable result or returned dynamic-stage descriptor. Only a real server run proves that the server schedules the retry or applies the append at the durable stage boundary.
Use the testing module in an application test suite
For JUnit tests, add the harness with test scope:
<dependency>
<groupId>com.pipelogiq</groupId>
<artifactId>pipelogiq-sdk-testing</artifactId>
<version>0.5.0</version>
<scope>test</scope>
</dependency>Convert the assertions in the standalone approval example into JUnit tests under src/test/java. The downloaded agent demo deliberately keeps the dependency at normal scope for its runnable offline main; do not change it to test scope while that main still resides under src/main/java.
Run the live recipes
Use fresh business IDs so an old idempotent result does not masquerade as a new run:
- Start
OrdersExample; submit, wait, inspect validation failure and test idempotency. - Run the same commands with
PIPELOGIQ_TRANSPORT=amqpwhen your server's broker is reachable. - Start
WorkflowExample worker; submit a fresh order and observe the retry plus fifth, dynamically appended stage. - Create cron/interval/one-time schedules, manually trigger one, inspect history and archive recurring examples.
- Start the Spring demo and submit through the same producer contract.
- Run a provider-backed agent only when you intend to make real provider calls.
Exact commands and expected outputs are in getting started, workflows, schedules, Spring and agents.
Verify the SDK from source
From the extracted full source root, run builds sequentially in the same checkout:
./mvnw -B clean verify
./mvnw -B install
python3 -m unittest discover -s scripts -p 'test_*.py' -v
python3 scripts/verify-packages.pyThe package check consumes locally built artifacts from an isolated consumer project, including the minimal umbrella dependency path. This does not publish to Central. The full source also includes the examples and their deterministic tests:
./mvnw -B -pl examples -am testLive SDK Lab and cross-language store checks require the adjacent Lab/.NET setup described in the source repository guides; they are not created by the standalone demo ZIP. Live suites are gated, and skipped tests must be reported as skipped. Provider and notification fixtures do not make paid calls or send real messages.