Testing
Mikro.js includes a built-in test framework and a CLI command for running tests directly on the device. Tests run in isolation: each test file is deployed and executed in a fresh runtime with a full heap.
Quick start
Create a test file:
// test/math.test.ts
import {describe, test, assert} from 'mikro/test'
describe('math', () => {
test('addition', () => {
assert.equal(1 + 1, 2)
})
})Run it:
mikro testThe mikro test command discovers all *.test.ts files, deploys them to the connected device, and reports colored results:
[1/1] test/math.test.ts
Building...
Deploying...
Waiting for results...
math
✓ addition (1ms)
1 passed (12ms)
PASS 1 passed (1 tests across 1 files) 3.2sFailures show in red with the error message indented below. Skipped tests show in yellow.
Writing tests
Import describe, test, and assert from mikro/test.
Suites and tests
import {describe, test, assert} from 'mikro/test'
describe('my module', () => {
test('sync test', () => {
assert.equal(2 * 3, 6)
})
test('async test', async () => {
const {sleep} = await import('mikro/sleep')
const start = Date.now()
await sleep(50)
assert.truthy(Date.now() - start >= 40)
})
})Skipping tests
test.skip('not ready yet', () => {
// this won't run, but shows as skipped in output
})
describe.skip('entire suite', () => {
test('also skipped', () => {})
})Use test.fixme / describe.fixme to flag broken tests that need to be fixed (same behavior as skip, different intent).
Focusing tests
test.only('the one I care about', () => {
// only this test runs; all others in the file are skipped
})
describe.only('focused suite', () => {
test('runs', () => {})
})When any .only is present in a file, non-focused suites and tests in that file are skipped. Focus is per-file only, not manifest-wide: other test files in the run still execute normally, since each one evaluates in its own fresh runtime. To narrow the run across files, pass a path or glob:
# Just the one file
mikro test 'test/math.test.ts'
# Anything under test/wifi/
mikro test 'test/wifi/**/*.test.ts'Conditional tests
Use skipIf and runIf to conditionally skip tests or suites based on a truthy/falsy expression:
import {describe, test, assert} from 'mikro/test'
import {env} from 'mikro/env'
const hasWifi = env.get('WIFI_SSID') && env.get('WIFI_PASSPHRASE')
// skip the entire suite when credentials are missing
describe.runIf(hasWifi)('wifi', () => {
test('connect', async () => {
const {wifi} = await import('mikro/wifi')
const result = await wifi.connect({
ssid: env.get('WIFI_SSID')!,
passphrase: env.get('WIFI_PASSPHRASE')!,
})
assert.ok(result)
})
})These are available on both test and describe:
| Modifier | Behavior |
|---|---|
test.skip(name, fn) | Always skip |
test.only(name, fn) | Focus: if any .only exists, only focused tests run |
test.fixme(name, fn) | Skip with intent "broken, needs fix" |
test.skipIf(cond) | Skip when cond is truthy |
test.runIf(cond) | Skip when cond is falsy |
describe.skip(name, fn) | Skip all tests in the suite |
describe.only(name, fn) | Focus the suite |
describe.todo(name, fn) | Mark suite as todo; all tests reported as todo |
describe.fixme(name, fn) | Skip the suite with intent "broken, needs fix" |
describe.skipIf(cond) | Skip suite when cond is truthy |
describe.runIf(cond) | Skip suite when cond is falsy |
skipIf and runIf return a function, so the test/suite name comes in the second call:
test.skipIf(isDev)('prod only', () => {})
describe.runIf(hasHardware)('gpio', () => {})Todo tests
Mark tests you plan to write later. No function body is needed:
test.todo('handle reconnect after deep sleep')
test.todo('retry on timeout')Todo tests show as blue in the output and are counted separately from skipped tests.
Parameterized tests
Use test.each or describe.each to run the same test with different inputs:
test.each([0, 1, 2])('pin %s toggles', (pin) => {
// runs three tests: "pin 0 toggles", "pin 1 toggles", "pin 2 toggles"
})
describe.each([9600, 115200])('uart at %s baud', (baud) => {
test('sends data', () => {
/* ... */
})
})Name interpolation supports %s (string coercion), %# (index), and %o (JSON for objects).
each composes with skipIf and runIf:
test.skipIf(isCI).each([1, 2, 3])('hardware test %s', (pin) => {
/* ... */
})Setup and teardown
beforeAll and afterAll run once per suite. beforeEach and afterEach run around every test:
import {describe, test, assert, afterAll, afterEach} from 'mikro/test'
import {nvsStorage} from 'mikro/kv/nvs'
describe('storage', () => {
const val = nvsStorage.createValue('test-key')
afterAll(() => {
nvsStorage.clear().orPanic('nvs clear failed')
})
afterEach(() => {
val.delete()
})
test('write and read', () => {
val.set('hello')
assert.equal(val.get(), 'hello')
})
})If beforeAll throws, all tests in the suite are skipped. If beforeEach throws, that test fails. afterAll and afterEach errors are non-fatal.
Assertions
The assert object provides common assertion methods:
| Method | Description |
|---|---|
assert.equal(a, b) | Strict equality (Object.is) |
assert.notEqual(a, b) | Strict inequality |
assert.truthy(value) | Truthy check |
assert.deepEqual(a, b) | Deep equality (JSON comparison) |
assert.throws(fn) | Expects function to throw |
assert.rejects(fn) | Expects async function to reject |
assert.type(value, type) | typeof check |
assert.instance(value, ctor) | instanceof check |
assert.ok(result) | Asserts Result is ok, returns value |
assert.err(result) | Asserts Result is error, returns error |
See the full mikro/test API reference for details.
Testing Results
Since Mikro.js APIs return Result types, assert.ok and assert.err are particularly useful:
import {describe, test, assert} from 'mikro/test'
import {encode, decode} from 'mikro/cbor'
describe('cbor', () => {
test('roundtrip', () => {
const encoded = encode({temp: 22.5})
assert.ok(encoded)
const decoded = decode(encoded.value)
assert.ok(decoded)
assert.deepEqual(decoded.value, {temp: 22.5})
})
test('decode rejects invalid input', () => {
const result = decode(new Uint8Array([0xff, 0xff]))
assert.err(result)
assert.equal(result.error.name, 'DecodeFailed')
})
})Running tests
All tests
mikro testSpecific file
mikro test 'test/math.test.ts'With environment variables
mikro test auto-loads .env and .env.test from the project root. Pass --env-file FILE to layer an extra file on top, or --no-auto-env to skip auto-discovery. See Environment Variables for the full precedence rules.
The MIKRO_ENV variable is automatically set to "test" during test runs. Use it for conditional behavior:
import {env} from 'mikro/env'
if (env.get('MIKRO_ENV') === 'test') {
// test-specific setup
}Environment-based skipping
Tests that depend on hardware or network can use runIf with env vars:
import {env} from 'mikro/env'
import {describe, test, assert} from 'mikro/test'
const ssid = env.get('WIFI_SSID')
const pass = env.get('WIFI_PASSPHRASE')
describe.runIf(ssid && pass)('wifi', () => {
test('connect', async () => {
const {wifi} = await import('mikro/wifi')
const result = await wifi.connect({ssid: ssid!, passphrase: pass!})
assert.equal(result.ok, true)
})
})Resource tracking
Each test file runs in a fresh runtime with a full heap.
The test runner flags common leaks at the end of each file:
- Timers that were started but never cleared.
- In-flight HTTP requests that weren't cancelled or awaited.
- Heap growth that exceeds a committed per-test snapshot.
These show up as ⚠ warnings in the test output.
Heap snapshots
The first run writes each file's measured figures to __heap_snapshots__/<chip>.json in the project root, keyed by the test's path:
// __heap_snapshots__/esp32c6.json
{
"tests": {
"test/smoke.test.ts": {"heapDelta": 3296},
"test/wifi-powersave.test.ts": {"heapDelta": 37692, "sysUsed": 71680}
}
}Two figures, because they fail differently:
heapDeltais retention: JS heap the file still holds when it finishes, summed over its suites. A leak grows it run over run. It counts QuickJS allocations only, so native memory (TLS records, wifi buffers, drivers) is invisible to it.sysUsedis a peak: how far free system heap fell from the start of the run to its lowest sampled point, which is how close the file came to running out. Device-only, so entries measured on the host simulator carry none.
A file can be clean on one and regress on the other, so a run flags whichever moved. Retention is measured from a baseline recaptured after each suite's beforeAll, which keeps warmup (module loads, TLS setup, wifi.connect) from reading as a leak. The peak is measured from the start of the run and never rebaselined: memory a beforeAll takes is memory the file genuinely needed at once.
There's a file per chip because the same test retains different amounts on different targets: pointer size, which native modules are built in, and sdkconfig defaults all move the number. A run only touches the file for the chip it ran against.
Commit these files. Subsequent runs compare against the stored value and warn if it's exceeded. When a refactor legitimately changes the footprint, rerun with -u (--update-heap) to overwrite them; the diff lands with the code change.
Runs drift by a few bytes on their own, so a snapshot is only flagged, and only rewritten, once the change clears a tolerance. For heapDelta the default is 256 bytes or 1% of the stored value, whichever is larger. sysUsed gets a wider band, 2KB or 5%, because a peak moves with wifi buffer timing and TLS record sizes and is sampled between tests rather than continuously. --heap-tolerance overrides both:
mikro test --heap-tolerance 1kThe same tolerance governs flagging and rewriting, so anything a run warns about is something -u will write.
A file with failures, or whose tests were all skipped, leaves its snapshot untouched, with or without -u: a skipped run measures an empty file, and a failing run measures a broken one.
The same file also holds boot figures: the JS budget and free system heap a device leaves for an app, read from the ready handshake, plus the memReserved the device booted with. The device captures them at boot, before the test supervisor allocates anything, so they describe the floor your app starts from rather than the run that read them:
// __heap_snapshots__/esp32c6.json
{
"boot": {"heapFree": 223232, "systemFree": 241664, "memReserved": 65536},
"tests": {
"test/smoke.test.ts": {"heapDelta": 3296}
}
}A run prints them before the first file and records them under the same rules as the per-test figures: a chip it has never seen is written, and -u overwrites once the drift clears the tolerance. This is worth having because a per-test heapDelta is measured against a baseline taken inside that run, so firmware that leaves 20KB less for everything moves no per-test figure at all.
mikro profile reads the same figures on demand, without deploying a test manifest, and records them with --write.
The figures describe the config the device booted with. A project that overrides memReserved for its test environment boots a test run and mikro profile with different reserves, so the js figure is compared with the reserve added back and a reserve change on its own never reads as a regression. The recorded memReserved says which reserve the stored figure was taken at.
beforeAll warmup (module init, wifi.connect, a throwaway TLS handshake) is folded into the baseline automatically.
Project structure
mikro test picks up every *.test.ts under the current directory (ignoring node_modules/ and build/):
app/
sensors/
temperature.ts
temperature.test.ts
storage/
kv-schema.ts
kv-schema.test.tsA top-level test/ directory also works, and fits end-to-end suites that don't map to a single source file.
CLI reference
See mikro test for the full list of options.