Skip to content

igorjs/pure-test

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

119 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Pure Test

Minimal cross-runtime test runner. Zero dependencies.

Note: This project is in beta. APIs may change.

Works identically on Node.js, Deno, Bun, Cloudflare Workers, and browsers.

Node.js Deno Bun Zero Dependencies

Philosophy

  1. No magic. Tests are just modules. Import, register, done.
  2. No workers. Single process, no startup overhead. Tests run in ~5ms, not ~500ms.
  3. No transforms. Requires .mjs or runtime-native TypeScript. No babel, no esbuild.
  4. No config files. CLI args only. Zero configuration to start.
  5. Isolation is your responsibility. The runner doesn't sandbox tests. Use beforeEach/afterEach for setup/teardown. If tests share mutable state, fix the tests, not the runner.
  6. Performance by default. Sequential execution is safe. describe.concurrent is opt-in for when you know tests are independent.

Install

npm install @igorjs/pure-test --save-dev

Quick Start

import { describe, it, expect } from '@igorjs/pure-test'

describe('math', () => {
  it('adds numbers', () => {
    expect(1 + 1).toBe(2)
  })

  it('works async', async () => {
    const result = await Promise.resolve(42)
    expect(result).toBe(42)
  })
})

// Auto-runs when the module finishes loading. No run() call needed.

Run with any runtime:

node tests/math.test.mjs
deno run --allow-all tests/math.test.mjs
bun tests/math.test.mjs

CLI

pure-test [paths...] [options]

Options:
  --reporter, -r <name>      Output format: spec (default), tap, json, minimal, verbose
  --grep, -g <pattern>       Run only tests matching name pattern (regex)
  --testNamePattern, -t      Alias for --grep (Jest/Vitest compatible)
  --testPathPattern <pat>    Filter test files by path pattern (regex)
  --testTimeout <ms>         Default timeout for each test (ms)
  --shard <i>/<n>            Run only the i-th of n shards (1-indexed)
  --bail, -b                 Stop after first failure; skip importing remaining files
  --verbose, -v              Stream each test result as it runs
  --force-exit               Force exit after all tests complete (prevents hanging on open handles)
  --watch, -w                Re-run tests on file change (spawns fresh process per change)
  --no-parallel              Import test files sequentially (default: parallel)
  --runInBand, -i            Force serial: sequential imports + override describe.concurrent
  --ts                       Discover .ts / .mts tests (Node opt-in; automatic on Deno/Bun)
  --passWithNoTests          Exit 0 even when no test files are found
  --listTests                Print discovered test file paths and exit
  --clearMocks               Auto-call clearAllMocks() before each test
  --resetMocks               Auto-call resetAllMocks() before each test
  --restoreMocks             Auto-call restoreAllMocks() before each test
  --help, -h                 Show help

Discovers: *.test.mjs, *.test.js, *.spec.mjs, *.spec.js (and, with --ts, *.test.ts, *.spec.ts, *.test.mts, *.spec.mts)

By default the CLI imports all discovered test files in parallel, then runs everything once. With --bail, it imports one file at a time and stops on the first failure (skipping the rest). No workers, no transforms, no config.

TypeScript is opt-in: pass --ts to also discover .ts / .mts tests (automatic on Deno and Bun). Type stripping is delegated to the runtime — no build step, no bundled transpiler — so a TypeScript-capable runtime is required (Deno, Bun, or Node with type stripping). See docs/typescript.md.

npx pure-test tests/                     # discover and run all tests
npx pure-test tests/ --reporter tap      # TAP output
npx pure-test tests/ --reporter json     # JSON output
npx pure-test tests/ --reporter verbose  # streaming spec format
npx pure-test tests/math.test.mjs        # single file
npx pure-test tests/ --grep "auth"       # only tests matching "auth"
npx pure-test tests/ --shard 1/4         # CI: split across 4 jobs
npx pure-test tests/ --watch             # re-run on file change
npx pure-test tests/ --testTimeout 5000  # global 5s test timeout
npx pure-test tests/ --bail              # stop importing remaining files on first failure
npx pure-test tests/ --runInBand         # debug: serial everything

Shard for CI parallelism

Split the test file list across N jobs:

# .github/workflows/test.yml
strategy:
  matrix:
    shard: [1, 2, 3, 4]
steps:
  - run: pure-test tests/ --shard ${{ matrix.shard }}/4

