nole
v3.4.0
Published
Testing Library for Typescript projects
Downloads
918
Readme

Nole
A test runner for TypeScript. A class is a suite. A method is a test.
// test/queue.test.ts
import { Test } from "nole";
export class QueueTest extends Test() {
queue!: Queue;
createInstance() {
this.queue = new Queue();
}
push() {
this.queue.push(10);
}
async pipe() {
await this.queue.pipe(somewhere);
}
}$ nole './test/**/*.test.ts'
(ok) 0.06 ms QueueTest.createInstance()
(ok) 0 ms QueueTest.push()
(ok) 1.63 ms QueueTest.pipe()
3 passed (2.48 ms)
discover: 2.68 ms
resolve: 80.23 ms
tests: 2.48 msNo describe, no it, no registration. Export the class and it runs.
Install
npm i -D nole tsx{
"scripts": {
"test": "nole './test/**/*.test.ts'"
}
}Quote the glob. Nole expands it, not your shell.
When a test fails
The run keeps going. Failures are collected and shown at the end.
$ nole './test/**/*.test.ts'
(ok) 0.05 ms CartTest.addsItem()
(failed) 0.52 ms CartTest.checksTotal()
AssertionError: 24 == 25
(ok) 0.01 ms CartTest.pays()
Failures:
1) CartTest.checksTotal()
AssertionError [ERR_ASSERTION]: 24 == 25
at CartTest.checksTotal (test/cart.test.ts:10:12)
- expected + actual
- 25
+ 24
2 passed, 1 failed (1.37 ms)The stack has no nole frames in it. Only your code.
Skip
Start the name with skip.
export class QueueTest extends Test() {
skip_drain() {}
} (skip) QueueTest.skip_drain() {marked as skipped}Careful. Any name that starts with skip is skipped, so skipsBadInput() never
runs. Call it rejectsBadInput().
Skip the whole class.
export class QueueTest extends Test({ skip: "no need" }) {
push() {}
}Skip one test at runtime.
import { Test, skipTest } from "nole";
export class QueueTest extends Test() {
needsRedis() {
if (!process.env.REDIS_URL) {
skipTest("REDIS_URL is not set");
}
}
} (dskip) QueueTest.needsRedis() {REDIS_URL is not set}Skip the rest of the class at runtime. This works in before too.
import { Test, skipClass } from "nole";
export class RemoteTest extends Test() {
before() {
if (!process.env.STAGING_URL) {
skipClass("STAGING_URL is not set");
}
}
ping() {}
pong() {}
} (dskip) RemoteTest:before() {STAGING_URL is not set}
(dskip) RemoteTest.ping() {STAGING_URL is not set}
↳ skip came from :before
(dskip) RemoteTest.pong() {STAGING_URL is not set}
↳ skip came from :beforeskipTest() inside a hook is an error. You meant skipClass().
Helper methods
Every method is a test. Start the name with _ to keep one for yourself.
export class QueueTest extends Test() {
async _connect() {
return new Connection("...");
}
async push() {
const conn = await this._connect();
conn.push(10);
}
}Change the prefix with --spec-skip-prefix or SPEC_SKIP_PREFIX.
Hooks
export class HookTest extends Test() {
value = 0;
before() {}
beforeEach() {}
afterEach() {}
after() {}
cleanUp() {}
checks() {}
}Order:
before once, per class
beforeEach before every test
test
afterEach after every test
after once, per class
cleanUp once, after everything that depends on this class is doneWant more than one hook of a kind? Start the name with hook. End it with the
kind you want. No ending means before.
export class QueueTest extends Test() {
hookConnect() {}
hookSeedBeforeEach() {}
hookCloseAfter() {}
push() {}
} (ok) 0.14 ms QueueTest.hookConnect:before()
(ok) 0.01 ms QueueTest.hookSeedBeforeEach:beforeEach()
(ok) 0.03 ms QueueTest.push()
(ok) 0.01 ms QueueTest.hookCloseAfter:after()cleanUp always runs. After a failure, after --bail, after ctrl+c.
Dependencies
export class DatabaseTest extends Test() {
connection = { open: false };
connect() {
this.connection.open = true;
}
cleanUp() {
this.connection.open = false;
console.log("connection closed");
}
}
export class OrderTest extends Test({
dependencies: { database: () => DatabaseTest },
}) {
reads() {
if (!this.database.connection.open) throw new Error("no connection");
}
} (ok) 0.06 ms DatabaseTest.connect()
(ok) 0.01 ms OrderTest.reads()
connection closed
(ok) 0.05 ms DatabaseTest:cleanUp()DatabaseTest runs first. Its cleanUp runs last, after OrderTest is done.
Did your dependency get skipped?
import { Test, isSkipped, skipClass } from "nole";
export class OrderTest extends Test({
dependencies: { database: () => DatabaseTest },
}) {
reads() {
if (isSkipped(this.database)) {
skipClass("no database, no orders");
}
}
}Order without dependencies
export class A extends Test() { a() {} }
// B runs before A
export class B extends Test({ before: () => [A] }) { b() {} }
// C runs after A
export class C extends Test({ after: () => [A] }) { c() {} } (ok) 0.05 ms B.b()
(ok) 0 ms A.a()
(ok) 0 ms C.c()Inheritance
class Simple extends Test() {
value = 1;
check() {}
}
// not exported, so nole ignores it. The children below still run.
export class Complex extends Simple {
check() {
super.check();
}
}
export class MoreComplex extends Simple {
moreSpecs() {}
}Parent tests run first, so a child can build on what the parent left behind.
export class GrandparentTest extends Test() {
variable = 1;
doesSomething() {
assert.equal(this.variable, 1);
this.variable = 2;
}
}
export class ChildTest extends GrandparentTest {
doesMoreStuff() {
assert.equal(this.variable, 2);
}
} (ok) 0.08 ms ChildTest.doesSomething()
(ok) 0.01 ms ChildTest.doesMoreStuff()
(ok) 0 ms GrandparentTest.doesSomething()Both classes are exported, so both run. Each one gets its own instance.
Dynamic tests
import { Test, addTest } from "nole";
// not exported, nole cannot find it
class DynamicTest extends Test() {
test() {}
}
addTest(() => DynamicTest);Add one from inside a test.
export class DeepTest extends Test() {
check() {
if (something) {
addTest(() => class Wololo extends Test() {});
}
}
}Reach another class from inside a test with getTest. It returns null if
that class has not been registered yet.
import { Test, getTest } from "nole";
export class ReportTest extends Test() {
reads() {
const database = getTest(() => DatabaseTest);
console.log(database?.connection);
}
}Timeouts
Default is 5000 ms.
export class SlowTest extends Test({ timeout: 30_000 }) {}$ nole './test/**/*.test.ts' --timeout 10000Only know at runtime? Call timeout(). The clock restarts from there.
import { Test, timeout } from "nole";
export class UploadTest extends Test() {
async uploadsABigFile() {
timeout(60_000);
await upload(bigFile);
}
}Retries
let tries = 0;
export class NetworkTest extends Test({ retries: 2 }) {
fetches() {
if (++tries < 3) throw new Error("connection reset");
}
} (retry) NetworkTest.fetches() {attempt 1 failed, 2 left}
(retry) NetworkTest.fetches() {attempt 2 failed, 1 left}
(ok) 0 ms NetworkTest.fetches() (flaky, passed on attempt 3)
1 passed, 1 flaky (1.37 ms)A test that only passes on a retry is flaky, not green.
beforeEach and afterEach run again on every attempt.
Same thing for every class:
$ nole './test/**/*.test.ts' --retries 2Bail
Stop the whole run at the first failure.
$ nole './test/**/*.test.ts' --bailStop one class only. Its tests build on each other, so the rest is noise.
export class CartTest extends Test({ bail: true }) {
addsItem() {}
checksTotal() {}
pays() {}
} (ok) 0.05 ms CartTest.addsItem()
(failed) 0.52 ms CartTest.checksTotal()
AssertionError: 24 == 25
(block) CartTest.pays() {an earlier spec of this class failed}
(ok) 0.02 ms OtherTest.stillRuns()Other classes keep running. after and cleanUp still run.
Run classes in parallel
$ nole './test/**/*.test.ts' --concurrency 4Dependencies are respected. Output of each class is printed as one block.
Tests inside one class always run in order. They share an instance.
Some classes cannot share the machine. A port, a directory, an env var.
export class MigrationTest extends Test({ exclusive: true }) {
async migrates() {
process.chdir("./tmp/workspace"); // nothing else is running right now
}
}Nole waits for the running classes to finish, runs this one alone, then goes
back to normal. Free at --concurrency 1.
Run one test
$ nole './test/**/*.test.ts' --grep 'QueueTest.push'
(ok) 0.05 ms QueueTest.push()
1 passed, 2 filtered (0.5 ms)A /pattern/ is a regular expression.
$ nole './test/**/*.test.ts' --grep '/^Queue.*\.p/'Classes your match depends on still run in full. They set up the state it needs.
Reporters
$ nole './test/**/*.test.ts' --reporter dotspec (default), dot, json, tap, junit.
$ nole './test/**/*.test.ts' -r tap
TAP version 13
ok 1 - DatabaseTest.connect
ok 2 - OrderTest.reads
ok 3 - RemoteTest.ping # SKIP STAGING_URL is not set
1..3Keep the readable output and write a file for CI at the same time.
$ nole './test/**/*.test.ts' --report junit:reports/results.xml --report json:reports/results.jsonConfig file
Drop a nole.config.ts next to your package.json.
import { defineConfig } from "nole";
export default defineConfig({
globs: ["./test/**/*.test.ts"],
timeout: 10_000,
retries: 1,
concurrency: 4,
reporter: "dot",
reports: [{ name: "junit", file: "./reports/results.xml" }],
});$ noleFlags beat the config file. .js, .mjs and .cjs work too.
Watch
$ nole './test/**/*.test.ts' --watchEvery pass runs in a fresh process, so your changes are really reloaded.
Coverage
$ nole './test/**/*.test.ts' --coverageRuns the same command through the c8 you already have.
CommonJS
Both builds ship. import gets ESM, require gets CommonJS.
import { Test } from "nole"; // ESM
const { Test } = require("nole"); // CommonJSTypeScript is optional.
// test/queue.test.js
const { Test } = require("nole");
exports.QueueTest = class QueueTest extends Test() {
check() {
console.log("It works from CommonJS");
}
};Both kinds of file can live in one run.
$ nole './test/**/*.test.{ts,js}'
(ok) 0.11 ms QueueTest.push()
(ok) 0.58 ms CommonJSTest.check()Exit codes
0 everything passed
1 something failed
130 ctrl+cOptions
Usage: nole <glob> ... [options]
Arguments:
globs test file globs, quote them so nole expands them
Options:
-b, --bail stop as soon as something fails
-t, --grep <pattern> only run specs matching ClassName.specName,
/regex/ is supported
--timeout <ms> override the timeout of every spec and hook
--retries <n> extra attempts before a failing spec is reported
-c, --concurrency <n> how many test classes may run at once
-r, --reporter <name> spec, dot, json, tap or junit
--report <reporter:file> also write a report to a file, repeatable
-s, --setup <file> load a file before discovery, repeatable
--config <file> path to a nole config file
--no-config ignore any nole.config.* file
--wait <ms> delay before the run, for late dynamic tests
--spec-skip-prefix <prefix> methods with this prefix are helpers
-w, --watch rerun when a file changes
--coverage run the suite through c8
--no-color disable coloured output
-V, --version output the version number
-h, --help display help for commandClass options:
Test({
skip: "reason", // skip the class
timeout: 30_000, // ms per test and hook
retries: 2, // extra attempts for a failing test
bail: true, // stop this class at its first failure
exclusive: true, // never run next to another class
dependencies: { db: () => DatabaseTest },
before: () => [A], // this class runs before A
after: () => [B], // this class runs after B
});Your own reporter
// reporters/slack.ts
import { BaseReporter, type SpecResult } from "nole";
export class SlackReporter extends BaseReporter {
specResult(result: SpecResult) {
if (result.status === "failed") {
notify(`${result.className}.${result.specName} failed`);
}
}
}Override only what you need. Every event is optional.
Point the config file at it. Nole hands you the output stream.
// nole.config.ts
import { defineConfig } from "nole";
import { SlackReporter } from "./reporters/slack.js";
export default defineConfig({
globs: ["./test/**/*.test.ts"],
reporter: (write) => new SlackReporter(write),
});Events, all optional:
runStart(info) // the run is about to start
classStart(name) // a class is about to start
hookResult(result) // a hook finished
specResult(result) // a test finished
retry(info) // a test failed and will be tried again
classEnd(name) // a class is done
runEnd(summary) // everything is done
finalize() // flush your buffers, may return a promiseLicense
MIT
