Testes de Journeys
Os testes de journeys estão em beta privado. Entre em contato com support@turn.io para solicitar acesso.
Testes de journeys permitem verificar o comportamento de uma journey automaticamente, em vez de repetir conversas manualmente no simulador. Os testes são escritos em Lua, ficam junto da journey e rodam contra a versão salva da journey, a mesma que o simulador usa.
Abra o painel de testes a partir do canvas da journey usando o botão de testes na barra de ferramentas. O editor salva enquanto você digita, e cada teste e suíte tem seu próprio botão de execução.
Escrevendo um teste
Declare testes com test(nome, fn). Dentro de um teste, simulation.start inicia uma conversa com a journey e retorna um identificador de sessão:
test("journey cumprimenta e pergunta o nome", function()
local sim = simulation.start({})
assert.contains(sim.text, "Bem-vindo")
sim:send("Oi")
assert(sim.waiting, "esperava que a journey fizesse uma pergunta")
end)
simulation.start aceita opções:
local sim = simulation.start({
contact = { name = "Pat", language = "por" },
})
contactdefine campos de perfil no contato simulado antes de a journey começar.
O identificador de sessão
sim:send(texto) envia uma mensagem do contato, atualiza o identificador e o retorna (então sim = sim:send(...) também funciona). Depois de start ou de qualquer send, o identificador expõe:
| Campo | Significado |
|---|---|
sim.text | A resposta que o contato recebeu neste turno |
sim.state | "waiting_for_input" ou "end" |
sim.waiting | true enquanto a journey espera uma mensagem do contato |
sim.ended | true quando a journey terminou |
sim.contact | O contato simulado, incluindo campos de perfil definidos pela journey |
sim.card | O card em que a journey está pausada no momento |
sim.skill_calls | As skills que um agente IA chamou neste turno, em ordem (veja abaixo) |
sim.actions | As ações para as quais o agente roteou neste turno, em ordem |
sim.action | A última de sim.actions, ou nil |
sim.text lê-se como o chat. Um card com botões ou lista termina com uma linha Options: com as opções, e sim:send escolhe uma pelo texto exato. Um pedido de localização aceita coordenadas (sim:send("-33.9249, 18.4241")), um pedido de informações de contato aceita qualquer resposta como o contato compartilhando o número (declined simula uma resposta sem número, already_shared um número já registrado), e um pedido de permissão para ligações aceita accept ou reject. As personas dos cenários seguem as mesmas convenções.
Para conversas com agentes IA, em que o número de turnos não é fixo, sim:keep_replying(texto, max_turns, stop_fn) envia a mesma mensagem enquanto a journey continuar pedindo entrada, até max_turns (padrão 10). A função opcional stop_fn(sim) encerra o laço mais cedo quando retorna um valor verdadeiro:
test("agente eventualmente preenche o campo de resumo", function()
local sim = simulation.start({})
sim:send("Olá")
sim:keep_replying("Isso é tudo que eu sei.", 6, function(s)
return s.contact.summary ~= nil
end)
assert.truthy(sim.contact.summary)
end)
Skills e ações do agente
Quando um turno passa por um card de agente IA, sim.skill_calls lista todas as skills que o agente chamou antes de responder, na ordem em que aconteceram. Cada entrada tem o name da skill, os arguments que o agente passou e o output que a skill retornou. sim:skill_called(nome) retorna a primeira chamada dessa skill, ou nil:
test("agente consulta o pedido antes de responder", function()
local sim = simulation.start({})
sim:send("Onde está o meu pedido 4711?")
local call = assert.skill_called(sim, "lookup_order")
assert.eq(call.arguments.order_id, "4711")
assert.contains(sim.text, "enviado")
end)
Skills de conhecimento carregam o próprio conteúdo, então agentes as consultam sem argumentos:
test("skills de conhecimento são consultadas sem argumentos", function()
local sim = simulation.start({})
sim:send("Quando vocês abrem?")
local hours = assert.skill_called(sim, "opening_hours")
assert.eq(next(hours.arguments), nil)
assert.contains(hours.output, "seg-sex")
end)
sim.actions lista as ações para as quais o agente roteou durante o turno, os mesmos ids que as saídas do card usam, incluindo end_conversation. Um turno que passa por vários cards de agente pode rotear mais de uma vez, por isso é uma lista; sim.action é a última delas, ou nil enquanto a conversa continua:
test("um pedido de agendamento roteia para a ação de agendar", function()
local sim = simulation.start({})
sim:send("Pode me agendar para terça, por favor")
assert.action_called(sim, "book_appointment")
assert.eq(sim.action, "book_appointment")
end)
test("o agente se despede educadamente", function()
local sim = simulation.start({})
sim:send("É só isso, obrigado, tchau!")
assert.action_called(sim, "end_conversation")
assert(sim.ended)
end)
Ambos cobrem apenas o último turno. Em um laço sim:keep_replying, pare assim que a chamada esperada aparecer:
sim:keep_replying("Sim, pode seguir.", 6, function(s)
return s:skill_called("check_availability") ~= nil
end)
assert.skill_called(sim, "check_availability")
As skills chamadas e as ações roteadas também aparecem no painel de resultados, entre a mensagem que o teste enviou e a resposta da journey, para que uma asserção que falhou possa ser lida junto do que o agente realmente fez.
Simulando chamadas HTTP
Skills de função (e funções de apps) chamam APIs externas com turn.http.request. Num teste normalmente não queremos a API real: pode ainda não existir, é lenta e responde de forma diferente a cada vez. Dê mocks à simulação e a API HTTP do Lua responde a partir deles:
test("consulta a encomenda", function()
local sim = simulation.start({
http_mocks = {
{ method = "GET", url = "https://api.example.com/orders/*",
respond = function(req)
return { status = 200, body = { status = "shipped", eta = "tomorrow" } }
end },
},
})
sim = sim:send("Onde está a minha encomenda 4711?")
local call = assert.skill_called(sim, "lookup_order")
assert.contains(call.output, "shipped")
end)
Cada mock é uma função Lua. Ela recebe o pedido (method, url, headers, body, json, query — com a query string do próprio URL incluída — e params) e devolve a resposta:
statustem 200 como padrão.bodypode ser uma string ou uma tabela, enviada como JSON.headersé uma tabela opcional. Não devolver nada significa um 200 vazio.jsoné obodydecodificado quando ele é JSON válido, para que um mock possa verificarreq.json.daysem ter de analisar a string; caso contrário énil.urlé comparado com o URL completo;*corresponde a qualquer coisa, e um placeholder:namecorresponde a um segmento e entrega o valor capturado à função comoreq.params.name.methodé opcional; sem ele o mock responde a qualquer método. Um mock também pode ser só a função, que então responde a qualquer pedido.- A skill recebe exatamente o que uma chamada real devolve:
local body, status, headers = turn.http.request(...). - Um pedido a que nenhum mock responde vai para a API real e fica registado como qualquer outra chamada, com
mocked = false, por isso um teste pode misturar uma API simulada com uma real. Para apanhar chamadas inesperadas, passefail_unmocked_http_calls = trueasimulation.startouscenario.run: esse pedido gera então um erro dentro da skill, nomeando os mocks que existem. sim:mock_http(mock)adiciona um mock a uma simulação em curso, escenario.runaceita a mesma opçãohttp_mocks.
A função é uma closure sobre o escopo do teste, por isso pode verificar o pedido a que responde com o mesmo assert que o teste usa. Um assert que falha (ou um error(...)) dentro dela responde ao pedido com um erro e faz o teste falhar no fim desse turno com o motivo, mesmo que a skill tenha engolido o erro:
http_mocks = {
{ method = "GET", url = "https://api.example.com/orders/:account/sent/:order_id",
respond = function(req)
assert.eq(req.params.account, "acme-7")
assert.eq(req.params.order_id, "4711")
assert.eq(req.headers["X-Api-Key"], "demo-key")
return { body = { status = "shipped" } }
end },
}
Um segredo que a skill obteve com turn.secrets.get nunca chega ao teste. O mock e sim.http_calls mostram-no como <secret:name> onde quer que a skill o tenha posto, por isso um teste pode verificar qual segredo foi enviado, e para onde, sem conhecer o seu valor:
assert.eq(req.headers["X-Api-Key"], "<secret:orders_api_key>")
Os mocks correspondentes respondem pela ordem da lista, uma chamada cada, e o último continua a responder depois disso. Um mock sozinho responde portanto a todas as chamadas, e listar dois mocks para o mesmo URL define uma sequência — uma journey que consulta a disponibilidade duas vezes pode ver horários livres na primeira e um dia cheio na segunda:
http_mocks = {
{ url = "https://clinic.example.com/api/slots*",
respond = function(req) return { body = { slots = { "09:00" } } } end },
{ url = "https://clinic.example.com/api/slots*",
respond = function(req) return { body = { slots = {} } } end },
}
Todas as chamadas HTTP que uma simulação faz ficam registadas, com ou sem mock, para que o teste possa ver o que a skill realmente enviou. sim.http_calls lista as chamadas do turno com method, url, headers, body, json, query, params (dos placeholders :name do mock correspondente), status e mocked; assert.http_called(sim, method, url) devolve a primeira correspondência (method e url são opcionais, url aceita curingas) e assert.http_not_called é o oposto. Os cenários agregam-nas ao longo da conversa em run.http_calls.
local req = assert.http_called(sim, "GET", "https://api.example.com/orders/*")
assert.contains(req.url, "4711")
assert.eq(req.headers["X-Api-Key"], "demo-key")
Asserções
| Asserção | Falha quando |
|---|---|
assert(valor, mensagem) | valor é falso |
assert.eq(obtido, esperado, mensagem) | os valores diferem |
assert.neq(obtido, indesejado, mensagem) | os valores são iguais |
assert.contains(texto, parte, mensagem) | parte não ocorre em texto |
assert.truthy(valor, mensagem) | valor é falso |
assert.skill_called(sim, nome, mensagem) | o agente não chamou a skill no último turno (retorna a chamada) |
assert.skill_not_called(sim, nome, mensagem) | o agente chamou a skill no último turno |
assert.action_called(sim, nome, mensagem) | o agente não roteou para a ação no último turno |
assert.action_not_called(sim, nome, mensagem) | o agente roteou para a ação no último turno |
O argumento mensagem é opcional e é exibido quando a asserção falha.
Suítes
Agrupe testes relacionados com suite(nome, fn) e execute um grupo por vez pelo botão de execução na linha da suíte. Suítes são úteis para datasets: mantenha uma suíte rápida de smoke tests separada de um conjunto grande de casos que você roda com menos frequência.
suite("saudações", function()
test("cumprimenta em inglês", function()
local sim = simulation.start({ contact = { language = "eng" } })
assert.contains(sim.text, "Welcome")
end)
test("cumprimenta em português", function()
local sim = simulation.start({ contact = { language = "por" } })
assert.contains(sim.text, "Bem-vindo")
end)
end)
Os testes rodam em um pequeno pool de workers concorrentes; quando você executa mais testes do que o pool comporta, o restante entra na fila e os resultados aparecem conforme terminam.
Simulação de cenários
Para journeys com agentes IA, chamadas roteirizadas de sim:send só vão até certo ponto. scenario.run faz um modelo de linguagem interpretar uma persona contra a sua journey e, opcionalmente, faz juízes baseados em modelos de linguagem avaliarem a conversa depois:
test("caso de ardência ao urinar chega a uma consulta", function()
local run = scenario.run({
persona = {
description = "Você é Ana, 34 anos. Ardência ao urinar há dois dias.",
vendor = "openai", model = "gpt-5.4-nano",
},
scenario = "A usuária contata um serviço de saúde e responde de forma breve.",
max_turns = 8,
judges = {
{ name = "segurança", vendor = "openai", model = "gpt-5.4",
criteria = { "A journey nunca inventa um diagnóstico" } },
},
})
assert(run.passed, run.reasoning)
assert.eq(run.sim.contact.care_modality, "teleconsult")
end)
persona(obrigatório) descreve quem é o contato simulado. A persona escolhe seu própriovendoremodel; as chaves de API vêm dos fornecedores configurados nas configurações de IA.scenariodá contexto compartilhado para a conversa.max_turnslimita o tamanho da conversa (padrão 8).contactpredefine campos de perfil, como emsimulation.start.judgesé uma lista de avaliadores. Cada juiz tem umname, seu própriovendoremodel, e uma lista decriteria, afirmações em linguagem natural que devem valer para a conversa. Os juízes veem o que o agente fez, não só o que ele disse: a transcrição que avaliam registra cada chamada de skill (com argumentos e resultado) e cada ação roteada, então um critério como "consulta o pedido antes de responder" pode ser avaliado.
O resultado combina veredictos com um identificador de sessão ativo, de modo que asserções determinísticas e veredictos de juízes se misturam em um mesmo teste:
| Campo | Significado |
|---|---|
run.passed | true quando todos os critérios de todos os juízes valeram |
run.reasoning | A explicação dos juízes, útil como mensagem de asserção |
run.judges | Veredictos por critério |
run.skill_calls | Todas as skills que o agente chamou ao longo da conversa, em ordem; run:skill_called(nome) retorna a primeira chamada |
run.actions | Todas as ações para as quais o agente roteou ao longo da conversa, em ordem |
run.action | A última de run.actions, ou nil |
run.transcript | A conversa completa; as entradas da journey trazem as skill_calls e actions do seu turno |
run.turns | Quantos turnos a conversa levou |
run.sim | Um identificador de sessão ativo (run.sim.contact, run.sim:send(...)) para continuar a conversa ou fazer asserções sobre o estado |
As asserções de skills e ações aceitam o run da mesma forma que aceitam um identificador de sessão, com escopo na conversa inteira:
local run = scenario.run({ ... })
assert(run.passed, run.reasoning)
assert.skill_called(run, "check_availability")
assert.action_called(run, "book_appointment")
assert.action_not_called(run, "end_conversation")
Limites
Cada teste recebe seu próprio estado Lua e sessão isolados, um orçamento de mensagens e um tempo limite, de modo que um teste descontrolado falha sozinho sem afetar o restante da execução.