Empty shards (more shards than files) exit 0 cleanly so small suites don't fail CI.

Watch mode

--watch runs tests once, then watches the target directories. On any .ts/.tsx/.js/.mjs/.jsx change, it kills the in-flight child (if any) and re-spawns a fresh process — sidesteps the ESM module cache so changes to shared helpers get picked up.

Cross-runtime: uses node:child_process.spawn on Node/Bun, Deno.Command on Deno (auto-detected).

Invoking under Bun and Deno

The bin/pure-test.mjs shebang is #!/usr/bin/env node. That picks the bootstrap runtime, not what the CLI can run under: POSIX shebangs are limited to one interpreter, and Node is the universal choice because the npm bin-symlink mechanism (node_modules/.bin/pure-test) assumes it. The script's own code uses only node: imports, so it runs identically under any of the three runtimes once the bootstrap step is bypassed.

Runtime Invocation
Node pure-test tests/ or npx pure-test tests/ (shebang fires, Node executes)
Bun bun ./node_modules/@igorjs/pure-test/bin/pure-test.mjs tests/
Deno deno run -A ./node_modules/@igorjs/pure-test/bin/pure-test.mjs tests/, or deno run -A npm:@igorjs/pure-test/bin/pure-test.mjs tests/ (no install needed)

Pin the invocation in package.json scripts so the call site stays short:

{
  "scripts": {
    "test": "pure-test tests/",
    "test:bun": "bun ./node_modules/@igorjs/pure-test/bin/pure-test.mjs tests/",
    "test:deno": "deno run -A ./node_modules/@igorjs/pure-test/bin/pure-test.mjs tests/"
  }
}

You don't need the CLI to run Pure Test. Test files auto-run on import, so node tests/foo.test.mjs, bun tests/foo.test.mjs, and deno run -A tests/foo.test.mjs all work directly without involving the CLI. The CLI exists as a convenience layer for discovery, sharding, watch mode, and reporters; under any runtime you can also write a single entry file that imports all your tests and let the runner take over.

API Reference

Test Registration

describe(name, fn)              // define a suite (nestable)
describe.concurrent(name, fn)   // suite with parallel test execution
describe.skip(name, fn)         // skip a suite
describe.only(name, fn)         // focus: only run this suite
it(name, fn, options?)          // define a test (sync or async)
test(name, fn, options?)        // alias for it
it.skip(name, fn)               // skip a test
it.only(name, fn)               // focus: only run this test
it.todo(name)                   // placeholder for a planned test
it.each(cases)(name, fn)        // parameterised test from data

Lifecycle Hooks

beforeAll(fn)    // run once before all tests in the suite
afterAll(fn)     // run once after all tests in the suite
beforeEach(fn)   // run before each test in the suite
afterEach(fn)    // run after each test in the suite

Hooks inherit: a beforeEach in an outer describe runs before each test in all nested describe blocks.

Test Options

The third parameter to it() / test() accepts a timeout (number) or an options object:

// Timeout: fail if the test takes longer than 5 seconds
it('slow operation', async () => { ... }, 5000)

// Retry: re-run a flaky test up to 3 times before failing
it('flaky API call', async () => { ... }, { retry: 3 })

// Both: timeout + retry
it('network test', async () => { ... }, { timeout: 10000, retry: 2 })

Works with it(), test(), and it.only().

Assertion Counting

Verify that the expected number of assertions ran during a test:

it('calls both callbacks', () => {
  expect.assertions(2)       // exactly 2 assertions must run
  expect(a).toBe(1)
  expect(b).toBe(2)
})

it('has at least one assertion', () => {
  expect.hasAssertions()     // at least 1 assertion must run
  expect(result).toBeTruthy()
})

Concurrent Execution

By default, tests run sequentially. Use describe.concurrent when tests are independent:

describe.concurrent('crypto operations', () => {
  it('hash', async () => { /* 100ms */ })   // all three run
  it('sign', async () => { /* 100ms */ })   // at the same time
  it('verify', async () => { /* 100ms */ }) // total: ~100ms, not 300ms
})

Nested suites within a concurrent suite still run sequentially for predictability.

Focus Mode (.only)

Use .only to run a single test or suite during debugging. All other tests are skipped:

describe('math', () => {
  it.only('this one runs', () => {
    expect(1 + 1).toBe(2)
  })

  it('this is skipped', () => {
    expect(true).toBeTruthy()
  })
})

