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:
| Surface | What it asserts |
|---|---|
calls | Step counts and provider effects (GitHub/Slack operations) |
prompts | Final AI prompts after templating and context assembly |
outputs | Step outputs, including history and selectors |
workflow_output | Workflow-level outputs |
no_calls | That specific steps or provider operations were not called |
fail | That the case failed with a specific message |
strict_violation | Strict-mode failure for a missing expect on a step |
llm_judge | Semantic evaluation of outputs using an LLM, as pass/fail or structured extraction |
use | Reusable 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
- List the observable effects first: which steps, which provider operations, how many times.
- Encode counts with
exactly,at_least, orat_most, choosing per the harm a duplicate or omission causes. - Pin arguments with
args.containsfor labels and message substrings. - Assert prompts with
contains,not_contains, and regex, selecting the right item withindexorwhere. - Add
no_callsfor every side effect the workflow must never trigger. - Enable strict mode so a missing
expectis a failure, not a gap. - Reserve
llm_judgefor requirements you cannot express as substrings, and validate the judge before gating on it. - 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.