AIForum.

How to Write Acceptance Tests for AI Workflow Outputs

On this page

THE SHORT ANSWER

Acceptance tests for AI workflows should verify intent and observable effects, not exact wording. This guide explains how to combine action-level assertions (tool calls, provider operations, step counts) with tolerant content matching, negative assertions, and semantic judging, using the documented assertion surfaces in Visor and the scoring model in AgenticAssure.
Quick facts

Acceptance tests for AI workflow outputs verify that a workflow achieved its intended effect rather than reproducing one exact string. In practice that means asserting on two layers: the observable actions a workflow took — tool calls, provider operations, step counts — and the content it produced, such as final prompts, step outputs, and workflow-level results. The Visor assertions documentation and AgenticAssure’s scenario guide document complementary ways to do both.

This guide is for practitioners who already have a working AI workflow — an agent that reviews pull requests, drafts copy, or posts to Slack — and now need tests that catch real regressions without failing every time the model rephrases a sentence.

Why exact-match assertions break on AI workflows

Traditional unit tests assert equality: given input X, the function returns Y. LLM-powered agents do not behave that way. As AgenticAssure’s scenario guide puts it, the same prompt can produce different but equally valid responses across runs. A support agent asked “How do I reset my password?” might answer with a direct Settings path, a paraphrase about account settings and the security tab, or a numbered list of steps. All three are correct.

The guide’s stated principle is therefore to test for intent and correctness, not for exact wording. The failures you still want to catch are the ones that survive rephrasing: hallucinated URLs, missing steps, and unsafe advice. An assertion that compares full strings will flag all three correct answers as failures and will still miss a confidently wrong answer that happens to match a template.

Separate the effect layer from the content layer

Before writing anything, sort what you want to check into two buckets. Effects are deterministic and cheap to verify: did the workflow add the label, post the message, call the step exactly once? Content is probabilistic: does the summary mention the right file, does the comment read as a review rather than a changelog?

Visor’s assertion documentation organizes this explicitly under an expect: block, with separate surfaces for each kind of claim:

SurfaceWhat it asserts
callsStep counts and provider effects (GitHub/Slack operations)
promptsFinal AI prompts after templating and context assembly
outputsStep outputs, including history and selectors
workflow_outputWorkflow-level outputs
no_callsThat specific steps or provider operations were not called
failThat the case failed with a specific message
strict_violationStrict-mode failure for a missing expect on a step
llm_judgeSemantic evaluation of outputs using an LLM, as pass/fail or structured extraction
useReusable macros defined in tests.defaults.macros

Keeping these surfaces separate matters because they fail for different reasons. A broken prompt template breaks prompts; a misconfigured integration breaks calls; a model that suddenly writes in bullet points only affects content assertions.

Start with the actions, not the prose

The most valuable acceptance assertions are usually about side effects, because side effects are hard to undo and easy to observe. Visor’s documented calls syntax uses consistent count operators — exactly, at_least, and at_most — and supports the providers github and slack:

expect:
  calls:
    - step: overview
      exactly: 1
    - provider: github
      op: labels.add
      at_least: 1
      args:
        contains: [feature, "review/effort:2"]
    - provider: slack
      op: chat.postMessage
      at_least: 1
      args:
        contains: ["Review complete"]

The args field supports a contains list: for labels, the values must be present; for Slack text, the values are treated as substrings. That gives you a check that is specific enough to catch a wrong label or a message sent to the wrong destination, without depending on the model’s phrasing.

Choosing between the count operators is a design decision, not a formality. Use exactly: 1 where a duplicate is harmful, such as posting the same review comment twice. Use at_least where retries are legitimate. Use at_most where an operation is optional but must not multiply.

Match content with tolerant rules

For prompts and outputs, Visor documents a small set of matching tools: contains for required substrings, not_contains for forbidden substrings, matches for regular expressions (with the (?i) prefix for case-insensitive matching), index to select first, last, or an N-th item from history, and where to select which prompt to check when a step has produced several.