describe.only focuses an entire suite — all tests inside it run:

describe.only('focused suite', () => {
  it('runs', () => { /* ... */ })
  it('also runs', () => { /* ... */ })
})

describe('skipped suite', () => {
  it('does not run', () => { /* ... */ })
})

Multiple .only markers can coexist. Remember to remove them before committing.

Todo Tests

Document planned tests without failing the suite:

describe('auth', () => {
  it.todo('should handle token refresh')
  it.todo('should revoke expired sessions')

  it('logs in', () => {
    // this test runs normally
  })
})

Todo tests appear in output with a todo label and are counted separately.

Parameterised Tests (it.each)

Reduce duplication for data-driven tests:

// Scalar values
it.each([1, 2, 3])('doubles %d', (n) => {
  expect(n * 2).toBeGreaterThan(n)
})

// Tuple values (array elements are spread as arguments)
it.each([
  [1, 2, 3],
  [2, 3, 5],
  [10, 20, 30],
])('%d + %d = %d', (a, b, expected) => {
  expect(a + b).toBe(expected)
})

// Object values with $property interpolation
it.each([
  { input: 'hello', len: 5 },
  { input: 'hi', len: 2 },
])('$input has length $len', ({ input, len }) => {
  expect(input).toHaveLength(len)
})

Name template specifiers:

Specifier Description
%s String
%d, %f Number
%i Integer (floored)
%j, %o JSON
%# Test case index
$property Object property value

Assertions

expect(value).toBe(expected)              // strict ===
expect(value).toEqual(expected)           // deep structural equality
expect(value).toBeTruthy()
expect(value).toBeFalsy()
expect(value).toBeNull()
expect(value).toBeUndefined()
expect(value).toBeDefined()
expect(value).toBeNaN()                  // Number.isNaN
expect(value).toBeInstanceOf(Class)
expect(value).toBeTypeOf('string')       // typeof check (Vitest-compatible)
expect(value).toSatisfy(fn)              // custom predicate
expect(value).toBeGreaterThan(n)
expect(value).toBeLessThan(n)
expect(value).toBeGreaterThanOrEqual(n)
expect(value).toBeLessThanOrEqual(n)
expect(value).toBeCloseTo(n, digits?)    // float comparison (default: 2 digits)
expect(value).toContain(item)            // string or array (===)
expect(value).toContainEqual(item)       // array (deep equality)
expect(value).toMatch(/regex/)           // regex or string pattern
expect(value).toHaveLength(n)
expect(value).toMatchObject(subset)      // partial deep match
expect(value).toHaveProperty('a.b', v)   // nested property exists, optional value
expect(value).toStrictEqual(expected)    // deep equality + undefined props + constructors
expect(fn).toThrow()
expect(fn).toThrow('message')
expect(fn).toThrow(/pattern/)
expect(spy).toHaveBeenCalled()
expect(spy).toHaveBeenCalledTimes(n)
expect(spy).toHaveBeenCalledWith(a, b)
expect(spy).toHaveBeenLastCalledWith(a)  // last call args
expect(spy).toHaveBeenNthCalledWith(n, a) // nth call args (1-indexed)
expect(spy).toHaveReturned()             // returned at least once
expect(spy).toHaveReturnedTimes(n)       // returned exactly n times
expect(spy).toHaveReturnedWith(value)    // any return matches
expect(spy).toHaveLastReturnedWith(value)
expect(spy).toHaveNthReturnedWith(n, value)

Asymmetric Matchers

Use inside toEqual, toMatchObject, toHaveBeenCalledWith, and other deep comparisons:

expect(42).toEqual(expect.any(Number))
expect('hello').toEqual(expect.any(String))
expect({ id: 1 }).toEqual(expect.anything())
expect('hello world').toEqual(expect.stringContaining('world'))
expect('abc-123').toEqual(expect.stringMatching(/\d+/))
expect({ a: 1, b: 2 }).toEqual(expect.objectContaining({ a: 1 }))
expect([1, 2, 3]).toEqual(expect.arrayContaining([3, 1]))
expect({ price: 0.1 + 0.2 }).toEqual({ price: expect.closeTo(0.3) })

Negated asymmetric matchers:

expect([1, 2]).toEqual(expect.not.arrayContaining([3, 4]))
expect({ a: 1 }).toEqual(expect.not.objectContaining({ b: 2 }))
expect('hello').toEqual(expect.not.stringContaining('xyz'))
expect('hello').toEqual(expect.not.stringMatching(/\d+/))

