Skip to content

Testing

Wabot incluye un kit de testing completo en un entrypoint dedicado: @wabot-dev/framework/testing. Es agnóstico del runner (funciona con node:test, Vitest y bun test) y te permite probar chatbots, controladores, endpoints REST, comandos asíncronos, validación y repositorios de forma determinista: sin llamadas a APIs de LLM y sin PostgreSQL.

Conceptos clave

  • createChatBotHarness(): ejecuta un ChatBot real (system prompt, loop de tools, validación de argumentos) contra un LLM guionado y memoria en RAM.
  • MockChatAdapter: adapter determinista que interpreta el papel del LLM — tú escribes el guion con reply() y callTool().
  • createChatControllerHarness(): prueba un @chatController de extremo a extremo sin canal real.
  • createRestHarness(): monta tus @restController en un servidor HTTP privado y ejercita el pipeline real (parsers, guards, validación).
  • createAsyncHarness(): ejecuta handlers de @command y @cronHandler en línea, sin workers.
  • useMemoryRepositories(): respalda todos tus @repository con un adaptador en RAM.
  • Convención de archivos: *.unit.test.ts para tests deterministas; *.eval.test.ts para evals con modelos reales (ver Evals con LLM).

Paso 1: Configura los scripts

La plantilla de proyecto ya incluye el script de tests unitarios. Si creaste el proyecto antes, agrégalo en package.json:

{
"scripts": {
"test:unit": "node --import=@yucacodes/ts --env-file=./.env --test './src/**/*.unit.test.ts'",
"test:eval": "node --import=@yucacodes/ts --env-file=./.env --test './src/**/*.eval.test.ts'"
}
}

Los tests viven junto al código que prueban: src/sales-bot/SalesModule.unit.test.ts.

Paso 2: Prueba tu chatbot (mindset + tools)

createChatBotHarness({ mindset }) construye el bot real. El MockChatAdapter hace de LLM: encolas sus “turnos” y el framework ejecuta todo lo demás de verdad — incluyendo tus tools, su validación y tus repositorios.

import assert from 'node:assert/strict'
import test from 'node:test'
import { Chat, container } from '@wabot-dev/framework'
import { createChatBotHarness, entityFixture, useMemoryRepositories } from '@wabot-dev/framework/testing'
import { SalesBotMindset } from './mindsets/SalesBotMindset'
import { ProspectRepository } from './repositories/ProspectRepository'
// Respalda los @repository con RAM. Llamar UNA vez, al inicio del archivo.
useMemoryRepositories()
// Nuestros módulos inyectan Chat (para this.chat.id), así que registramos
// un chat de prueba en el contenedor del harness.
const testChat = () =>
entityFixture(Chat, {
type: 'PRIVATE',
connections: [{ chatType: 'PRIVATE', channelName: 'TestChannel', id: 'test-chat' }],
})
test('el bot responde con su identidad', async () => {
const harness = createChatBotHarness({ mindset: SalesBotMindset })
harness.adapter.reply('¡Hola! Soy Martín, tu asesor comercial.')
const turn = await harness.send('hola')
assert.equal(turn.replies.length, 1)
assert.match(turn.replies[0].text ?? '', /Martín/)
})
test('el loop de tools persiste un prospecto real', async () => {
const harness = createChatBotHarness({
mindset: SalesBotMindset,
register: [[Chat, testChat()]],
})
// Guion del LLM: primero pide la tool, luego confirma con texto.
// El framework ejecuta createProspect DE VERDAD (validación + módulo + repo).
harness.adapter
.callTool('createProspect', { firstName: 'Ana', contactSource: 'whatsapp' })
.reply('¡Encantado, Ana! ¿En qué puedo ayudarte?')
const turn = await harness.send('hola, soy Ana')
// El turno reporta las tools ejecutadas y sus resultados
assert.equal(turn.toolCalls.length, 1)
assert.equal(turn.toolCalls[0].name, 'createProspect')
// ...y el efecto es observable en el repositorio real (en RAM)
const repository = container.resolve(ProspectRepository)
const nuevos = await repository.findByStatus('new')
assert.equal(nuevos.length, 1)
})

Dependencias de chat: el ChatBotHarness no registra un Chat por sí solo. Si tu mindset o tus módulos inyectan Chat, regístralo como arriba; si algo inyecta ChatOperator, agrega también [ChatRepository, new TestChatRepository()]. Alternativa más simple: usa ChatControllerHarness (Paso 3), que construye el contenedor del chat exactamente como producción.

