Probar un paquete
Escribe archivos de prueba ordinarios para un paquete Beyond y ejecútalos con beyond test, el propio runner de pruebas de Node sobre los módulos públicos compilados; ubica un fallo en la fuente TypeScript, acota una ejecución, simula un módulo público, verifica comportamiento asíncrono, lee la cobertura sobre las fuentes y conecta un depurador.
- Disponibilidad: Experimental
- Evidencia: Ejecución registrada
- Tutorial
Qué construyes
Un workspace de dos paquetes, @qa/shared y @qa/app, con cinco archivos de prueba ordinarios: el contrato de un módulo público, una prueba junto a las fuentes de ese módulo, una prueba que simula una dependencia pública, una prueba de comportamiento asíncrono y una prueba en JavaScript que copia un fixture mutable. Las ejecutas con beyond test, haces que una falle, acotas la ejecución, lees un reporte de cobertura que nombra las fuentes TypeScript y conectas un depurador.
Nada de esto es específico de Beyond salvo el comando que ejecuta los archivos: el runner es el propio de Node (node:test), las aserciones son node:assert, y una prueba importa un módulo público por el mismo especificador bare que importa una aplicación.
Declara los paquetes
El workspace lista sus paquetes; cada paquete publica sus módulos con exports, selecciona el bundler ts y declara "type": "module", para que Node lea un archivo de prueba .ts como módulo ES sin adivinar. La entrada beyond.modules del paquete compartido dice dónde están sus manifiestos de módulo, que usa el último paso.
{
"packages": ["shared", "app"]
}{
"name": "@qa/shared",
"version": "1.0.0",
"type": "module",
"exports": {
"./text": "./text/index.ts"
},
"beyond": {
"bundler": "ts",
"modules": "."
},
"bundlers": {
"ts": "@beyond-js/packages/bundlers/ts"
}
}{
"name": "@qa/app",
"version": "1.0.0",
"type": "module",
"exports": {
"./main": "./main/index.ts",
"./clock": "./clock/index.ts"
},
"dependencies": {
"@qa/shared": "1.0.0"
},
"beyond": {
"bundler": "ts"
},
"bundlers": {
"ts": "@beyond-js/packages/bundlers/ts"
}
}Escribe el módulo bajo prueba
@qa/shared/text es un módulo público de tres archivos internos, uno de ellos en un subdirectorio que tiene su propio index.ts. Ninguna prueba llama a shout(): es la sonda que el reporte de cobertura debe marcar.
import { decorate } from './decorate';
import { deep } from './sub/deep';
import { title } from './sub';
/** Greets by name */
export const greet = (name: string) => decorate(name.trim() && `Hello ${name.trim()}`);
/** Never called by the tests: the coverage probe */
export const shout = (name: string) => {
const upper = name.toUpperCase();
return deep(title(upper));
};
export default 'shared default';/** Adds the exclamation; it is internal to the module and not importable by a consumer */
export const decorate = (text: string) => {
if (!text.trim()) throw new Error('nothing to decorate');
return `${text}!`;
};/** A second `index.ts`, in a subdirectory: its map must keep the directory */
export const title = (text: string) => `== ${text} ==`;/** A source in a subdirectory of the module */
export const deep = (text: string) => `${text}?`;Escribe las pruebas
Una prueba del contrato público está junto al directorio del módulo e importa el módulo por su especificador bare. El archivo interno decorate.ts no es importable por una prueba, igual que no lo es por un consumidor; su comportamiento se observa a través de greet.
// The contract of the public module @qa/shared/text, imported exactly as a consumer imports it: by its bare
// specifier, resolved to the module the development service compiled from the sources beside this file.
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import label, { greet } from '@qa/shared/text';
describe('@qa/shared/text', () => {
test('greets by name', () => {
assert.equal(greet('QA'), 'Hello QA!');
});
test('exports a default value', () => {
assert.equal(label, 'shared default');
});
test('refuses an empty name, with the error the internal file throws', () => {
// The internal file is not importable; its behavior is observed through the public function
assert.throws(() => greet(' '), { message: 'nothing to decorate' });
});
});Una prueba también puede estar dentro del directorio del módulo. Se recolecta y se ejecuta como cualquier otro archivo de prueba, y nunca se compila dentro del módulo: el artefacto de @qa/shared/text no la contiene.
// A test beside the sources of the module. It is collected and run like any other test file, and it is
// never compiled into the public module: the artifact of @qa/shared/text does not contain it.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { greet } from '@qa/shared/text';
test('a colocated test imports the public module, not the file beside it', () => {
assert.equal(greet('here'), 'Hello here!');
});La aplicación importa el módulo compartido. Su prueba reemplaza esa dependencia con mock.module() de node:test, registrado antes de importar el módulo bajo prueba, por eso la importación es dinámica y viene después del mock. La aplicación compilada conserva su importación bare de @qa/shared/text, así que el mock llega a todos los archivos internos de la aplicación.
import { greet } from '@qa/shared/text';
/** Decorates the greeting of the shared module: the bare import survives compilation */
export const main = (name: string) => `[app] ${greet(name)}`;// A dependency mocked at the public boundary: @qa/app/main keeps its bare import of @qa/shared/text, so the
// runner's own module mock replaces it for every internal file of the application. The mock is registered
// before the module under test is imported, which is why the import is dynamic and comes after it.
import { test, mock } from 'node:test';
import assert from 'node:assert/strict';
mock.module('@qa/shared/text', {
namedExports: { greet: (name: string) => `mocked ${name}` },
defaultExport: 'mocked default'
});
const { main } = await import('@qa/app/main');
test('the application uses the mocked shared module', () => {
assert.equal(main('QA'), '[app] mocked QA');
});El comportamiento asíncrono se espera y se verifica, nunca se observa con logs ni con esperas fijas: un valor se espera, un rechazo se verifica con assert.rejects por su código, y una operación lenta se acota con el timeout de la propia prueba, que la hace fallar en lugar de colgarla.
/** Resolves with a reading once the clock is ready; it settles on a later turn, never synchronously */
export const reading = (): Promise<string> => new Promise(resolve => setTimeout(() => resolve('tick'), 20));
/** Rejects with a coded error, which a test asserts explicitly */
export const broken = (): Promise<never> => Promise.reject(Object.assign(new Error('the clock is broken'), { code: 'CLOCK_BROKEN' }));
/** Settles after the given delay, which a test bounds with a timeout */
export const slow = (ms: number): Promise<string> => new Promise(resolve => setTimeout(() => resolve('late'), ms));// Asynchronous behavior is awaited and asserted, never observed through logs or fixed waits.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { reading, broken, slow } from '@qa/app/clock';
test('a reading is awaited', async () => {
assert.equal(await reading(), 'tick');
});
test('a rejection is asserted explicitly, by its code', async () => {
await assert.rejects(broken(), { code: 'CLOCK_BROKEN' });
});
test('a bounded wait fails the test instead of hanging it', { timeout: 200 }, async () => {
assert.equal(await slow(10), 'late');
});Una prueba en JavaScript funciona igual. Esta copia un fixture a un directorio temporal antes de editarlo, para que las pruebas nunca interfieran a través de archivos compartidos, y elimina la copia al terminar.
// A mutable fixture is copied to a directory of its own before a test changes it, so tests never interfere
// through shared files and the permanent template stays as it is. Whoever creates the copy removes it.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const template = fileURLToPath(new URL('./fixtures/', import.meta.url));
test('a copied fixture is edited in isolation and removed', async t => {
const directory = await mkdtemp(join(tmpdir(), 'qa-fixture-'));
t.after(() => rm(directory, { recursive: true, force: true }));
await cp(template, directory, { recursive: true });
const notes = join(directory, 'notes.txt');
await writeFile(notes, `${await readFile(notes, 'utf8')}second line\n`);
assert.equal(await readFile(notes, 'utf8'), 'first line\nsecond line\n');
assert.equal(await readFile(join(template, 'notes.txt'), 'utf8'), 'first line\n', 'the template is untouched');
});La plantilla que copia es una línea de texto:
first lineEjecuta las pruebas
# From the workspace directory, with the Beyond toolchain installed in <installation>
# (its acceptance builds such an installation; nothing is published to a registry)
BEYOND="<installation>/node_modules/.bin/beyond"
$BEYOND test # every <name>.test.ts, .mts, .js or .mjs file of the workspace
$BEYOND test shared app/clock.test.ts # only the files under a directory, or one file
$BEYOND test --name "rejection" # only the tests whose name matches
$BEYOND test --coverage shared # with a coverage report of the workspace sources
$BEYOND test --reporter spec # a reporter of Node's test runner: spec, tap, dot, junit, lcov
$BEYOND test tests -- --inspect-brk # arguments after -- are given to Node, before --testbeyond test reutiliza el servidor de desarrollo del workspace o inicia uno, le pide compilar todos los módulos públicos, recolecta los archivos de prueba (<name>.test.ts, .mts, .js o .mjs, en orden alfabético, nunca bajo node_modules ni un directorio oculto) y los ejecuta con node --test, un proceso por archivo. Su salida de error dice qué servidor usó y qué archivos recolectó; la salida estándar es el reporte del runner, spec en una terminal y tap en cualquier otro caso:
beyond: started development server at http://127.0.0.1:59360
beyond: 5 test files: app/clock.test.ts, app/main.test.ts, shared/text.test.ts, shared/text/index.test.ts, tests/fixture.test.mjs
…
# tests 9
# pass 9
# fail 0El código de salida es el del runner: 0 cuando todas las pruebas pasaron. El servidor iniciado aquí termina poco después de la ejecución; un servidor que mantienes abierto con beyond run se usa y sobrevive.
Punto de control. Nueve pruebas pasan en cinco archivos, TypeScript y JavaScript por igual, y el comando termina.
Haz que una prueba falle
Agrega un archivo con una expectativa equivocada y una llamada que lanza un error dentro del módulo compilado:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { greet } from '@qa/shared/text';
test('wrong expectation', () => {
assert.equal(greet('QA'), 'Hi QA');
});
test('an error thrown by the compiled module', () => {
greet(' ');
});beyond test tests/failing.test.ts --reporter spec✖ wrong expectation
at TestContext.<anonymous> (file:///…/tests/failing.test.ts:6:9)
✖ an error thrown by the compiled module
Error: nothing to decorate
at decorate (/…/shared/text/decorate.ts:3:26)
at greet (/…/shared/text/index.ts:6:48)
at TestContext.<anonymous> (file:///…/tests/failing.test.ts:10:2)
ℹ pass 0
ℹ fail 2La aserción se ubica en el archivo de prueba, y el error lanzado por el módulo compilado se ubica en decorate.ts y en el greet que lo llamó: los mapas de fuentes del módulo compilado se aplican a la traza. El código de salida es 1. Elimina el archivo antes de continuar.
Punto de control. Código de salida 1, dos fallos, cada uno con la línea de la fuente que lo produjo.
Acota la ejecución
Un directorio recolecta solo sus archivos; un archivo nombra uno; --name selecciona las pruebas cuyo nombre coincide:
beyond test shared # 2 test files: shared/text.test.ts, shared/text/index.test.ts
beyond test app/clock.test.ts --name rejection # 1 test file, 1 testUna ruta que no existe, un archivo que no es una prueba y un directorio sin archivos de prueba son fallos con un mensaje, nunca una ejecución verde vacía:
beyond: error: no test files found in tests/empty (a test file is named <name>.test.ts, .mts, .js or .mjs)Qué hace un workspace que no compila
Rompe shared/text/decorate.ts (borra la comilla de cierre de una cadena, por ejemplo) y ejecuta las pruebas otra vez. No se ejecuta nada: el comando reporta el diagnóstico ubicado en la fuente, termina con código de salida 1 y nunca sirve un artefacto anterior en lugar del módulo roto. Corrige el archivo y el mismo comando vuelve a ejecutar las pruebas.
beyond: error: "@qa/shared/text" does not build (BUILD_FAILED)
beyond: error: shared/text/decorate.ts:1:50 TRANSPILE_ERROR: Module "@qa/shared/text": decorate.ts (1:50): Expression expected.
beyond: error: nothing was run: correct the sources and run the tests againLee la cobertura
beyond test shared --coverageEl reporte es el de Node, reasignado a las fuentes TypeScript del workspace: los archivos de prueba y los paquetes instalados quedan excluidos, el subdirectorio conserva su propia fila, los dos archivos index.ts son dos filas, y el cuerpo de shout() (líneas 10 y 11 de shared/text/index.ts), al que ninguna prueba llama, está sin cubrir:
# file | line % | branch % | funcs % | uncovered lines
# shared | | | |
# text | | | |
# decorate.ts | 100.00 | 100.00 | 100.00 |
# index.ts | 85.71 | 100.00 | 50.00 | 10-11
# sub | | | |
# deep.ts | 100.00 | 100.00 | 0.00 |
# index.ts | 100.00 | 100.00 | 25.00 |
# all files | 91.30 | 100.00 | 37.50 |Las líneas son como Node las reporta. Una función que nunca fue llamada siempre aparece en el conteo de funciones de su archivo, como deep() y title() en el subdirectorio; aparece en las líneas sin cubrir solo cuando su cuerpo tiene líneas propias, y por eso la sonda está escrita en varias líneas. --reporter lcov escribe los mismos datos como registros LCOV que nombran las mismas fuentes.
Conecta un depurador
Los argumentos después de -- se le dan a Node antes de --test, así que el inspector se conecta al proceso de prueba y los puntos de interrupción se asocian a las fuentes TypeScript a través de los mapas de fuentes:
beyond test tests/fixture.test.mjs -- --inspect-brkDebugger listening on ws://127.0.0.1:<port>/…Abre la dirección en un depurador, o usa el de tu editor, y ejecuta hasta tu punto de interrupción.
Compila un archivo de prueba a propósito
El runner recolecta un archivo de prueba dentro del directorio de un módulo, pero el compilador lo deja fuera del módulo: <name>.test.*, <name>.spec.* y todo lo que hay bajo __tests__ o __fixtures__ no son entradas de un procesador. Un módulo que deba compilar esos archivos lo dice en su manifiesto, y por eso el paquete compartido declara "beyond": { "modules": "." }:
{
"tests": "included"
}Cualquier otro valor de tests es el diagnóstico INVALID_TESTS_CONFIGURATION, y el workspace no compila hasta que se corrige.
Limpia
Cada ejecución carga la compilación actual y no hay modo de observación: vuelve a ejecutar las pruebas después de una edición. Los módulos servidos se identifican en los procesos de prueba con archivos marcadores bajo .beyond/modules/ del workspace; agrega .beyond/ al archivo de ignorados del proyecto. El servidor que beyond test inició terminó por sí solo después de la ejecución.
Siguiente
Cada opción, mensaje y regla del comando: La referencia de beyond test.