Composable: nest matchers freely:

expect(spy).toHaveBeenCalledWith(
  expect.any(Number),
  expect.objectContaining({ name: expect.any(String) })
)

All assertions support .not, .resolves, and .rejects:

expect(1).not.toBe(2)
expect([1, 2]).not.toContain(3)
expect(() => {}).not.toThrow()

await expect(Promise.resolve(42)).resolves.toBe(42)
await expect(Promise.reject(new Error('fail'))).rejects.toBeInstanceOf(Error)

Custom Matchers (expect.extend)

Register project-specific matchers; they integrate with .not automatically:

import { expect } from '@igorjs/pure-test'

expect.extend({
  toBeWithin(received, min, max) {
    const pass = received >= min && received <= max
    return {
      pass,
      message: () => `expected ${received} to be within ${min}..${max}`,
    }
  },
})

expect(5).toBeWithin(1, 10)
expect(50).not.toBeWithin(1, 10)

A matcher returns { pass: boolean; message(): string }. The matcher's this is bound to { isNot: boolean } so you can produce different messages depending on negation. Custom matchers are dispatched via a Proxy that falls through to built-ins first, so they never shadow toBe/toEqual/etc.

For TypeScript users, augment the Expectation interface to type your matchers:

declare module '@igorjs/pure-test' {
  interface Expectation<T> {
    toBeWithin(min: number, max: number): void
  }
}

Spies

Create standalone spy functions or spy on existing methods.

spyFn(impl?)

Create a standalone spy function. Optionally wrap an implementation.

import { spyFn } from '@igorjs/pure-test'

const callback = spyFn()
callback(1, 2)
callback('a')

callback.mock.calls        // [[1, 2], ['a']]
callback.mock.lastCall     // ['a']
callback.mock.calls.length // 2
callback.mock.results      // [{ type: 'return', value: undefined }, ...]

// With initial implementation
const double = spyFn((x) => x * 2)
double(5) // 10

spyOn(object, method)

Spy on an existing method. Calls through to the original by default.

import { spyOn, restoreAllMocks } from '@igorjs/pure-test'

const obj = { greet: (name) => `hello ${name}` }
const spy = spyOn(obj, 'greet')

obj.greet('world')         // 'hello world' (calls original)
spy.mock.calls             // [['world']]

spy.mockReturnValue('hi')
obj.greet('world')         // 'hi' (overridden)

spy.mockRestore()          // restore original
obj.greet('world')         // 'hello world'

Spy Behavior Control

All spy methods are chainable and Vitest-compatible:

const spy = spyFn()

// Fixed return value
spy.mockReturnValue(42)
spy() // 42

// One-time return values (chainable, uses queue)
spy.mockReturnValueOnce(1).mockReturnValueOnce(2).mockReturnValueOnce(3)
spy() // 1
spy() // 2
spy() // 3
spy() // falls through to mockReturnValue or undefined

// Custom implementation
spy.mockImplementation((a, b) => a + b)
spy(2, 3) // 5

// One-time implementation
spy.mockImplementationOnce(() => 'once')
spy() // 'once'
spy() // falls through to mockImplementation

// Async: resolved promise
spy.mockResolvedValue({ data: 'ok' })
await spy() // { data: 'ok' }

// Async: one-time resolved
spy.mockResolvedValueOnce('first').mockResolvedValueOnce('second')
await spy() // 'first'
await spy() // 'second'

// Async: rejected promise
spy.mockRejectedValue(new Error('fail'))
await spy() // throws Error('fail')

// Async: one-time rejected
spy.mockRejectedValueOnce(new Error('once'))

// Throw synchronously
spy.mockThrow(new Error('boom'))
spy() // throws Error('boom')

// One-time throw
spy.mockThrowOnce(new Error('once'))

// Return `this` context
spy.mockReturnThis()
const obj = { method: spy }
obj.method() === obj // true

Spy Inspection

spy.mock.calls              // Parameters<T>[] — all call arguments
spy.mock.lastCall            // Parameters<T> | undefined — last call args
spy.mock.results             // { type: 'return'|'throw', value }[] — all results
spy.mock.invocationCallOrder // number[] — global call ordering

spy.getMockName()            // string — spy's name
spy.mockName('myFn')         // set name (for assertion messages)
spy.getMockImplementation()  // T | undefined — current implementation