El turno (IChatBotTurn)

harness.send(mensaje) retorna todo lo que hizo el bot en ese turno:

CampoContenido
repliesMensajes del bot entregados por el callback de respuesta.
toolCallsTools ejecutadas durante el turno, con sus resultados.
itemsTodos los chat items creados (humano, bot y tool calls).

Guionar el LLM

Método del adapterEfecto
.reply(texto)El “LLM” responde con texto plano.
.callTool(nombre, args)El “LLM” pide ejecutar una tool (el ChatBot la ejecuta de verdad).
.enqueue(items | fn)Turno crudo: lista de chat items, o función del request.

Cada respuesta encolada se consume en una llamada al LLM. Tras un callTool() el ChatBot vuelve a llamar al adapter con el resultado — encola la siguiente respuesta o crea el adapter con new MockChatAdapter({ fallbackReply: 'ok' }). Si la cola queda vacía sin fallback, el harness lanza un error explicativo.

Con harness.adapter.lastRequest puedes inspeccionar exactamente qué recibió el “modelo”: models, tools, systemPrompt y prevItems.

Probar una tool individual

harness.callTool(nombre, args) ejecuta una sola tool con la validación real, sin guionar una conversación. Retorna el string que recibiría el LLM:

test('argumentos inválidos devuelven INVALID_ARGUMENTS al LLM', async () => {
const harness = createChatBotHarness({ mindset: SalesBotMindset })
// quantity viola @min(1) y falta productName
const result = await harness.callTool('createOffer', { quantity: 0 })
assert.match(result, /INVALID_ARGUMENTS/)
assert.match(result, /productName/)
})

Snapshot del prompt y las tools

const prompt = await harness.systemPrompt() // el system prompt real del mindset
const tools = harness.tools() // las tools reales que ve el modelo
assert.deepEqual(tools.map((t) => t.name).sort(), ['createOffer', 'createProspect'])

Paso 3: Prueba un @chatController

createChatControllerHarness({ controller }) entrega mensajes por el mismo code path de producción (resolución del Chat, contenedor por mensaje, inyección de @chatBot), sin canal real:

import { createChatControllerHarness } from '@wabot-dev/framework/testing'
import { SalesChatController } from './SalesChatController'
test('el controller responde por el reply del canal', async () => {
const harness = createChatControllerHarness({ controller: SalesChatController })
harness.adapter.reply('¡Hola! ¿Qué necesitas?')
const turn = await harness.invoke('onMessage', 'hola')
assert.equal(turn.replies[0].text, '¡Hola! ¿Qué necesitas?')
})
test('la memoria persiste entre mensajes de la misma conexión', async () => {
const harness = createChatControllerHarness({ controller: SalesChatController })
harness.adapter.reply('Anotado').reply('Claro que sí')
await harness.invoke('onMessage', 'me interesa el plan Pro')
await harness.invoke('onMessage', '¿recuerdas qué me interesa?')
// El segundo request al LLM incluye los turnos anteriores
const transcript = JSON.stringify(harness.adapter.lastRequest?.prevItems)
assert.match(transcript, /plan Pro/)
})

Opciones: chatConnection (default { chatType: 'PRIVATE', channelName: 'TestChannel', id: 'test-chat' }), register y authInfo.

Paso 4: Prueba endpoints REST

createRestHarness({ controllers }) monta los controladores en un puerto efímero y ejercita el pipeline real — incluyendo middlewares, guards JWT/API key, validación (400 reales) y mapeo de errores:

