bun:test — Testing con herramientas nativas de Bun
🎯 Para quién es esta guía. Es una guía técnica para quien ya programa en TypeScript y va a escribir tests. Si estás en tu primer día y todavía no has tocado código, no la necesitas — vuelve cuando te toque escribir tu primer test.
Un test es un pequeño programa que comprueba, de forma automática, que otro programa hace lo que debe (por ejemplo: "sumar 2 + 2 devuelve 4"). Se escriben para detectar errores al instante cada vez que se cambia el código, sin tener que revisar todo a mano. El resto de la nota asume que ya conoces esta idea y usa jerga de testing (cross-runtime, testcontainers, jsdom, fast-check, etc.).
⚠️ Verificar versiones antes de usar — las versiones de Bun y sus APIs documentadas aquí pueden estar desactualizadas. Consultar bun.sh/docs y el changelog oficial antes de usar. Ver politicas-core.
Cuándo usar bun:test — alcance y restricciones
Política Wiedii:
bun:testse usa únicamente en herramientas CLI puras — proyectos que no requieren conexión a bases de datos, flujos HTTP/web, ni dependencias de runtime externas. Para cualquier otro tipo de proyecto (APIs, web apps, workers, SDKs con IO complejo) se usa Vitest (decisión provisional — ver trofeo-testing).Cross-runtime (obligatorio): todo CLI/librería que se ejecute sobre Bun debe garantizar también ejecución en Node.js — doble flujo de testing (
bun:test+node:test). Ver Compatibilidad cross-runtime.
Proyectos donde aplica bun:test
- CLIs compiladas (Go, Bun, Node) sin IO externo en sus tests
- Scripts de automatización y utilidades de línea de comandos
- Librerías de lógica pura (parsers, transformadores, utilidades de fecha/string)
- Paquetes de configuración compartida
Proyectos donde se usa Vitest
| Tipo de proyecto | Framework recomendado | Razón |
|---|---|---|
| API / backend con DB | Vitest | Mejor integración con testcontainers, soporte nativo para workers |
| Web app (Astro, React) | Vitest | Integración con jsdom/happy-dom, ecosystem de plugins |
| SDK con múltiples targets | Vitest | Modes node/browser/edge, configuración flexible por entorno |
| Proyecto con tests E2E complejos | Vitest + Playwright | Integración oficial, mejor reporte de flakiness |
Vitest es la opción por defecto (decisión provisional) para proyectos nuevos que no sean CLI puras. Jest no forma parte del stack de Wiedii. Ver trofeo-testing.
Por qué bun:test es suficiente para CLI puras
Bun incluye un test runner completo en bun:test que cubre el 100 % de los casos de herramientas CLI sin instalar Jest, Vitest ni ninguna dependencia adicional. La suite es compatible con la API de Jest/Vitest (describe, test, expect, mock, spyOn), corre TypeScript directamente, y es significativamente más rápida que cualquier alternativa basada en Node.
En herramientas CLI de Wiedii, los niveles del trofeo son unit → integration → e2e; property-based (con fast-check) es una técnica transversal, no un nivel. Todos corren con
bun testnativo. Ver trofeo-testing.
Compatibilidad cross-runtime — Bun + Node
Política Wiedii: un CLI/librería que corre sobre Bun debe garantizar también ejecución en Node.js (el runtime estándar de la empresa). Durante la transición y por compatibilidad, mantener dos flujos de testing nativos y exigir ambos en verde antes del merge:
test:bun→bun test(bun:test nativo — feedback rápido, mocking completo).test:node→node --test(node:test nativo — paridad con el runtime Node).
{
"scripts": {
"test:bun": "bun test",
"test:node": "node --test",
"test": "bun run test:bun && bun run test:node"
}
}
CI corre ambos; el gate del MR requiere los dos verdes.
Cómo compartir la suite (a validar en piloto)
bun:test y node:test tienen APIs de import distintas (bun:test → expect/mock; node:test → node:test + node:assert), así que un mismo archivo no corre en ambos con plena fidelidad. La garantía cross-runtime más barata y robusta es ejecutar el CLI ya construido bajo cada runtime en los tests de integración/e2e (invocar el binario — bun dist/cli.js y node dist/cli.js — no la API del runner): prueba el comportamiento real en Bun y en Node sin duplicar cada unit test. Los unit de lógica pura que se quieran en ambos se escriben en el subconjunto portable (node:test + node:assert).
Bun implementa el módulo
node:test(limitado: sin mocking/snapshots completos), pero el flujo Node debe correr en Node real (node --test) para validar paridad — no bajo Bun. El esquema exacto (suite portable única vs lane Node solo para integración/e2e) queda a validar en un piloto — ver cosas-a-cargo-del-tl.
Estructura de tests en proyectos Wiedii
packages/<pkg>/
src/
lib/
foo.ts
foo.spec.ts # unit
foo.integration.spec.ts # integration (IO real, tmp dirs)
foo.property.spec.ts # property (fast-check)
e2e/
foo.e2e.spec.ts # e2e (dist/ built bundle)
Scripts estándar en package.json de cada paquete:
{
"scripts": {
"test:unit": "find src -name '*.spec.ts' ! -name '*.integration.spec.ts' ! -name '*.property.spec.ts' -exec bun test {} +",
"test:integration": "find src -name '*.integration.spec.ts' -exec bun test {} +",
"test:property": "find src -name '*.property.spec.ts' -exec bun test {} +",
"test:e2e": "find e2e -name '*.e2e.spec.ts' -exec bun test {} +"
}
}
API básica — describe / test / expect
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
describe('calculator', () => {
test('adds two numbers', () => {
expect(2 + 2).toBe(4);
});
test('throws on division by zero', () => {
expect(() => divide(1, 0)).toThrow('division by zero');
});
test('async operation resolves', async () => {
await expect(fetchUser(1)).resolves.toMatchObject({ id: 1 });
});
});
Mock de funciones — mock() / spyOn()
Función stub
import { mock, test, expect } from 'bun:test';
const greet = mock((name: string) => `Hello, ${name}!`);
greet('Ana');
expect(greet).toHaveBeenCalledWith('Ana');
expect(greet).toHaveBeenCalledTimes(1);
expect(greet.mock.calls[0]).toEqual(['Ana']);
Spy sobre objeto existente
import { mock, spyOn, test, expect, afterEach } from 'bun:test';
import * as fs from 'node:fs/promises';
afterEach(() => {
// Restaurar todos los spies después de cada test
mock.restore();
});
test('calls readFile with correct path', async () => {
const spy = spyOn(fs, 'readFile').mockResolvedValue(Buffer.from('content'));
await readConfig('/etc/config.json');
expect(spy).toHaveBeenCalledWith('/etc/config.json');
});
Auto-restore con using (Bun ≥ 1.3.9, recomendado)
import { spyOn, test, expect } from 'bun:test';
test('auto-restores spy al salir del bloque', () => {
using spy = spyOn(console, 'warn').mockImplementation(() => {});
doSomethingThatWarns();
expect(spy).toHaveBeenCalledOnce();
// spy se restaura automáticamente al salir — Symbol.dispose
});
Mock de módulos — mock.module()
Uso básico
import { mock, test, expect } from 'bun:test';
// Mockear antes de que el módulo sea importado
mock.module('./config', () => ({
default: { apiUrl: 'http://localhost:3000', timeout: 100 },
}));
// Las importaciones posteriores ven el mock
const { default: config } = await import('./config');
expect(config.apiUrl).toBe('http://localhost:3000');
Live binding update (característica única de Bun)
Bun parchea los live bindings de ES modules en tiempo de ejecución. A diferencia de Jest, el mock funciona incluso después de que el módulo ya fue importado:
import { mock, test, expect } from 'bun:test';
import { apiUrl } from './config';
// apiUrl ya está importado — en Jest esto no funcionaría
mock.module('./config', () => ({ apiUrl: 'http://mocked' }));
expect(apiUrl).toBe('http://mocked'); // ✅ live binding actualizado
Patrón recomendado: mock.module() + mockFn por test
El problema central: mock.module() no se puede deshacer dentro del mismo archivo
(bun#7823, abierto desde Dic 2023).
La solución: mockear la función dentro del módulo, no el módulo entero.
import { mock, beforeEach, test, expect } from 'bun:test';
// Definir el mock function una vez — esto sí se puede resetear
const fetchMock = mock(async () => ({ ok: true, json: async () => ({ id: 1 }) }));
// Mockear el módulo UNA vez apuntando al mock function
mock.module('node-fetch', () => ({ default: fetchMock }));
beforeEach(() => {
fetchMock.mockClear(); // limpiar historial de llamadas entre tests
});
test('llama a la API correctamente', async () => {
await getUser(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test('maneja error de red', async () => {
fetchMock.mockRejectedValueOnce(new Error('timeout'));
await expect(getUser(1)).rejects.toThrow('timeout');
});
--preload para mocks que deben existir antes de cualquier import
Cuando un módulo tiene efectos secundarios en su inicialización y necesitas que el mock esté activo antes de que se cargue:
// test-setup.ts (declarado en bunfig.toml → [test].preload)
import { mock } from 'bun:test';
// Este mock existe antes de que cualquier test file se evalúe
const logger = { log: mock(() => {}), error: mock(() => {}) };
mock.module('./lib/logger', () => logger);
export { logger };
# bunfig.toml
[test]
preload = ["./test-setup.ts"]
Aislamiento entre archivos — --isolate y --parallel
El problema
Sin flags adicionales, los mock.module() de un archivo pueden leakear a otros archivos del mismo proceso (bun#12823).
Solución recomendada (Bun ≥ 1.3.13)
# Fresh VM context por cada archivo — mismo proceso, aislamiento garantizado
bun test --isolate
# Workers separados — aislamiento máximo, más lento
bun test --parallel
# Sharding para CI distribuido
bun test --shard=1/4
En proyectos Wiedii, añadir --isolate al target de test en project.json cuando la suite usa mock.module():
{
"targets": {
"test:unit": {
"command": "bun test --isolate src/**/*.spec.ts"
}
}
}
Timers falsos
import { test, expect, jest } from 'bun:test';
test('debounce espera 300ms', () => {
jest.useFakeTimers();
const cb = mock(() => {});
debounce(cb, 300)();
expect(cb).not.toHaveBeenCalled();
jest.advanceTimersByTime(300);
expect(cb).toHaveBeenCalledOnce();
jest.useRealTimers();
});
Alias jest.* y vi.*
bun:test exporta jest y vi como aliases completos. Útil para migrar suites existentes:
| bun:test nativo | jest alias | vi alias |
|---|---|---|
mock() | jest.fn() | vi.fn() |
spyOn() | jest.spyOn() | vi.spyOn() |
mock.module() | jest.mock() | vi.mock() |
mock.restore() | jest.restoreAllMocks() | vi.restoreAllMocks() |
mock.clearAllMocks() | jest.clearAllMocks() | vi.clearAllMocks() |
⚠️ Gaps conocidos (mayo 2025, todos open issues)
| Función | Issue | Workaround |
|---|---|---|
jest.resetModules() | #5356 | Usar --isolate |
vi.resetModules() | #16140 | Usar --isolate |
vi.unmock() | #16140 | Separar en otro archivo |
vi.importActual() | #16140 | Spread manual en factory |
jest.mock() sin factory | #29834 | Siempre pasar factory |
mock.restore() para módulos | #7823 | Patrón mockFn + --isolate |
Partial mocks — cómo imitar vi.importActual()
Como vi.importActual() no existe, el workaround es require() dentro de la factory:
mock.module('./utils', () => {
// importar el módulo real con require (síncrono) dentro de la factory
const real = require('./utils');
return {
...real, // conservar todos los exports reales
fetchData: mock(async () => []), // sobreescribir sólo lo necesario
};
});
Cobertura
# Cobertura en consola
bun test --coverage
# Cobertura en formato LCOV (para CI y merge)
bun test --coverage --coverage-reporter=lcov
# Threshold configurado en bunfig.toml (proyectos Wiedii: 80%)
# bunfig.toml
[test]
coverageThreshold = { line = 0.8, function = 0.8, statement = 0.8, branch = 0.8 }
coverageReporter = ["text", "lcov"]
Tests de propiedad con fast-check
Para tests property-based, usar la integración con @wiedii-registry/wietoo-testing:
import { describe, test, expect } from 'bun:test';
import * as fc from 'fast-check';
describe('nonEmptyArray — invariante', () => {
test('equivale a length > 0', () => {
fc.assert(
fc.property(fc.array(fc.anything()), (arr) => {
expect(isNonEmptyArray(arr)).toBe(arr.length > 0);
}),
);
});
});
Cheatsheet rápida
import {
describe, test, it, expect, // estructura
beforeAll, afterAll, // lifecycle: suite
beforeEach, afterEach, // lifecycle: por test
mock, spyOn, // mocking
jest, // alias jest.*
} from 'bun:test';
// Mock function
const fn = mock((x: number) => x * 2);
fn(3);
expect(fn).toHaveBeenCalledWith(3);
fn.mockClear(); // resetear historial
fn.mockImplementation((x) => 0); // cambiar implementación
fn.mockReturnValueOnce(99); // valor para próxima llamada
// Mock de módulo (no se puede deshacer — usar --isolate)
mock.module('pkg', () => ({ foo: 'bar' }));
// Spy con auto-restore
using spy = spyOn(obj, 'method').mockReturnValue('mocked');
// Timers
jest.useFakeTimers();
jest.advanceTimersByTime(1000);
jest.useRealTimers();
// Flags CLI clave
// bun test --isolate → fresh VM por archivo
// bun test --parallel → workers separados
// bun test --watch → re-run en cambios
// bun test --coverage → cobertura
// bun test --only → sólo tests marcados con test.only()
Por qué NO hay createModuleMocker en wietoo-devkit
Existió una utilidad createModuleMocker en @wiedii-registry/wietoo-devkit. Fue eliminada en mayo 2025 porque:
- Su
restore()sólo reseteaba un flag interno — no restauraba el módulo real mock.module()nativo es una sola línea — el wrapper añadía ceremonial sin valor--isolate(Bun ≥ 1.3.13) resuelve el aislamiento a nivel runtime, no JS- Semánticamente era incorrecto:
devkites IO helpers de producción, no test utilities
Si en el futuro se necesita una abstracción (p.ej. withAutoReset(), partial mock helpers), irá en @wiedii-registry/wietoo-testing, no en devkit.
Referencias
- bun:test — documentación oficial
- bun:test mocks
- bun:test mock functions
- bun — overview del runtime y comandos generales
- nx-monorepo — cómo se organizan los tests en el monorepo wietoo