Spy Reset

spy.mockClear()    // clear call history, keep implementation
spy.mockReset()    // clear history + reset implementation to default
spy.mockRestore()  // clear history + restore original (spyOn only)

Object Mocking

mock(object)

Shallow mock: replace all function properties with spies.

import { mock, restoreAllMocks } from '@igorjs/pure-test'

const api = {
  getUser: (id) => fetch(`/users/${id}`),
  deleteUser: (id) => fetch(`/users/${id}`, { method: 'DELETE' }),
  baseUrl: 'https://api.example.com',
}

mock(api)
api.getUser(1)
api.getUser.mock.calls     // [[1]]
api.baseUrl                // 'https://api.example.com' (non-functions untouched)
restoreAllMocks()               // restore originals

mockDeep(object)

Recursively mock all methods on nested objects.

import { mockDeep, restoreAllMocks } from '@igorjs/pure-test'

const db = {
  users: {
    find: (id) => ({ id, name: 'Alice' }),
    create: (data) => ({ id: 1, ...data }),
  },
  posts: {
    list: () => [],
  },
}

mockDeep(db)

db.users.find.mockReturnValue({ id: 1, name: 'Mock' })
db.users.find(1)             // { id: 1, name: 'Mock' }
db.users.find.mock.calls     // [[1]]
db.posts.list.mock.calls     // []

restoreAllMocks()                 // restore all originals at every level

Handles circular references safely.

Drop-in Namespaces (vi / jest)

For easy migration, Pure Test exports vi and jest namespace objects that map to the built-in spy/mock functions:

// Vitest migration
import { vi } from '@igorjs/pure-test'    // was: import { vi } from 'vitest'

const spy = vi.fn()
vi.spyOn(obj, 'method')
vi.restoreAllMocks()
vi.clearAllMocks()
vi.resetAllMocks()

// Jest migration
import { jest } from '@igorjs/pure-test'  // was: import { jest } from '@jest/globals'

const spy = jest.fn()
jest.spyOn(obj, 'method')
jest.restoreAllMocks()

These cover the spy/mock and fake timer subsets. Features like vi.mock() and jest.mock() are intentionally not included (see What Pure Test will never support).

Fake Timers

Control time in your tests. Replaces setTimeout, setInterval, Date, and performance.now() with controllable fakes.

import { useFakeTimers, advanceTimersByTime, restoreAllMocks } from '@igorjs/pure-test'

describe('debounce', () => {
  afterEach(() => restoreAllMocks())  // also restores real timers

  it('fires after delay', async () => {
    useFakeTimers()
    let fired = false
    setTimeout(() => { fired = true }, 500)

    await advanceTimersByTime(499)
    expect(fired).toBe(false)

    await advanceTimersByTime(1)
    expect(fired).toBe(true)
  })
})

Also available via vi.useFakeTimers() / jest.useFakeTimers().

Configuration

useFakeTimers({
  now: new Date('2025-01-01'),  // initial fake time (default: real Date.now())
  toFake: ['setTimeout', 'Date'], // selective faking (default: all)
  loopLimit: 10_000,            // max iterations for runAllTimers (default: 10000)
})

Timer set/clear pairs are atomic: faking setTimeout also fakes clearTimeout.

Timer Control

await advanceTimersByTime(1000) // advance clock, fire due callbacks
await runAllTimers()            // drain queue (throws on infinite loop)
await runOnlyPendingTimers()    // fire current queue, skip newly scheduled
getTimerCount()                 // number of pending timers

All advancement functions are async and await each callback, so async timer callbacks complete before the function returns.

Date Control

useFakeTimers({ now: new Date('2025-01-01') })
Date.now()           // 1735689600000
new Date()           // 2025-01-01T00:00:00.000Z
new Date('2020-06-15') // passes through to real Date
setSystemTime(new Date('2025-06-01'))  // change clock, don't fire timers
getRealSystemTime()  // real Date.now(), bypasses fake

new Date() with no arguments returns fake time. new Date(value) passes through. instanceof Date works correctly.

Note: Fake timers use module-level state and should not be used with describe.concurrent.

Bulk Operations

import { restoreAllMocks, clearAllMocks, resetAllMocks } from '@igorjs/pure-test'

restoreAllMocks()       // restore all spied methods to originals (also clears stubs)
clearAllMocks()    // clear call history on all spies, keep implementations
resetAllMocks()    // clear history + reset implementations on all spies

