@bro-code/test
v2026.0.0
Published
Bro Code's own test runner for TypeScript and JavaScript: suites, expect, mocks, coverage and a customizable reporter
Maintainers
Readme
@bro-code/test
Bro Code's own test runner for TypeScript and JavaScript: suites, expect, mocks, coverage and a customizable reporter.
It runs each test file in its own process, reports through @bro-code/logger, writes the JUnit and Cobertura files a CI server shows, and can write an HTML page of the run with coverage charts drawn by @bro-code/charts. It needs Node 22.17 or later.
Installation
npm install -D @bro-code/testFor TypeScript tests, and for coverage, the project needs typescript 5 or 6 too (npm install -D typescript@6); TypeScript 7 doesn't work yet (see Versions).
Quick Start
Tell
bc-testwhere the tests are, in the project'sbro.config.json:{ "test": { "include": ["tests/**/*.test.ts"] } }Write a test,
tests/math.test.ts:import { describe, it, expect } from '@bro-code/test'; import { add } from '../src/math'; describe('add', () => { it('adds two numbers', () => { expect(add(2, 3)).toBe(5); }); });Make it the test script in
package.json, and runnpm test:{ "scripts": { "test": "bc-test" } }✓ tests/math.test.ts 1 passed 280 ms Tests 1 total · 1 passed · 0 failed · 0 skipped Files 1 total · 1 passed · 0 failed Time 0.30s
npm test -- --level tree shows every test, npm test -- --coverage measures coverage, and npm test -- tests/services runs one folder.
The bc-test command
bc-test [targets...] [--level <level>] [--reporter <name>]... [--coverage | --no-coverage] [--junit <file>] [--open | --no-open] [--watch] [--parallel <n>] [--timeout <ms>]Add it as the project's test script:
{
"scripts": {
"test": "bc-test"
}
}It finds the test files under the folder it runs in, runs each in its own Node process (several at once), prints each file's results as it finishes, and ends with a summary. npm test -- tests/services passes arguments through npm.
| Argument | What it does |
|---|---|
| targets... | Test files or folders: only the test files that are one of them, or inside one, run |
| --level <level> | How much to print: tree, verbose, default, dot or minimal |
| --reporter <name> | Which reporters run, given once per reporter: replaces the config's list |
| --coverage, --no-coverage | Measure coverage, or don't, whatever the config says |
| --junit <file> | Add the junit reporter, writing to this file |
| --open, --no-open | Open the HTML page after the run, or don't |
| --watch | Run again whenever a test file, or a file one loaded, changes, until stopped (watch mode) |
| --parallel <n> | How many test files run at once |
| --timeout <ms> | Milliseconds per test |
| -h, --help | Print the usage line |
A flag beats the config. The exit code is 0 when every test passed, 1 when anything failed (a test, a file that wouldn't load, no test file found, or coverage under a minimum), and 2 for a bad config or command line, or coverage asked for without typescript 5 or 6.
Each file's process runs with --enable-source-maps. A test file fails as a whole when it doesn't load, registers no tests, ends its own process, or blocks its event loop past its test's timeout (it's stopped two seconds later). An error thrown or a promise rejected outside any test's await fails the file once its tests have run. Whatever a test writes to stdout or stderr is kept with that test's result, so output from files running side by side never interleaves; the default level prints a failing test's output with its failure.
The test key in bro.config.json
bc-test reads the test key of the bro.config.json where it runs, beside the other @bro-code/* packages' keys. Every setting is optional:
{
"test": {
"include": ["tests/**/*.test.ts"],
"level": "default",
"timeout": 5000,
"env": { "STRIPE_SECRET_KEY": "unit-tests-have-no-stripe-key" },
"reporter": ["terminal", "junit"]
}
}| Setting | Default | What it is |
|---|---|---|
| include | ["**/*.{test,spec}.{ts,mts,cts,js,mjs,cjs}"] | Test file globs: *.test.* and *.spec.* files by default |
| exclude | ["**/node_modules/**", "**/dist/**"] | Globs left out |
| level | "default" | The report level |
| parallel | the number of CPUs (os.availableParallelism()) | How many test files run at once |
| timeout | 5000 | Milliseconds per test and hook |
| env | {} | Variables set in every test process before any file loads |
| reporter | ["terminal"] | Which reporters run |
| junit | "test-results/junit.xml" | Where the junit reporter writes |
| html | "test-results/index.html" | Where the html reporter writes |
| open | false | Open the HTML page in the default browser after the run |
| coverage | off | The coverage settings |
| theme | | The terminal's colors, symbols and summary box (theme) |
An unknown key, or a setting of the wrong type, stops the run with a message naming it (exit code 2). So does a glob with ** glued to a name, such as **.spec.ts: ** means any folders only as a whole path segment (**/*.spec.ts), and glued to a name it acts like a single *, so it would quietly find only the files at the top. The message suggests the fix. bc-test leaves NODE_ENV alone; set it in env if the code under test needs it.
Watch mode
bc-test --watch (or npm test -- --watch) runs the tests, then keeps watching the project and runs them again as files change:
- a changed test file runs again;
- every test file that loaded a changed file runs again (each test process reports the project files it loaded, through
requireorimport); - a new test file the
includeglobs match runs; a deleted one drops out; - a change nothing loaded runs nothing, and changes in
node_modules,.git,distandbc-test's own output folders (coverage, JUnit, HTML) are ignored.
With targets (npm test -- --watch tests/services), only those test files are watched and run again, with the files they load. In a terminal the screen clears before each run, and after it a line says it's watching. Press Enter to run everything in scope again, and Ctrl+C to stop. A change during a run queues one more run after it.
Each run reports as a plain run does, and with the html reporter the page in your browser refreshes itself. Coverage of a run that covers only some test files reports like a targeted run: only the files those tests loaded, never failing on the minimums. Watch mode is a flag only, with no config setting, so a plain npm test (and a pipeline) always ends. A change to bro.config.json takes a restart.
Reporters
The reporter setting lists where a run's results go, each named once:
| Reporter | What it does |
|---|---|
| terminal | Prints the results at the chosen level, styled by the theme |
| junit | Writes a JUnit XML file to the junit path |
| html | Writes an HTML page of the run to the html path, and opens it with open or --open |
{
"test": {
"reporter": ["terminal", "junit", "html"],
"junit": "test-results/junit.xml",
"html": "test-results/index.html"
}
}With no reporter, only terminal runs. Without terminal, nothing prints but setup errors (a bad config, no test files found); the exit code still gives the result. --reporter <name>, given once per reporter, replaces the list for one run, and --junit <file> adds junit to it, writing to that file. The coverage file is written whenever coverage runs, whatever the list.
Report levels
Pick one with --level or the level setting; default is the default. Nothing changes the level for you, not CI and not an AI agent. Every level ends with the same summary:
Tests 3 total · 1 passed · 1 failed · 1 skipped
Files 1 total · 0 passed · 1 failed
Time 0.08sA file prints as soon as it finishes, so files finishing side by side print in the order they end. The samples below are one file with a passing test that logs a line, a failing test and a skipped one.
tree: every file, then its suites and tests, each test with its time and the output it wrote. Every failure in full, under its test.
✗ tests/mixed.test.js 1 passed · 1 failed · 1 skipped 71 ms
cart
✓ adds an item 1 ms
cart has 1 item
✗ totals the items 0 ms
expect(received).toBe(expected)
Expected: 5
Received: 4
at tests/mixed.test.js:10:19
- applies a couponverbose: a line per test, then every failure in full.
✓ tests/mixed.test.js › cart › adds an item 0 ms
✗ tests/mixed.test.js › cart › totals the items 0 ms
- tests/mixed.test.js › cart › applies a coupon
✗ tests/mixed.test.js › cart › totals the items
expect(received).toBe(expected)
...default: a line per file with its counts and time. A failing file opens into its failing tests, each with its error, diff, stack and output.
✗ tests/mixed.test.js 1 passed · 1 failed · 1 skipped 71 ms
✗ cart › totals the items
expect(received).toBe(expected)
...dot: a symbol per test. The logger writes whole lines, so a file's symbols print as one line when it finishes. Every failure follows in full.
.F-minimal: nothing for a passing test, and each failure on one line, <symbol> <file>:<line> <test> — <message>.
✗ tests/mixed.test.js:10 cart › totals the items — expect(received).toBe(expected) Expected: 5 Received: 4At every level but tree, a failing test's output prints with its failure, and a passing test's output doesn't print.
The theme
The theme setting changes how the terminal output looks:
{
"test": {
"theme": {
"colors": { "pass": "cyan", "fail": "brightRed", "skip": "yellow", "muted": "gray" },
"symbols": { "pass": "✓", "fail": "✗", "skip": "-" },
"dotSymbols": { "pass": ".", "fail": "F", "skip": "-" },
"box": true,
"borderStyle": "round"
}
}
}| Setting | Default | What it is |
|---|---|---|
| colors | pass green, fail red, skip yellow, muted gray | Each a @bro-code/logger color name. A line mixes them: a test's or file's symbol takes its status's color, its name stays plain, and its time, counts and path take muted, as do stack frames and the tree level's test output. The summary colors each count (passed, failed, skipped) when it isn't zero, and a failure's heading is all fail |
| symbols | ✓, ✗, - | The symbols of passed, failed and skipped tests and files |
| dotSymbols | ., F, - | The dot level's symbols |
| box | false | Draw the summary in a box, all in the pass or fail color (the logger draws a box in one color) |
| borderStyle | "single" | The box's border: single, double, round or bold |
Any key left out keeps its default. With no color support (NO_COLOR set), nothing is colored.
JUnit
With junit in the reporter list (or --junit <file>), the run writes a JUnit XML file to the junit path (its folder is made if it's missing):
- a
testsuiteper file, named by its path, with its counts and time; - a
testcaseper test, itsnamethe suites and test (cart › totals the items) and itsclassnamethe file; failure(the message, diff and stack) on a failed test,skippedon a skipped one, andsystem-outwith what the test wrote;- for a file that failed as a whole (it didn't load, held no tests, or its process died or hung), a testcase named
File failedwith anerror.
Color codes and the control characters XML can't hold are left out.
The HTML page
With html in the reporter list (or --reporter html), the run writes one page to the html path (test-results/index.html by default; its folder is made if it's missing), and the terminal reporter prints where. The page is a single file, its styles inline, with no scripts and nothing fetched, so it opens straight from disk or as a pipeline artifact:
- Summary: the run's test and file counts and its time.
- Coverage, only when coverage ran: a pie chart each for lines, branches and functions, covered against uncovered, with the percentage and the counts, drawn as SVG by
@bro-code/charts; a note when a total is under its minimum; and the per-file table with each file's uncovered lines. - Results: a collapsible tree, each file, then its
describesuites, nested, then its tests, each with its status and time. A failure shows its message, diff and stack, a test with output shows what it wrote, and a file that failed as a whole shows its error. Failing files, suites and tests start open; passing ones start closed. It follows the system's light or dark mode.
open: true in the config, or --open, opens the page in the default browser once the run ends (start on Windows, open on macOS, xdg-open elsewhere). It's off by default, and --no-open turns it off for one run. A browser that won't open prints a warning and never changes the exit code.
In watch mode the page refreshes itself. bc-test --watch serves the page from a local server (http://127.0.0.1:<port>/, a free port, printed after each run) and still writes the file. The served page listens for the end of each run and reloads, keeping its scroll position, so the tab you have open always shows the latest results. open opens a tab only when none is listening: once at the start, and again only if you closed it. A plain run is a new process each time and can't reach a tab it didn't open, so it writes the file and, with open, opens it again.
Coverage
--coverage, or coverage.enabled in the config, measures lines, branches and functions:
{
"test": {
"coverage": {
"enabled": false,
"source": ["src"],
"exclude": ["**/node_modules/**", "**/dist/**", "src/generated/**"],
"output": "coverage",
"minimum": { "lines": 80, "branches": 70, "functions": 80 }
}
}
}| Setting | Default | What it is |
|---|---|---|
| enabled | false | Measure coverage on every run (--coverage and --no-coverage beat it) |
| source | ["."] | The folders whose .ts, .mts, .cts, .js, .mjs and .cjs files are measured (.d.ts files never are) |
| exclude | ["**/node_modules/**", "**/dist/**"] | Globs left out, beside the test files, which never count |
| output | "coverage" | The folder cobertura-coverage.xml is written to |
| minimum | none | lines, branches and functions percentages; one left unset isn't enforced |
Coverage needs typescript 5 or 6 installed, since it reads every source file with TypeScript's parser (JavaScript files too); without it, or with TypeScript 7, the run stops (exit code 2).
What it measures. Each test process runs under V8's own coverage (NODE_V8_COVERAGE, with the compile cache off), and the ranges V8 records are mapped back to each file through the source maps Node keeps, so TypeScript is measured in its own lines. Then, in each file's source:
- Lines: the lines where a statement that survives compilation starts. Interfaces, type aliases,
declared things, type-only imports and exports, and function declarations (counted as functions) aren't lines. A line is covered when a statement starting on it ran. - Functions: declarations, expressions, arrows, methods, constructors and accessors with a body. A function is covered when it was called.
- Branches: both arms of an
if(theelsecounted even when there's none: it's covered when theifran more often than itsthen), both arms of a ternary, the right side of&&,||and??, and eachcaseof aswitch. V8 records a range for every one of these kinds.
A file no test loaded counts every line, function and branch as uncovered, and a file with nothing to measure (only types) is left out.
Test files never count, wherever they sit and whatever exclude says: a file the include globs match, or one named *.test.* or *.spec.*, is never measured. A test beside its source (src/lib/text.test.ts) stays out of the table.
Which files. A run with no targets measures every file under source that isn't excluded, loaded or not. A run given targets measures only the files under source that those tests loaded.
What it prints. tree, verbose and default print a table before the summary; dot and minimal print only the totals:
File Lines Branches Functions Uncovered lines
src/math.ts 80.00% 60.00% 66.67% 13
src/unused.ts 0.00% 0.00% 0.00% 2
All files 66.67% 42.86% 50.00%Coverage lines 66.67% · branches 42.86% · functions 50.00%Cobertura. Every coverage run writes <output>/cobertura-coverage.xml: a package per folder, a class per file with its lines, hits and branch condition coverage, methods for its functions, and the overall rates. A CI server's coverage tab reads it (Azure DevOps' PublishCodeCoverageResults@2, for one).
Minimums. On a run with no targets, a total under its minimum prints which ones (Coverage is under its minimum: lines 66.67% (minimum 70%)) and the run exits 1, even when every test passed. A targeted run prints its coverage and never fails on it, since it measures only part of the source.
TypeScript
.ts, .mts and .cts test files run as they are, and so does the TypeScript they import. Install typescript in the project (it's an optional peer dependency); bc-test uses the project's own version, and a TypeScript file in a project without it fails with a message saying to install it.
Each test process registers Node's synchronous module hooks (module.registerHooks) before the test file loads.
Load. A
.ts,.mtsor.ctsfile is compiled withtypescript'stranspileModule, using the options of thetsconfig.jsonnearest to it (extendsincluded)..ctsis CommonJS,.mtsan ES module, and.tsfollows the nearestpackage.json'stype, as.jsdoes. The output carries an inline source map, so stack traces point at the TypeScript lines. A file whose real path is insidenode_modulesis left to Node, so afile:-linked local package ("shared": "file:../shared") is compiled like the project's own files.Resolve. Node resolves every import first. Where it finds nothing, the hooks try, in order:
- the path with
.ts,.mtsand.ctsadded, a.js,.mjsor.cjspath's.ts,.mtsor.ctsfile, and a folder'sindex.ts; - for a bare specifier, the nearest
tsconfig.json'spaths(the longest matching pattern) andbaseUrl, then a subpath inside a package innode_modules(shared/_interfaces/apps.interfaces).
That covers a CommonJS server's extensionless imports, an ES module's
./x.jsimports of./x.ts, and@/*aliases.- the path with
The hooks don't type-check: a type error doesn't fail a test. Run tsc --noEmit for that. Each file is compiled on its own, as isolatedModules describes, and .tsx files aren't handled.
Versions
bc-test needs typescript 5 or 6 (the peer range is >=5.0 <7). TypeScript 7 is the native compiler, and its package has no JavaScript API to compile or parse with: in a TypeScript 7 project a TypeScript file fails, and coverage stops the run, each with a message saying to install typescript@6. JavaScript tests without coverage still run. npm doesn't check an optional peer's version when it installs, so nothing warns sooner; npm ls typescript marks 7 as invalid.
API
Everything a test file uses comes from the package's main entry, with import or require:
import { describe, it, expect, beforeEach, fn, spyOn, useFakeTimers } from '@bro-code/test';const { describe, it, expect, beforeEach, fn, spyOn, useFakeTimers } = require('@bro-code/test');It also exports REPORT_LEVELS and the types of everything here, the test config's included (TestConfig, CoverageConfig, CoverageMinimum, ThemeConfig, ReporterName, ReportLevel, Status), and REPORTER_NAMES.
Suites and tests
| Function | What it does |
|---|---|
| describe(name, fn, options?) | Groups tests. fn registers them and must not be async. |
| it(name, fn, options?) | A test. fn may return a promise, which is awaited. test is the same function. |
| .only | describe.only and it.only run only the focused tests in their file. In a suite that holds an only, only its onlys run; an only suite runs all of itself unless it holds an only of its own. |
| .skip | describe.skip and it.skip report their tests as skipped. A skip wins over an only inside it. |
options.timeout is milliseconds: a test still running then fails. A suite's timeout applies to everything in it; the default is 5000 ms.
Hooks
beforeAll, beforeEach, afterEach and afterAll take a function (async or not) and belong to the suite they're called in, or the whole file at the top level.
beforeEachhooks run outermost suite first,afterEachinnermost first.- A failing
beforeAllfails every test in its suite; itsafterAllhooks still run. - A failing
beforeEachfails its test; itsafterEachhooks still run. - A failing
afterAllis reported as its own failure, namedafterAll hook.
expect
expect(actual) takes Jest's matcher names. .not negates any matcher; .resolves and .rejects await a promise first and return a promise to await.
expect(total).toBe(42);
expect(user).toEqual({ id: 1, name: 'Ann' });
expect(() => parse('')).toThrow('empty');
await expect(load()).resolves.toMatchObject({ ok: true });
await expect(save(bad)).rejects.toThrow(ValidationError);| Matchers | Check |
|---|---|
| toBe | Object.is |
| toEqual | Deep equality that ignores undefined properties |
| toStrictEqual | node:util's isDeepStrictEqual: undefined properties and prototypes count |
| toBeTruthy, toBeFalsy, toBeNull, toBeUndefined, toBeDefined, toBeNaN | What they name |
| toBeInstanceOf(Class) | instanceof |
| toBeGreaterThan, toBeGreaterThanOrEqual, toBeLessThan, toBeLessThanOrEqual | Numbers or bigints |
| toBeCloseTo(n, digits = 2) | Equal to digits decimal places |
| toContain(item) | A substring, or an item of an array or other iterable by === |
| toContainEqual(item) | An item by toEqual's equality |
| toHaveLength(n) | .length |
| toHaveProperty(path, value?) | A property at 'a.b[0]' or ['a', 'b', 0], equal to value when one is given |
| toMatch(substring \| RegExp) | A string |
| toMatchObject(object) | Every property of object, recursively; arrays item by item |
| toThrow(expected?) | A function that throws, or after .rejects a rejection: matching a substring of the message, a RegExp, a class, or an error's message |
| toHaveBeenCalled, toHaveBeenCalledTimes(n) | A mock's calls |
| toHaveBeenCalledWith(...args), toHaveBeenLastCalledWith(...args), toHaveBeenNthCalledWith(n, ...args) | A mock's arguments, by toEqual's equality; n counts from 1 |
A failed matcher throws an AssertionError with matcher, expected, actual and, for the matchers that compare two values, diff: node:assert's + actual - expected diff. Its stack starts at the test's expect(...) line. Check for one with isAssertionError(error): a CommonJS test file and the runner load different builds of the class, so instanceof can't tell.
Mocks
fn(implementation?) returns a mock function. It records calls (each call's arguments) and results ({ type: 'return' | 'throw', value }), and returns undefined until it has an implementation.
const send = fn<(to: string) => Promise<boolean>>().mockResolvedValue(true);
await notify(send);
expect(send).toHaveBeenCalledWith('[email protected]');| Method | What it does |
|---|---|
| mockImplementation(fn) | Runs fn for every call |
| mockReturnValue(v), mockReturnValueOnce(v) | Returns v |
| mockResolvedValue(v), mockResolvedValueOnce(v) | Returns a promise of v |
| mockRejectedValue(e), mockRejectedValueOnce(e) | Returns a promise that rejects with e |
| mockReset() | Forgets calls, results and implementations |
The Once forms queue up and are used before the lasting implementation. Every method returns the mock. isMock(value) tells whether a value is one of these mocks (a spy included), in either build.
spyOn(object, key) replaces a method with a mock that calls the original until it's given an implementation; mockReset() goes back to calling through. spy.restore() puts the original back, and restoreAllMocks() restores every spy still in place.
There is no module mocking: hand a module's dependencies to it (through a constructor or a parameter) and pass mocks in the test.
Fake timers
useFakeTimers({ now? }) replaces setTimeout, setInterval, setImmediate, their clear functions and Date (with Date.now) until restore(). Time starts at now (a Date or milliseconds), or the real time.
const clock = useFakeTimers({ now: new Date('2026-03-08T12:00:00Z') });
scheduleReminder();
clock.advance(60_000);
expect(sent).toHaveBeenCalled();
clock.restore();| Method | What it does |
|---|---|
| advance(ms) | Moves time forward, firing each timer that falls due on the way, in order |
| runAll() | Fires timers, including ones they schedule, until none is left; stops with an error after 10,000 |
| setNow(date) | Sets the time without firing anything |
| restore() | Puts the real functions and Date back |
The fake timers hand back handles with ref, unref, hasRef and refresh, like Node's. Calling useFakeTimers again replaces the earlier fakes. The runner's own timeouts keep the real timers.
Helpers
Five subpaths fake what Bro Code's servers share. Each is typed by the shape it fakes and imports nothing from the library behind it, so none of them adds a dependency; cast a fake to the project's own type where a constructor or function takes one. They work through import and require, and a project on TypeScript's older moduleResolution: "node" finds their types too.
@bro-code/test/prisma
fakeModel() returns a model delegate (prisma.user) whose findUnique, findUniqueOrThrow, findFirst, findFirstOrThrow, findMany, create, createMany, update, updateMany, upsert, delete, deleteMany, count, aggregate and groupBy are fn() mocks. fakePrisma(models) returns a client with those models, $queryRaw and $executeRaw mocks, and a $transaction mock that awaits an array of queries or calls a callback with the fake client.
import { fakeModel, fakePrisma } from '@bro-code/test/prisma';
const prisma = fakePrisma({ app: fakeModel() });
prisma.app.findMany.mockResolvedValue([{ id: 1, slug: 'rombie' }]);
const repository = new AppsRepository(prisma as unknown as PrismaClient);
expect(await repository.getAll()).toHaveLength(1);
expect(prisma.app.findMany).toHaveBeenCalledTimes(1);@bro-code/test/express
fakeRequest({ body, params, query, headers, ...extra }):body,paramsandquerydefault to empty,headersare keyed in lower case,get(name)andheader(name)read one in any case, and anything else (user,ip) is kept.fakeResponse():status,json,send,sendStatus,set,setHeaderandendare chainable mocks that recordstatusCode(200 to start),bodyandheaders;localsis an empty object.fakeNext(): a mock fornext.
import { fakeNext, fakeRequest, fakeResponse } from '@bro-code/test/express';
const res = fakeResponse();
await controller.get(fakeRequest({ params: { id: '4' } }) as unknown as Request, res as unknown as Response, fakeNext());
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: 'Not found' });@bro-code/test/stripe
checkoutSession, subscription, invoice, customer and paymentIntent return plain objects shaped like Stripe's, with the fields a server commonly reads filled in: a paid one-time checkout from the United States, an active monthly subscription (its period both on itself and on its first item, as Stripe's API versions place it), a paid invoice, a customer and a succeeded payment. Each takes overrides, merged key by key into plain objects (arrays and null replace), and any field it doesn't fill can be added. event(type, object) wraps one as a webhook event.
import { checkoutSession, event } from '@bro-code/test/stripe';
const session = checkoutSession({ customer_details: { address: { country: 'FR' } } });
expect(isSaleAllowed(session as unknown as Stripe.Checkout.Session)).toBe(false);
await service.handleWebhookEventAsync(event('checkout.session.completed', session) as unknown as Stripe.Event);@bro-code/test/scribe
captureScribe(scribe) takes the project's @bro-code/scribe instance (anything with flush() and records), flushes it, and returns records() and messages(method?) for what was logged since. Scribe keeps records in memory only with buffer: true in its config, so turn that on for the tests.
import { captureScribe } from '@bro-code/test/scribe';
const logged = captureScribe(scribe);
await service.updateAsync(input);
expect(logged.messages('info')).toContain('App rombie updated\n');@bro-code/test/env
withEnv(vars, fn) sets the variables for the call (a variable given as undefined is unset), awaits fn, and puts every one back afterwards, whether fn failed or not. It returns a promise of what fn returns.
import { withEnv } from '@bro-code/test/env';
await withEnv({ CORS_ORIGIN: undefined, PORT_CLIENT: '11111' }, () => {
expect(baselineCorsOrigins()).toEqual(['http://localhost:11111']);
});In Azure DevOps
The JUnit file fills a run's Tests tab and the Cobertura file its Code Coverage tab. A pipeline that runs the tests, then publishes both even when a test failed:
pool:
vmImage: ubuntu-latest
steps:
- task: UseNode@1
inputs:
version: 22.x
- script: npm ci
displayName: Install
- script: npx bc-test --level tree --coverage --junit test-results/junit.xml
displayName: Unit tests
- task: PublishTestResults@2
condition: succeededOrFailed()
inputs:
testResultsFormat: JUnit
testResultsFiles: test-results/junit.xml
- task: PublishCodeCoverageResults@2
condition: succeededOrFailed()
inputs:
summaryFileLocation: coverage/cobertura-coverage.xmlbc-test never changes its level for CI: pick one in the command (tree keeps every test in the log).
License
MIT - see LICENSE.
Changelog
[2026.0.0] - 2026-11-11
Added
- Initial release.
- The
bc-testcommand: finds the test files, runs each in its own process (parallelat a time), and exits 0, 1 or 2; test file and folder targets;--level,--reporter,--coverage,--junit,--open,--watch,--paralleland--timeout. - The
testkey inbro.config.json, checked when it loads, withenvfor the test processes. describe,itandtest, with.only,.skipand atimeoutoption, and thebeforeAll,beforeEach,afterEachandafterAllhooks.expect, with.not,.resolves,.rejectsand Jest's matcher names; a failed matcher throws anAssertionErrorwith a diff, recognized byisAssertionError.fnandspyOnmocks,isMockandrestoreAllMocks.useFakeTimers, withadvance,runAll,setNowandrestore.- TypeScript test files and the TypeScript they import, compiled through Node's module hooks with the project's own
typescript, with stack traces at the TypeScript lines. TypeScript 5 or 6; on TypeScript 7, which has no JavaScript API, a message says to installtypescript@6. - The
tree,verbose,default,dotandminimalreport levels, each ending with the summary, and thethemesetting's colors, symbols, dot symbols and summary box. Lines mix colors: symbols in their status color, times and counts muted (with@bro-code/logger'sfg). - The
reporterlist (terminal,junit,html), with--reporter; JUnit XML output to thejunitpath. - The
htmlreporter: one self-contained page with coverage pie charts from@bro-code/charts, the per-file coverage table and a collapsible results tree;openand--openopen it in the default browser. - Coverage of lines, branches and functions from V8, mapped back through source maps, with a table or a totals line,
cobertura-coverage.xml, and minimums that fail a full run. Test files (theincludeglobs,*.test.*and*.spec.*) never count. *.spec.*files are tests by default, beside*.test.*ones.- A glob with
**glued to a name (**.spec.ts) stops the run, suggesting**/*.spec.ts. --watch: runs again on changes, only the test files a change affects, or only the targets and what they load; with thehtmlreporter it serves the page live, so the open tab refreshes after each run instead of a new one opening.- The helper subpaths
@bro-code/test/prisma,/express,/stripe,/scribeand/env, withtypesVersionsformoduleResolution: "node".
