Skip to main content

Journey Tests

Private beta

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" },
})
  • contact sets 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:

FieldMeaning
sim.textThe reply the contact received in this turn
sim.state"waiting_for_input" or "end"
sim.waitingtrue while the journey waits for a contact message
sim.endedtrue once the journey has finished
sim.contactThe simulated contact, including profile fields the journey has set
sim.cardThe 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

AssertionFails 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 own vendor and model; API keys come from the vendors configured under AI settings.
  • scenario gives shared context for the conversation.
  • max_turns caps the conversation length (default 8).
  • contact presets profile fields, like in simulation.start.
  • judges is a list of graders. Each judge has a name, its own vendor and model, and a list of criteria, 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:

FieldMeaning
run.passedtrue when every judge criterion held
run.reasoningThe judges' explanation, useful as an assertion message
run.judgesPer-criterion verdicts
run.transcriptThe full conversation
run.turnsHow many turns the conversation took
run.simA 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.