The auto-* CLI flags wire these into the runner so they fire before each test:

pure-test tests/ --clearMocks      # clearAllMocks() before every test
pure-test tests/ --resetMocks      # resetAllMocks() before every test
pure-test tests/ --restoreMocks    # restoreAllMocks() before every test

Stubbing env vars and globals

Temporarily override process.env keys or globalThis properties; both are restored by restoreAllMocks():

import { stubEnv, stubGlobal, restoreAllMocks, vi } from '@igorjs/pure-test'

afterEach(() => restoreAllMocks())

stubEnv('NODE_ENV', 'production')
stubGlobal('fetch', mockFetch)

// vi/jest namespace drop-ins also expose stubEnv/stubGlobal:
vi.stubEnv('API_URL', 'https://staging.example.com')

Cross-runtime: stubEnv writes through process.env on Node and Bun, and Deno.env.set on Deno. Restoration uses the same backend it captured at stub time, so a stub set under one runtime restores correctly even if the global state changes mid-test.

Reporters

Five built-in output formats:

Reporter Description Use case
spec Human-readable with suite nesting (default) Local development
verbose Like spec, but streams each result as it runs Long-running suites where you want live feedback
tap TAP format CI pipelines, piping to TAP consumers
json Machine-readable JSON Custom tooling, dashboards
minimal Dots (. pass, F fail, s skip, T todo) Large test suites, quick overview
pure-test tests/ --reporter spec
pure-test tests/ --reporter verbose
pure-test tests/ --reporter tap
pure-test tests/ --reporter json
pure-test tests/ --reporter minimal

The spec reporter prints results in one batch at the end. The verbose reporter implements the optional onResult(testResult) hook on the Reporter interface, so each test prints as it completes — useful for slow suites where you want to see progress.

Programmatic selection:

import { setReporter } from '@igorjs/pure-test'
setReporter('tap')

Custom reporter:

import { setReporter } from '@igorjs/pure-test'

setReporter({
  name: 'custom',
  format: (summary) => {
    const icon = summary.failed > 0 ? 'FAIL' : 'PASS'
    return `${icon}: ${summary.passed}/${summary.results.length} passed in ${Math.round(summary.duration)}ms`
  }
})

Programmatic Filtering

Filter tests by name when running directly (without the CLI):

import { setGrep } from '@igorjs/pure-test'
setGrep('auth')            // string (treated as regex)
setGrep(/User.*login/i)    // RegExp

Matches against the full hierarchical test name (describe > test).

Test Isolation

Pure Test does not isolate tests. This is intentional.

Worker-per-file isolation (like Jest) costs ~50ms per file. For a project with 100 test files, that's 5 seconds of overhead before any test runs.

Instead, use the tools provided:

describe('database', () => {
  let db

  beforeEach(() => {
    db = createTestDb()    // fresh state per test
  })

  afterEach(() => {
    db.close()             // cleanup per test
  })

  it('inserts', () => {
    db.insert({ id: 1 })
    expect(db.count()).toBe(1)
  })

  it('starts empty', () => {
    expect(db.count()).toBe(0) // not affected by previous test
  })
})

If your tests have race conditions in sequential mode, that's a bug in the tests. describe.concurrent is opt-in: you explicitly take responsibility for independence.

Migration Guides

Already using Jest or Vitest? Step-by-step porting guides with before/after examples:

Mock instance methods (mockReturnValue, mockImplementation, mock.calls, etc.) are API-compatible. Most tests only need an import change.

Using on Deno

Pure Test ships two entry points for Deno consumers:

Entry Runtime command When to use
@igorjs/pure-test deno run --allow-all tests/foo.test.ts Default. Auto-runs in Pure Test's own runner. Get --grep, --bail, it.only, retries, custom reporters.
@igorjs/pure-test/deno deno test tests/foo.test.ts Each describe/it registers as a Deno.test() call so deno test, deno test --filter, IDE run/debug code lenses, and per-test permission sandboxes work.

Pick the adapter when you want the native deno test workflow or IDE buttons. Pick the default when you want every Pure Test feature (the adapter trades some features for native Deno integration).

// Default — pure-test owns scheduling, supports the full feature set.
import { describe, it, expect } from '@igorjs/pure-test'

