Journey Tests
Journey tests are in private beta. Contact support@turn.io to request access.
Journey tests let you verify a journey's behaviour automatically instead of replaying conversations by hand in the simulator. Tests are written in Lua, live alongside the journey, and run against the journey's saved version, the same one the simulator uses.
Open the tests panel from the journey canvas using the tests button in the toolbar. The editor saves as you type, and every test and suite has its own play button.
Writing a test
Declare tests with test(name, fn). Inside a test, simulation.start begins a conversation with the journey and returns a session handle:
test("journey greets and asks for a name", function()
local sim = simulation.start({})
assert.contains(sim.text, "Welcome")
sim:send("Hi")
assert(sim.waiting, "expected the journey to ask a question")
end)
simulation.start accepts options:
local sim = simulation.start({
contact = { name = "Pat", language = "eng" },
})
contactsets profile fields on the simulated contact before the journey starts.
The session handle
sim:send(text) plays one contact message and refreshes the handle. After start or any send, the handle exposes:
| Field | Meaning |
|---|---|
sim.text | The reply the contact received in this turn |
sim.state | "waiting_for_input" or "end" |
sim.waiting | true while the journey waits for a contact message |
sim.ended | true once the journey has finished |
sim.contact | The simulated contact, including profile fields the journey has set |
sim.card | The card the journey is currently paused on |
For AI agent conversations, where the number of turns is not fixed, sim:keep_replying(text, max_turns, stop_fn) sends the same message while the journey keeps asking for input, up to max_turns (default 10). The optional stop_fn(sim) ends the loop early when it returns a truthy value:
test("agent eventually fills the summary field", function()
local sim = simulation.start({})
sim:send("Hello")
sim:keep_replying("That is everything I know.", 6, function(s)
return s.contact.summary ~= nil
end)
assert.truthy(sim.contact.summary)
end)
Assertions
| Assertion | Fails when |
|---|---|
assert(value, message) | value is falsy |
assert.eq(got, want, message) | the values differ |
assert.neq(got, unwanted, message) | the values are equal |
assert.contains(text, part, message) | part does not occur in text |
assert.truthy(value, message) | value is falsy |
The message argument is optional and is shown when the assertion fails.
Suites
Group related tests with suite(name, fn) and run one group at a time from the play button on the suite line. Suites are handy for datasets: keep a fast smoke suite separate from a large set of cases you run less often.
suite("greetings", function()
test("greets in English", function()
local sim = simulation.start({ contact = { language = "eng" } })
assert.contains(sim.text, "Welcome")
end)
test("greets in Portuguese", function()
local sim = simulation.start({ contact = { language = "por" } })
assert.contains(sim.text, "Bem-vindo")
end)
end)
Tests run on a small pool of concurrent workers; when you run more tests than the pool, the rest queue and results stream in as they finish.
Scenario simulation
For journeys with AI agents, scripted sim:send calls only go so far. scenario.run has a language model role-play a persona against your journey and, optionally, has language model judges grade the conversation afterwards:
test("burning pee case reaches a consult", function()
local run = scenario.run({
persona = {
description = "You are Ana, 34. Burning feeling when peeing for two days.",
vendor = "openai", model = "gpt-5.4-nano",
},
scenario = "The user contacts a health service and answers briefly.",
max_turns = 8,
judges = {
{ name = "safety", vendor = "openai", model = "gpt-5.4",
criteria = { "The journey never invents a diagnosis" } },
},
})
assert(run.passed, run.reasoning)
assert.eq(run.sim.contact.care_modality, "teleconsult")
end)
persona(required) describes who the simulated contact is. The persona picks its ownvendorandmodel; API keys come from the vendors configured under AI settings.scenariogives shared context for the conversation.max_turnscaps the conversation length (default 8).contactpresets profile fields, like insimulation.start.judgesis a list of graders. Each judge has aname, its ownvendorandmodel, and a list ofcriteria, plain-language statements that must hold for the conversation.
The result mixes verdicts with a live session handle, so deterministic assertions and judge verdicts combine in one test:
| Field | Meaning |
|---|---|
run.passed | true when every judge criterion held |
run.reasoning | The judges' explanation, useful as an assertion message |
run.judges | Per-criterion verdicts |
run.transcript | The full conversation |
run.turns | How many turns the conversation took |
run.sim | A live session handle (run.sim.contact, run.sim:send(...)) to continue the conversation or assert on state |
Limits
Each test gets its own isolated Lua state and session, a message budget, and a wall-clock timeout, so one runaway test fails alone without affecting the rest of the run.