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 conreply()ycallTool().createChatControllerHarness(): prueba un@chatControllerde extremo a extremo sin canal real.createRestHarness(): monta tus@restControlleren un servidor HTTP privado y ejercita el pipeline real (parsers, guards, validación).createAsyncHarness(): ejecuta handlers de@commandy@cronHandleren línea, sin workers.useMemoryRepositories(): respalda todos tus@repositorycon un adaptador en RAM.- Convención de archivos:
*.unit.test.tspara tests deterministas;*.eval.test.tspara 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
ChatBotHarnessno registra unChatpor sí solo. Si tu mindset o tus módulos inyectanChat, regístralo como arriba; si algo inyectaChatOperator, agrega también[ChatRepository, new TestChatRepository()]. Alternativa más simple: usaChatControllerHarness(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:
| Campo | Contenido |
|---|---|
replies | Mensajes del bot entregados por el callback de respuesta. |
toolCalls | Tools ejecutadas durante el turno, con sus resultados. |
items | Todos los chat items creados (humano, bot y tool calls). |
Guionar el LLM
| Método del adapter | Efecto |
|---|---|
.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 mindsetconst tools = harness.tools() // las tools reales que ve el modeloassert.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 datosconst prospect = entityFixture(Prospect, { chatId: 'chat-1', status: 'new', /* ... */ }, { id: 'p-1' })
// Validación de modelos decorados, con issues aplanados por rutaconst 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@repositorycachea su runtime en el primer uso. El store en RAM es por proceso y se comparte entre los tests del mismo archivo (connode: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.tsdebe ser determinista:MockChatAdapterguionado,useMemoryRepositories(), sin red.- Tras cada
callTool()guionado, el ChatBot vuelve a llamar al adapter — guiona el siguiente turno o usafallbackReply. - Las entradas de
registerson pares[token, instancia]— pasa instancias vivas, no clases. - Cierra siempre el
RestHarnessconawait harness.close().
Siguiente paso
Para probar el comportamiento del bot con modelos reales y un juez LLM: Evals con LLM.