// Adapter — each test becomes a Deno.test, runs via `deno test`.
import { describe, it, expect } from '@igorjs/pure-test/deno'

describe('math', () => {
  it('adds', () => { expect(1 + 1).toBe(2) })
})

Adapter supports: describe, it (top-level and nested), expect and all matchers, spyFn / spyOn, useFakeTimers and timer control, beforeAll / afterAll / beforeEach / afterEach (with parent → child inheritance).

Adapter does not support (use the default entry instead): it.only / it.skip / it.todo, describe.only / describe.skip / describe.concurrent, per-test timeout / retry, it.each, --grep, --bail, custom reporters. Hooks must be inside a describe() (top-level hooks throw with a clear message).

For filtering inside the adapter, use Deno's own flags:

deno test tests/                          # discover and run all tests
deno test --filter "auth" tests/          # filter by top-level Deno.test name
deno test --allow-read tests/foo.test.ts  # single file

deno test --filter matches against top-level Deno.test names only. In adapter terms: it filters by describe name, not by nested it names. This is Deno's native behaviour, shared with @std/testing/bdd.

Why Pure Test

Pure Test Jest Vitest
50 tests (same suite) ~57ms ~762ms ~559ms
Startup (1 test) ~50ms ~880ms ~660ms
Install footprint 0 packages ~194 packages ~35 packages
Config needed No Yes Yes
Node.js Yes Yes Yes
Deno Yes No Experimental
Bun Yes Partial Partial
Workers/Browser Yes No No
Transforms needed No Yes (babel/SWC) Yes (esbuild/SWC)
Mock API vi + jest + individual Jest API vi namespace

Benchmarks: Apple M-series, Node 25, identical 50-test suite across all three runners, measured with performance.now() over 7 runs (median). Jest 30.2, Vitest 4.1.

Feature Comparison

What Pure Test supports

These features work the same way across all three frameworks. If you're using them in Jest or Vitest, they'll work in Pure Test with minimal changes.

Feature Pure Test Jest Vitest
describe / it / test Yes Yes Yes
describe.concurrent Yes No Yes
describe.skip / it.skip Yes Yes Yes
describe.only / it.only Yes Yes Yes
it.todo Yes Yes Yes
it.each (parameterised tests) Yes Yes Yes
useFakeTimers / useRealTimers Yes Yes Yes
advanceTimersByTime / runAllTimers Yes Yes Yes
beforeAll / afterAll Yes Yes Yes
beforeEach / afterEach Yes Yes Yes
expect().toBe() Yes Yes Yes
expect().toEqual() Yes Yes Yes
expect().toBeTruthy/Falsy() Yes Yes Yes
expect().toBeNull/Undefined/Defined() Yes Yes Yes
expect().toBeInstanceOf() Yes Yes Yes
expect().toBeNaN() Yes Yes Yes
expect().toBeTypeOf() Yes No Yes
expect().toSatisfy() Yes No Yes
expect().toBeEmail() Yes No No
expect().toBeUUID(version?) (RFC 9562) Yes No No
expect().toBeGreaterThan() and friends Yes Yes Yes
expect().toBeCloseTo() Yes Yes Yes
expect().toContain() / toContainEqual() Yes Yes Yes
expect().toMatch() Yes Yes Yes
expect().toMatchArray() Yes No No
expect().toMatchUnsortedArray() Yes No No
expect().toHaveLength() Yes Yes Yes
expect().toMatchObject() / toHaveProperty() Yes Yes Yes
expect().toStrictEqual() Yes Yes Yes
expect().toThrow() Yes Yes Yes
expect().toHaveBeenCalled/Times/With() Yes Yes Yes
expect().toHaveBeenLastCalledWith() Yes Yes Yes
expect().toHaveBeenNthCalledWith() Yes Yes Yes
expect().toHaveReturned/Times/With() Yes Yes Yes
expect().toHaveLastReturnedWith() Yes Yes Yes
expect().toHaveNthReturnedWith() Yes Yes Yes
expect.any() / asymmetric matchers Yes Yes Yes
expect.not.* asymmetric matchers Yes Yes Yes
expect.closeTo() Yes Yes Yes
expect.email() / expect.uuid(version?) Yes No No
expect.assertions() / expect.hasAssertions() Yes Yes Yes
expect.extend() (custom matchers) Yes Yes Yes
.not / .resolves / .rejects modifiers Yes Yes Yes
spyOn(obj, 'prop', 'get'|'set') Yes Yes Yes
Test timeout it('name', fn, 5000) Yes Yes Yes
Test retry it('name', fn, { retry: 3 }) Yes Yes (via jest.retryTimes) Yes
spyFn() / fn() / vi.fn() Yes Yes Yes
spyOn() Yes Yes Yes
mockReturnValue / mockReturnValueOnce Yes Yes Yes
mockImplementation / mockImplementationOnce Yes Yes Yes
mockResolvedValue / mockRejectedValue Yes Yes Yes
mockThrow / mockThrowOnce Yes No Yes (v4.1+)
mockReturnThis Yes Yes Yes
mockClear / mockReset / mockRestore Yes Yes Yes
mock.calls / mock.results / mock.lastCall Yes Yes Yes
mockDeep() Yes Via jest-mock-extended No
stubEnv() / stubGlobal() (auto-restored) Yes No Yes
--clearMocks / --resetMocks / --restoreMocks (CLI auto-reset) Yes Yes Yes
5 built-in reporters (spec, verbose, tap, json, minimal) Yes Via packages Via packages
Custom reporters via Reporter.format() and streaming onResult() Yes Via packages Via packages
Async test support Yes Yes Yes
--grep / -t / --testNamePattern (test name filter) Yes Yes Yes
--testPathPattern <regex> (test file filter) Yes Yes Yes
--testTimeout <ms> (default test timeout) Yes Yes Yes
--shard <i>/<n> (CI parallelism) Yes Yes Yes
--bail (stop on first failure) Yes Yes Yes
--watch (re-run on file change, cross-runtime) Yes Yes Yes
--runInBand (force serial execution) Yes Yes Yes
--passWithNoTests (CI-friendly empty-suite exit) Yes Yes Yes
--listTests (print discovered files) Yes Yes Yes
--force-exit (kill hanging open handles) Yes Yes Yes
--verbose / -v (streaming per-test output) Yes Yes Yes
Parallel test file imports (default) Yes No (workers) Partial
NO_COLOR env support Yes Yes Yes

