bun:test — Testing con herramientas nativas de Bun
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 debe evaluar Jest o Vitest según el stack.
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 debe evaluar Jest o 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, Next.js, 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 |
La elección entre Jest y Vitest se documenta en la nota correspondiente del stack. Si no hay nota previa, Vitest es la opción por defecto para proyectos nuevos que no sean CLI puras.
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, la pirámide de tests tiene cuatro niveles: unit → integration → property → e2e. Todos usan
bun testnativo.
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 { 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