expect:
  prompts:
    - step: overview
      contains: ["feat: add user search", "diff --git a/src/search.ts"]
    - step: comment-assistant
      matches: "(?i)visor help"
    - step: overview
      where:
        contains: ["src/search.ts"]
      contains: ["diff --git a/src/search.ts"]

The where selector is the part most people miss. When a step runs more than once, checking “the prompt” is ambiguous; the selector pins the assertion to the prompt that mentions a specific file. Without it, a test can pass against the wrong invocation.

Use a scorer that tolerates phrasing

AgenticAssure approaches the same problem from the scoring side. Its default passfail scorer checks four things in order: the agent produced non-empty output; if expected_tools is set, all listed tools were called; if expected_tool_args is set, each tool was called with the correct arguments; and if expected_output is set, that string appears somewhere in the response, case-insensitively.

The guide describes passfail as the most forgiving scorer and suitable for the majority of test cases, precisely because output matching uses substring containment. An expected_output of reset your password passes as long as that phrase appears anywhere in the answer. The tool assertions carry the real weight, which is the same conclusion the action-first approach reaches from the other direction.

Add semantic checks only where wording matters

Some requirements cannot be expressed as substrings: “the review is polite,” “the answer does not give medical advice,” “the summary names the responsible team.” Visor’s llm_judge surface exists for this, offering semantic evaluation of outputs as pass/fail verdicts or structured extraction.

Treat judges as a last resort rather than a default. A judge adds latency, cost, and its own source of non-determinism, so it can make an already variable suite harder to debug. The supplied documentation describes what llm_judge does but not how to configure its criteria or how stable its verdicts are across runs, so plan to validate any judge you adopt against known-good and known-bad outputs before trusting it as a gate.

Assert on what should not happen

Negative assertions are where acceptance tests earn their keep. Visor documents no_calls for asserting that specific steps or provider operations were not called, fail for asserting that a case failed with a specific message, and strict_violation for strict-mode failures when a step is missing an expect.

Two of these are easy to overlook. First, no_calls catches the silent extra side effect — a second comment, a label added to the wrong issue, a message sent to a channel the workflow should never touch. Second, strict mode converts “someone forgot to write an assertion for this step” into a test failure instead of a silent gap, which is the difference between a suite that covers a workflow and one that only appears to.

A worked illustrative example

The following is an illustrative design, not a recorded test run. It combines documented Visor surfaces into a single acceptance case for a pull-request review workflow that summarizes a diff, labels the PR, and notifies a Slack channel only after a human has approved the review.

expect:
  calls:
    - step: overview
      exactly: 1
    - provider: github
      op: labels.add
      exactly: 1
      args:
        contains: [feature, "review/effort:2"]
    - provider: slack
      op: chat.postMessage
      at_most: 1
      args:
        contains: ["Review complete"]
  no_calls:
    - provider: github
      op: pulls.createReview
  prompts:
    - step: overview
      contains: ["diff --git"]
      not_contains: ["lorem ipsum"]
  outputs:
    - step: overview
      contains: ["search"]

The intended observable result: the overview step runs once, exactly one label set is applied, at most one Slack notification is sent, no review is submitted automatically, and the prompt that reached the model actually contained a diff. If the model writes a beautiful summary but skips the label, the test fails. If it labels correctly but also submits a review, the test fails. If it produces a perfect review in different words, the test passes — which is the point.

Reuse, maintenance, and the cost of tolerance

Visor’s use surface references reusable macros defined in tests.defaults.macros, which keeps repeated assertions such as “this workflow always notifies Slack” in one place. Centralizing them reduces drift, but it also means a wrong macro fails many tests at once, so keep macros narrow.