What Pure Test will never support

These features are intentionally excluded. Each one conflicts with our philosophy of zero dependencies, no transforms, no magic, and cross-runtime compatibility.

Feature Jest Vitest Why we skip it
Module mocking (jest.mock / vi.mock) Yes Yes Requires transform hooks that intercept import statements at compile time. This is runtime-specific magic: Jest uses babel, Vitest uses Vite. There's no cross-runtime way to do it. Use dependency injection instead: pass dependencies as parameters, mock at the call site.
Hoisted mocks (vi.hoisted) No Yes Only works with Vite's transform pipeline. The concept doesn't exist without a bundler.
Snapshot testing Yes Yes Requires file I/O to read/write .snap files, which isn't available in Workers or browsers. Snapshots are also brittle: they pass when they shouldn't (accepting wrong output) and fail when they shouldn't (formatting changes). Write explicit assertions that document what you expect.
Built-in coverage (--coverage) Yes Yes Requires V8 or Istanbul instrumentation which is deeply runtime-specific. Use c8 for Node, deno coverage for Deno, or bun test --coverage for Bun. Separating coverage from the test runner is better architecture. The repo ships per-runtime c8 configs (.c8rc.node.json, .c8rc.deno.json) and a coverage:all script as a reference example.
Browser environments (jsdom, happy-dom) Yes Yes Simulated DOM is a Node-only concept. Pure Test runs in real browsers via Playwright. Test browser code in a real browser, not a simulation.
Worker/thread isolation Yes Yes Costs ~50ms per file in process creation overhead. For 100 test files, that's 5 seconds before any test runs. Use beforeEach/afterEach for test isolation. If your tests need process isolation, your tests have a design problem.
Config files Yes (jest.config) Yes (vitest.config) Config parsing adds startup overhead and a new thing to learn. CLI args cover everything. If you need project-specific settings, use npm scripts.
Benchmark mode No Yes (bench()) Benchmarking is a different tool. Use mitata or tinybench. Mixing tests and benchmarks in one runner conflates two concerns.

Contributing

See CONTRIBUTING.md for development setup, coding standards, and how to submit changes.

Disclaimer

THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

License

Apache-2.0

About

Minimal cross-runtime test runner. Zero dependencies. Works on Node.js, Deno, Bun, Workers, and browsers.

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Sponsor this project

Packages

 
 
 

Contributors