> ## Documentation Index
> Fetch the complete documentation index at: https://docs.os.meetluno.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Test workflows

> Run workflows in tests and assert their output, steps, and failures.

Workflows are deterministic orchestration, so unlike agents you can usually write real assertions on their output. Run the workflow with `executeWorkflow()` and check the result. Where a workflow includes an [agent](https://docs.os.meetluno.ai/learn/workflows/build-workflows.md#agent-steps) or [LLM](https://docs.os.meetluno.ai/learn/workflows/build-workflows.md#llm-steps) step, isolate that non-determinism so the rest of the workflow stays easy to assert.

Test workflows at the boundary you care about:

| Test style | Use when |
| - | - |
| Run tests | You want to execute the workflow and assert on its output |
| Definition tests | You want to verify the slug or input/output schema without running |
| Input validation tests | You want to confirm bad input is rejected |
| Integration tests | You want real action, agent, or LLM steps to run end to end |

Run tests from the project root:

```bash theme={null}
luno test --project unit          # src/**/*.test.ts
luno test --project integration   # src/**/*.int.test.ts
pnpm test                              # same — calls luno test
```

Vitest ships with `@meetluno/cli`. No project `vitest.config.ts` is required.

## Run a workflow in a test

`executeWorkflow()` runs one durable pass and resolves to a result you can assert on. The result is a discriminated union: `completed` with `output`, `failed` with `error`, `canceled`, or `suspended` when the run hit a [durable wait](https://docs.os.meetluno.ai/learn/workflows/build-workflows.md#durable-waits).

```ts theme={null}
import { executeWorkflow } from "@meetluno/luno/workflow";
import { describe, expect, it } from "vitest";
import greeting from "./greeting";

describe("greeting workflow", () => {
  it("returns a greeting for the input name", async () => {
    const result = await executeWorkflow(greeting, { name: "Ada" });

    expect(result).toEqual({
      status: "completed",
      output: { message: "Hello, Ada" },
    });
  });
});
```

For a workflow whose steps are pure or call deterministic actions, asserting the full `output` like this is the most useful test. Action steps run for real in-process; pass `resolveCredentials` in the options if they need credentials.

## Test durable waits

A workflow with a `.sleep()` / `.sleepUntil()` or a `hookStep()` suspends instead of completing in one pass. `executeWorkflow()` returns `{ status: "suspended", items }`, where each item's `kind` is `"sleep"` or `"hook"`. Assert that the run suspended where you expect:

```ts theme={null}
const result = await executeWorkflow(approvalFlow, { id: "req_1" });

expect(result.status).toBe("suspended");
if (result.status === "suspended") {
  expect(result.items[0].kind).toBe("hook");
}
```

## Stub agent and LLM steps

In-process, `agentStep()` and `llmStep()` call executors you pass to `executeWorkflow()`: `runAgent` for agent steps and `runLlm` for LLM steps. Without them those steps fail, which keeps unit tests from calling a model by accident. Pass stubs to test what the workflow does around an expensive step without running it:

```ts theme={null}
import { executeWorkflow } from "@meetluno/luno/workflow";
import { describe, expect, it } from "vitest";
import summarizeInbox from "./summarize-inbox";

describe("summarize-inbox", () => {
  it("returns the agent's reply as the summary", async () => {
    const result = await executeWorkflow(
      summarizeInbox,
      { subject: "Invoice", sender: "ada@example.com" },
      {
        runAgent: async () => ({ text: "Ada sent an invoice.", sessionId: "test", error: null }),
      },
    );

    expect(result).toEqual({ status: "completed", output: { summary: "Ada sent an invoice." } });
  });
});
```

`runAgent` receives the agent and `{ message, sessionId?, outputSchema? }` and returns `{ text, sessionId }` (plus `output` when the step declares `output`). `runLlm` receives `{ prompt, model, outputSchema?, … }` and returns the text, or the structured object when there is an `outputSchema`.

A [sub-workflow step](https://docs.os.meetluno.ai/learn/workflows/build-workflows.md#sub-workflow-steps) runs **inline** under local `executeWorkflow` and uses the same options. Local tests do **not** create separate child runs or `waiting_children` suspensions — those only appear on the deployed queued runtime. Assert queued-child behavior against a deployed project (or accept that local inline execution differs).

## Definition tests

Definition tests are fast and never run the workflow. Use them to catch accidental slug changes or schema edits that would break callers and triggers.

```ts theme={null}
import { describe, expect, it } from "vitest";
import signupPipeline from "./signup-pipeline";

describe("signup-pipeline workflow", () => {
  it("keeps its slug and input contract", () => {
    expect(signupPipeline.slug).toBe("signup-pipeline");
    expect(() => signupPipeline.input.parse({ name: "Ada", email: "ada@example.com" })).not.toThrow();
  });
});
```

Because `input` and `output` are Zod schemas, you can parse sample payloads against them directly.

## Input validation tests

A workflow rejects input that does not match its `input` schema before any step runs. Assert that bad input is refused.

```ts theme={null}
import { executeWorkflow } from "@meetluno/luno/workflow";
import { describe, expect, it } from "vitest";
import signupPipeline from "./signup-pipeline";

describe("signup-pipeline validation", () => {
  it("rejects a malformed email", async () => {
    await expect(
      executeWorkflow(signupPipeline, { name: "Ada", email: "not-an-email" }),
    ).rejects.toThrow();
  });
});
```

## Testing workflows with agent or LLM steps

A workflow with an `agentStep()` or `llmStep()` is no longer fully deterministic, so assert on the parts that are stable rather than exact model text.

* **Test deterministic actions separately.** Move logic-heavy steps into [actions](https://docs.os.meetluno.ai/learn/actions/overview.md) and unit-test those directly, so the workflow test only has to check orchestration.
* **Assert on shape, not wording.** For a step that returns model text, assert the output is a non-empty string or matches a structured `outputSchema`, not an exact sentence.
* **Guard real model runs.** To run an agent step against a real model, pass `runAgent` that prompts the agent (`agent.prompt(input)`). Tests that call a real model should skip when no provider key is available, so local and CI runs do not fail without credentials.

```ts theme={null}
import { executeWorkflow } from "@meetluno/luno/workflow";
import { describe, expect, it } from "vitest";
import support from "../agents/support";
import summarizeInbox from "./summarize-inbox";

describe("summarize-inbox workflow", () => {
  it.skipIf(!process.env.AI_GATEWAY_API_KEY)("summarizes with the real agent", async () => {
    const result = await executeWorkflow(
      summarizeInbox,
      { subject: "Invoice overdue", sender: "ada@example.com" },
      { runAgent: (_agent, input) => support.prompt(input) },
    );

    // `output` is typed `unknown`: assert its shape, not exact model text.
    expect(result).toMatchObject({
      status: "completed",
      output: { summary: expect.any(String) },
    });
  });
});
```

Run integration tests from the project root:

```bash theme={null}
luno test --project integration
# or
pnpm test -- --project integration
```

Integration tests load `.env` when present and skip when required keys are unset. Vitest and its config ship with `@meetluno/cli` — no project `vitest.config.ts`.

## Inspect failing runs

When a test fails, or a real run misbehaves, inspect the run before changing code. From the CLI:

```bash theme={null}
luno workflows runs list signup-pipeline --status failed
luno workflows runs get <run-id> --summary
luno workflows runs get signup-pipeline <run-id> --include trigger,steps,trace,children
```

`workflows runs get` accepts a bare `<run-id>` or the compound `<workflowSlug> <run-id>`. The `steps` include shows each recorded step (including `input` and `output` when available) and where the run failed. Pass `children` to embed nested child-run detail at the same include depth. For deployed workflows, use **History** in the web app and filter to workflow runs; the detail panel shows input, output, steps, errors, and trace data. See [workflow runs](https://docs.os.meetluno.ai/learn/logs/workflow-runs.md).

## Next steps

> **[Build workflows](https://docs.os.meetluno.ai/learn/workflows/build-workflows.md)**
>
> Compose actions, agents, and durable steps.

> **[Run workflows](https://docs.os.meetluno.ai/learn/workflows/run-workflows.md)**
>
> Start runs from the CLI, triggers, the API, and agent tools.

> **[Workflow runs](https://docs.os.meetluno.ai/learn/logs/workflow-runs.md)**
>
> Debug failed runs in the web app.

> **[Deploy a project](https://docs.os.meetluno.ai/learn/projects/deploy-a-project.md)**
>
> Run tests before deploying changed workflows.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.