import assert from 'node:assert/strict'
import test, { after } from 'node:test'
import { ApiKeyRepository } from '@wabot-dev/framework'
import { createRestHarness, RestHarness, TestApiKeyRepository } from '@wabot-dev/framework/testing'
import { ItemsController } from './ItemsController'
const apiKeys = new TestApiKeyRepository<{ userId: string }>()
let harness: RestHarness
test.before(async () => {
harness = await createRestHarness({
controllers: [ItemsController],
jwt: true, // @jwtGuard funciona sin JWT_SECRET en el env
register: [[ApiKeyRepository, apiKeys]], // @apiKeyGuard contra un repo en RAM
})
})
after(async () => await harness.close()) // siempre cierra el servidor
test('valida el request y retorna 400 con detalle', async () => {
const bad = await harness.request('POST', '/api/items', { body: {} })
assert.equal(bad.status, 400)
})
test('el guard JWT rechaza sin token y acepta con as()', async () => {
const anon = await harness.request('GET', '/api/items/secret')
assert.equal(anon.status, 401)
// as(authInfo) firma un Bearer token válido por request
const ok = await harness.as({ userId: 'u1' }).request('GET', '/api/items/secret')
assert.deepEqual(ok.body, { userId: 'u1' })
})
test('el guard de API key funciona con TestApiKeyRepository', async () => {
const secret = await apiKeys.addKey({ userId: 'u2' })
const res = await harness.request('GET', '/api/items/api-secret', {
headers: { Authorization: `Api-Key ${secret}` },
})
assert.equal(res.status, 200)
})

harness.request(método, ruta, { body?, headers?, query? }) retorna { status, body, headers } con el body parseado como JSON cuando aplica. harness.jwt.signInvalid() genera un token con firma incorrecta para tests de 401. harness.url expone la URL base si necesitas un cliente propio.

Paso 5: Prueba comandos y cron

createAsyncHarness() ejecuta los handlers en línea — sin PostgreSQL ni workers de polling — con la misma validación que aplica producción:

import { createAsyncHarness, isValidCronSequence } from '@wabot-dev/framework/testing'
import { EnviarEmailCommand } from './EnviarEmailCommand'
import { LimpiezaDiariaHandler } from './LimpiezaDiariaHandler'
test('ejecuta el handler con validación real', async () => {
const harness = createAsyncHarness({ register: [[EmailService, fakeEmailService]] })
const command = await harness.execute(EnviarEmailCommand, {
destinatario: 'ana@example.com',
asunto: 'Bienvenida',
})
assert.equal(fakeEmailService.sent.length, 1)
assert.equal(command.destinatario, 'ana@example.com') // comando ya transformado
})
test('rechaza datos inválidos con issues legibles', async () => {
const harness = createAsyncHarness()
await assert.rejects(() => harness.execute(EnviarEmailCommand, { destinatario: '' }), /destinatario/)
})
test('ejecuta un cron una vez', async () => {
const harness = createAsyncHarness()
await harness.runCron(LimpiezaDiariaHandler)
})

Helpers adicionales: waitUntil(async () => condición, timeoutMs?) para esperar condiciones, y isValidCronSequence('*/5 * * * *', fechas, { timezone?, toleranceMs? }) para verificar que una secuencia de ejecuciones respeta una expresión cron.

Paso 6: Repositorios y validación

import {
assertInvalid, assertValid, entityFixture,
useMemoryRepositories, validateFixture,
} from '@wabot-dev/framework/testing'
useMemoryRepositories() // una vez por archivo, ANTES de resolver cualquier repositorio
// Entidad "ya creada" (id y createdAt asignados, validada) para sembrar datos
const prospect = entityFixture(Prospect, { chatId: 'chat-1', status: 'new', /* ... */ }, { id: 'p-1' })
// Validación de modelos decorados, con issues aplanados por ruta
const value = assertValid(CreateOfferReq, { productName: 'Starter', quantity: 1 })
assertInvalid(CreateOfferReq, { quantity: 0 }, { path: 'productName' })
const { issues } = validateFixture(CreateOfferReq, datos) // [{ path: 'items[0].name', message }]

Importante: llama useMemoryRepositories() al inicio del archivo de test, antes de que algo resuelva un repositorio — cada @repository cachea su runtime en el primer uso. El store en RAM es por proceso y se comparte entre los tests del mismo archivo (con node:test, cada archivo corre en su propio proceso). Si tu repositorio usa @memExtension, importa el archivo de la extensión en el test (import por efecto secundario).

Reglas de oro

  • *.unit.test.ts debe ser determinista: MockChatAdapter guionado, useMemoryRepositories(), sin red.
  • Tras cada callTool() guionado, el ChatBot vuelve a llamar al adapter — guiona el siguiente turno o usa fallbackReply.
  • Las entradas de register son pares [token, instancia] — pasa instancias vivas, no clases.
  • Cierra siempre el RestHarness con await harness.close().

Siguiente paso

Para probar el comportamiento del bot con modelos reales y un juez LLM: Evals con LLM.