Unitium is a super simple test runner for Node, Bun, Deno, and the browser that will allow you to create test suites with classes:
// example.test.ts
import assert from "assert";
export class ExampleTestSuite
{
testThatPasses()
{
assert.equal(1,1);
}
testThatFails()
{
assert.equal(1,2);
}
#thisIsNotATest()
{
return 1;
}
}Leads to the following output on the console:
>> npx unitium-tsx
Basic Example Test Suite
Passed: 1/2
✔️ Test that passes
Failed: 1/2
❌ Test that fails --> AssertionError: 1 == 2 --> ".../unitium/example/example.test.ts:17:16"
- Every exported class of a test file is a test suite
- Public member functions of test suites/exported classes will be interpreted as tests
- If you want a member not to be interpreted as a test make it private through a prepended
#e.g.#noTest() {...}.
- If you want a member not to be interpreted as a test make it private through a prepended
- The class and function names serve as descriptors and will be de-camelized in the console output:
class IAmATestSuitewill becomeI Am A Test SuiteshouldPassThisTest()becomesShould pass this test
- Every test run will be served a "fresh" class instance - its constructor will be run before every member function call.
- If you do not desire this behaviour, decorate your class with the
@Sequentialdecorator. The class will then preserve its state between function calls. Test/functions will be called in order of appearance in the class.
- If you do not desire this behaviour, decorate your class with the
Install from npm:
npm install --save-dev unitium
Unitium supports js and ts files for testing in Node, Bun, and Deno. You may place your files anywhere in your code repository. Unitium looks for files ending on either .test.ts/.spec.ts or .test.js/.spec.js respectively.
There is no special folder to place your tests in - Unitium was created with repositories in mind where test files are placed right along the source files of the to be tested code. For example:
...
/src
/modules
...
- module.ts
- module.test.ts
...
...
Tests are organized in classes, which are referred to as test suites. Each public method of a class will be interpreted as a separate test. Tests may be synchronous and asynchronous.
A test file may have multiple test suites, but also other non-test-suite classes. Only classes marked with export will be interpreted as test suites.
Private members (#-prepended) will not be interpreted as tests and can be used as utility functions or as data variables.
import assert from "assert"; // <-- feel free to use any assertion library
export class TestSuite // <-- "export" test suites
{
#sampleData = [1,2,3]; // <-- keep any non-test-members private through #
constructor()
{
//if necessary, initialize your test suite here
}
testDefinition() // <--- give your tests descriptive function names
{
assert.equal(1,1);
}
#utilityFunction() // <-- keep any non-tests private through the #
{
}
}It's important to note that tests within a test suite are run sequentially even if they are async tests. That is to say that within a test suite no 2 tests are run in parallel to avoid async issues when variables that are shared between tests are used.
Multiple test suites, however, are run asynchronously/in parallel. That means that if there are multiple test suites in a file with async tests, these tests may execute along each other, but still with only one test running per test suite.
As mentioned, by default test suite test are run in parallel by default. But by using the @Sequential decorator you can tell Unitium to treat your test suite as sequential tests.
If a class is decorated with @Sequential only one single instance of a test suite will be created and passed through the test functions in order of appearance.
import { Sequential } from "unitium";
@Sequential
class SequentialTestSuite
{
counter = 0;
incrementCounterAndTest()
{
this.counter++;
assert(this.counter === 1);
}
incerementAgainAndAssert()
{
this.counter++;
assert(this.counter === 2); //If this suite was not sequential, this would fail as "this" would be a fresh instance of "SequentialtestSuite" and hence this.counter would be 0.
}
}A test suite goes through a certain lifecycle:
x -> pending --> <static> onSetup() --> set up ==>> onBeforeEach(test) >> testing >> onAfterEach(test) ==>> testing finished --> <static> onTeardown() --> completed -> o
Note that the onSetupand onTeardown hooks should be static members of the class if the test suite is non-sequential. All other hooks are member functions by default.
If any member function hooks are detected, the test suite is automatically treated as a sequential test suite.
All hooks can be synchrounous or asynchronous.
An example that might be a valid use case:
@Sequential
class DBTest
{
static DBConnection;
static async onSetup()
{
this.DBConnection = await DB.connect(...)
}
static async onTeardown()
{
await this.DBConnection.dispose();
}
async onBeforeEach(test: Test)
{
console.log("throttling before " + test.name);
await throttleTestQueries();
}
async testQuery1()
{
const result = await DBTest.DBConnection.query(...)
...
}
async testQuery2()
{
...
}
...
}Once you have installed Unitium and written your tests it's time to test:
npx unitium-tsx
Unitium will then scan the directory for test files, load them and then start their respective test suites.
You may want to add the following line to your package.json:
...
"scripts": {
"test": "unitium-tsx"
}
...By default Unitium will scan your entire working directory for testing files. It is recommended to specify the folder that contains your tests, especially in larger repositories.
You can specify certain files or folders to search for tests in:
npx unitium-tsx ./src/module.test.ts ./testingFolder ./anotherTestingFolder/
If you specify files or folders only these specified entities will be searched for tests.
Unitium currently does not read .gitignore, so ignored build output, fixture folders, or generated files can still be scanned if they contain files ending in .test.ts, .spec.ts, .test.js, or .spec.js.
Use the command that matches the runtime that will execute your tests:
| Runtime | Command |
|---|---|
| Node with TypeScript | npx unitium-tsx |
| Node with JavaScript | npx unitium |
| Bun | bun x unitium |
| Deno | deno x --allow-read unitium |
The plain unitium command runs JavaScript directly. Node supports native TypeScript execution in newer versions, but features that require type transformations are not supported by native type stripping. For decorators and other non-strippable TypeScript features, prefer unitium-tsx or unitium-ts-node.
All CLI runtime commands accept the same flags and file or folder arguments:
npx unitium-tsx --json ./src ./tests/example.test.ts
bun x unitium --silent ./src
deno x --allow-read unitium ./src
deno x fetches and runs Unitium on demand, so no separate deno install step is needed. Bun's package runner is invoked as bun x unitium.
You can install Unitium from npm if you use a build tool:
npm install --save-dev unitium
If your build tool supports package specifiers in HTML, you can reference Unitium through its package exports:
...
<head>
...
<link rel="stylesheet" href="unitium/browser/style.css">
...
</head>
<body>
<script src="unitium/browser/index.js" type="module"></script>
<script test src="example.test.ts" type="module"></script>
<main>
<div id="unitium-output"></div>
</main>
</body>
...Otherwise reference the files from node_modules directly:
...
<head>
...
<link rel="stylesheet" href="./node_modules/unitium/distribution/environments/browser/style.css">
...
</head>
<body>
<script src="./node_modules/unitium/distribution/environments/browser/index.js" type="module"></script>
<script test src="example.test.ts" type="module"></script>
<main>
<div id="unitium-output"></div>
</main>
</body>
...Alternatively you can download Unitium from an npm-based CDN:
...
<head>
...
<link rel="stylesheet" href="https://unpkg.com/unitium/distribution/environments/browser/style.css">
...
</head>
<body>
<script src="https://unpkg.com/unitium/distribution/environments/browser/index.js" type="module"></script>
<script test src="example.test.ts" type="module"></script>
<main>
<div id="unitium-output"></div>
</main>
</body>
...Unitium provides the option to display test results directly in the browser for easy inspection. Unitium will look for an element with the id unitium-output to mount its output there - so if you provide it like in the two examples above it will fill these elements with its output.
Unitium needs to know which files to test in the browser. Unitium will identify tests through script tags with a test-attribute applied to them. It will then load these modules and run the test suites in them:
<script src="no-test.ts"></script>
<script src="also-no-test.ts" type="module"></script>
<script test src="test-file-1.ts" type="module"></script>
<script test src="test-file-2.ts" type="module"></script>In the above example only the last 2 elements are considered test modules as they have a src-attribute and a test-attribute.
You can not provide non-modules as test files. Also inline-modules are currently not supported.
Tests are organized in classes, which are referred to as test suites. Each public method of a class will be interpreted as a separate test. Tests may be synchronous and asynchronous.
A test file may have multiple test suites, but also other non-test-suite classes. Only classes marked with export will be interpreted as test suites.
Private members (#-prepended) will not be interpreted as tests and can be used as utility functions or as data variables.
As browser environments do not include a native assert module you need to bring your own assertions. This can be a tiny helper in your project or any assertion library that throws upon false assertions.
Output-support may vary, but feel free to raise an issue if you like to have better support for a common library.
import * as assert from "./assert.ts"; // <-- any throwing assertion helper works
export class TestSuite // <-- "export" test suites
{
#sampleData = [1,2,3]; // <-- keep any non-test-members private through #
constructor()
{
//if necessary, initialize your test suite here
}
testDefinition() // <--- give your tests descriptive function names
{
assert.equal(1,1);
}
#utilityFunction() // <-- keep any non-tests private through the #
{
}
}It's important to note that tests within a test suite are run sequentially even if they are async tests. That is to say that within a test suite no 2 tests are run in parallel to avoid async issues when variables that are shared between tests are used.
Multiple test suites, however, are run asynchronously/in parallel. That means that if there are multiple test suites in a file with async tests, these tests may execute along each other, but still with only one test running per test suite.
Upon loading the page Unitium will run the tests and output its results to the output-HTML-element you provided - and upon finishing all tests - also on the console.
In order to debug tests or underlying programs during development Unitium checks whether there is any test decorated with the @Debug decorator on a test suite's member method. If this is the case all other test will be discarded for this test runner run and only this test will be executed.
class TestSuite
{
testThatWillNotBeRun()
{
//this test will not be run as it is not decorated with @Debug
}
@Debug
testThatWillBeDebugged()
{
//this test will be run
}
}If the test is in a sequential test suite, though, all tests leading up to the debugged test will be executed as they are assumed to alter the state of the test suite:
@Sequential
class TestSuite
{
testThatWillBeRun() {}
testThatWillBeRunAsWell() {}
@Debug
testThatWillBeDebugged() {}
testThatWillNotBeRun(){}
}If you would like to use Unitium programmatically, you can either use the CLI or the JS API to invoke the test runner.
There are three Node executables distributed with unitium:
unitium: The standard JS runner.unitium-tsx: A TS runner that invokestsx.unitium-ts-node: A TS runner that invokests-node.
Bun and Deno can execute the same CLI through their package runners:
bun x unitiumdeno x --allow-read unitium
deno x fetches Unitium and its npm dependencies on demand. Bun uses bun x for the same package-runner workflow.
Node supports native TypeScript execution in recent versions, but native type stripping does not handle TypeScript features that need transformations. Use unitium-tsx or unitium-ts-node for tests that use decorators or other non-strippable TypeScript features.
By default these runners scan your current working directory for files ending on either spec.ts, test.ts, spec.js and test.js and run all the found tests in them. All executables follow the same CLI schema:
unitium [flags] [files/folders]
--json: Instead of outputting human readable test results the output will be JSON summarizing the tests.--silent: No output will be printed to stdout. Success or failure of tests can be determined by the process' exit code.
You can combine individual test files and folders in one command. When you provide paths, Unitium only uses those files and the test files found inside those folders:
unitium-tsx --json ./path/to/specific.test.ts ./path/to/testFolder
Specifying files or folders is recommended because Unitium currently does not respect .gitignore while scanning.
You can also invoke the runner programmatically through its JS API.
RuntimeEnvironment.resolveRuntimeModules() selects the file-system adapter for the runtime that is executing the current process. Use it when you want the same programmatic runner code to work in Node, Bun, and Deno.
import { RuntimeEnvironment, TestRunner, ConsoleReporter } from "unitium/runner-api";
...
async runTests()
{
const { AppSpecification } = await RuntimeEnvironment.resolveRuntimeModules();
const spec = new AppSpecification();
await spec.load([]);
await new TestRunner(spec, [new ConsoleReporter(spec)]).run();
//after the tests are run you can inspect the results
if(spec.tests.includes(test => test.error !== undefined))
console.log("One or more tests failed");
}
...This behaves the same as invoking the CLI. If you would like to customize which files or folders are loaded, pass them to spec.load:
import { RuntimeEnvironment, TestRunner, ConsoleReporter } from "unitium/runner-api";
//...
const { AppSpecification } = await RuntimeEnvironment.resolveRuntimeModules();
const spec = new AppSpecification();
await spec.load(["./src/module.test.ts", "./tests"]);
//...You can control stdout output by passing an array of one or more reporters to the TestRunner constructor. The currently available reporters are:
ConsoleReporter: Outputs human readable output to stdout.JSONReporter: Outputs to stdout in JSON format.
If you have further requirements or need clarification, feel free to raise an issue.