The bigger maintenance risk is tolerance. Every loosened matcher buys stability and costs precision. A contains check for a common word can pass on coincidental text; a substring match cannot tell you that a URL in the output is hallucinated or that a step was skipped in the reasoning. AgenticAssure’s guide names hallucinated URLs, missing steps, and unsafe advice as the genuine failures to catch, and substring containment alone will not catch them. Combine effect assertions, negative assertions, and — sparingly — semantic checks if those are your real risks.

Limitations and verification gaps

Several practical constraints follow from the supplied documentation. The documented provider list for Visor’s calls assertions is github and slack; other integrations need their own assertion support or external verification. AgenticAssure documents four built-in scorers and describes passfail in detail, but the other scorers’ behavior is not covered here. Neither source provides pricing, performance figures, or version histories, so no cost or speed claims are made in this article.

Finally, non-determinism sets a ceiling on what any single passing run proves. A green acceptance test is evidence that the workflow met your criteria on that run, not proof that it always will. Run the suite across several model outputs, and treat a test that passes only on the first attempt as a flaky test rather than a satisfied requirement.

A practical checklist

  1. List the observable effects first: which steps, which provider operations, how many times.
  2. Encode counts with exactly, at_least, or at_most, choosing per the harm a duplicate or omission causes.
  3. Pin arguments with args.contains for labels and message substrings.
  4. Assert prompts with contains, not_contains, and regex, selecting the right item with index or where.
  5. Add no_calls for every side effect the workflow must never trigger.
  6. Enable strict mode so a missing expect is a failure, not a gap.
  7. Reserve llm_judge for requirements you cannot express as substrings, and validate the judge before gating on it.
  8. Re-run the suite against multiple outputs before declaring it stable.

If you are starting today, write the calls and no_calls block for one workflow, run it against three different model outputs, and only then add content assertions. If your results differ from what is described here — a provider operation that never appears, a matcher that behaves unexpectedly — share the workflow shape, the assertion block, and the observed output with the community so others can reproduce it.

FAQ

Should acceptance tests assert the exact text an AI workflow produces?

Generally no. AgenticAssure's scenario guide notes that the same prompt can yield different but equally valid responses, and recommends testing for intent and correctness rather than exact wording. Assert on effects such as tool calls, then use substring containment or semantic checks for content.

What is the difference between asserting on calls and asserting on outputs?

In Visor's model, calls cover step counts and provider effects such as GitHub or Slack operations, while outputs cover the content a step produced, including history and selectors. Calls are deterministic and usually the stronger signal; outputs are probabilistic and need tolerant matching.

How do I test that a workflow did not perform an action?

Use the no_calls assertion surface, which asserts that specific steps or provider operations were not called. This catches silent extra side effects such as duplicate comments or messages sent to an unintended destination.

Can assertions be reused across multiple tests?

Yes. Visor documents a use surface that references reusable macros defined in tests.defaults.macros, which lets you define a shared assertion such as a required Slack notification once and reference it from several cases.

When should I use an LLM judge instead of a substring check?

Use llm_judge when the requirement is semantic — tone, policy compliance, or whether a specific fact is present — and cannot be expressed as a substring or regex. Because a judge adds cost, latency, and its own variability, validate it against known-good and known-bad outputs before using it as a gate.

References

  1. https://github.com/probelabs/visor/blob/main/docs/testing/assertions.md
  2. https://docs.agenticassure.com/guides/writing-scenarios

Related terms

Acceptance test
A test that checks whether a system achieved its intended outcome, rather than whether an internal function returned a specific value.
Non-determinism
The property that the same input can produce different but equally valid outputs across runs, which is normal for LLM-powered agents.
Strict mode
A mode in which a step missing an expect assertion is itself treated as a test failure, surfaced in Visor via the strict_violation assertion.
LLM judge
A semantic evaluation in which a language model returns a pass/fail verdict or structured extraction for an output, used when substring matching cannot express the requirement.
Tool call assertion
A check that a specific tool or provider operation was invoked, optionally with required argument contents and a count.

Leave a Reply

Your email address will not be published. Required fields are marked *