test
utilityInstallation
npx jsrepo add github/reatom/reusables test Dependencies
-
vitest^4.0.18
Copy the source code below and save it to the specified file path in your project.
import { type Mock, test as viTest, vi, beforeAll } from 'vitest'
import type { Action, AtomLike, Unsubscribe } from '@reatom/core'
import { clearStack, context, isAction, noop, notify, top } from '@reatom/core'
beforeAll(() => {
clearStack()
})
/**
* Silences unhandled errors in Reatom's queues to prevent console noise during
* tests that intentionally check for errors. This function overrides the
* default queue push behavior to catch and ignore any errors thrown within
* queue callbacks.
*
* @example
* import { test, silentQueuesErrors } from 'test'
*
* test('handles errors silently', () => {
* silentQueuesErrors()
* // Now errors in Reatom queues won't log to console
* })
*/
export const silentQueuesErrors = () => {
top().root.pushQueue = function pushQueue(cb, queue) {
this[queue].push(async () => {
try {
await cb()
} catch {
// nothing
}
})
}
}
/**
* Enhanced version of Vitest's test function that automatically wraps test
* callbacks in Reatom's context to ensure proper atom tracking and execution
* within Reatom's reactive system.
*
* This wrapper preserves all functionality from Vitest while adding
* Reatom-specific context handling, which prevents "missed context" errors when
* testing Reatom atoms and actions.
*
* > **NOTE**: the test methods (`test.skip`, `test.each` and so on) are not
* > supported, use `viTest` export for this cases (`viTest.skip`, `viTest.each`
* > and so on)
*
* @example
* import { test, expect } from 'test'
* import { atom } from '@reatom/core'
*
* test('atom updates correctly', () => {
* const counter = atom(0, 'counter')
* counter.set(5)
* expect(counter()).toBe(5)
* })
*
* @param name - The name of the test case
* @param fn - The test function to execute within Reatom context
* @returns The result of the Vitest test execution
*/
// Create the wrapped test function
const test = ((name: string, fn: () => void | Promise<void>) =>
viTest(name, () => {
Reflect.defineProperty(fn, 'name', { value: name })
return context.start(fn)
})) as typeof viTest
export { test, viTest, notify }
/**
* Creates a mock subscriber for an atom or action that tracks all updates using
* Vitest's mock functionality.
*
* For atoms, the mock is called with each new state value. For actions, the
* mock is called with the action's params and returns the payload, making it
* easy to test action results.
*
* @example
* import { test, expect, subscribe } from 'test'
* import { atom, action } from '@reatom/core'
*
* test('subscribe captures atom updates', () => {
* const counter = atom(0, 'counter')
* const sub = subscribe(counter)
*
* counter.set(1)
* counter.set(2)
*
* expect(sub).toHaveBeenCalledTimes(3) // Initial + 2 updates
* expect(sub).toHaveBeenLastCalledWith(2)
*
* sub.unsubscribe()
* })
*
* @example
* test('subscribe captures action calls', () => {
* const doSomething = action((x: number) => x * 2, 'doSomething')
* const sub = subscribe(doSomething)
*
* doSomething(5)
*
* expect(sub).toHaveBeenCalledWith(5)
* expect(sub).toHaveLastReturnedWith(10)
*
* sub.unsubscribe()
* })
*/
export function subscribe<Params extends any[], Payload>(
target: Action<Params, Payload>,
cb?: (...params: Params) => Payload,
): Mock<(...params: Params) => Payload> & { unsubscribe: Unsubscribe }
export function subscribe<State, T extends (state: State) => any>(
target: AtomLike<State>,
cb?: T,
): Mock<T> & { unsubscribe: Unsubscribe }
export function subscribe(
target: AtomLike,
cb: (...args: any[]) => any = noop,
): Mock & { unsubscribe: Unsubscribe } {
const mock = vi.fn(cb)
if (isAction(target)) {
const unsubscribe = target.subscribe((calls) => {
for (const { params, payload } of calls) {
mock.mockReturnValueOnce(payload)
mock(...params)
}
})
return Object.assign(mock, { unsubscribe })
}
const unsubscribe = target.subscribe(mock as any)
return Object.assign(mock, { unsubscribe })
}
/**
* Re-exports from Vitest for convenient testing.
*
* These exports provide all standard Vitest testing utilities while ensuring
* compatibility with Reatom's testing utilities defined in this file.
*/
export {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
expectTypeOf,
vi,
} from 'vitest'
/**
* Re-exports all type definitions from Vitest.
*
* This ensures that Vitest types are available when importing from 'test'.
*/
export type * from 'vitest' Documentation
Test Harness
A reusable testing utilities on top of Vitest API for Reatom applications that provides enhanced testing capabilities with automatic context management and subscription mocking.
Note: This reusable is built on top of Vitest, but you may choose to rewrite it to work with other test runners.
Usage
After installation, import the test utilities:
import { test, expect, subscribe } from './src/reatom/test'
Or use an alias (see Configuration section):
import { test, expect, subscribe } from 'test'
Features
- Automatic Context Management: Test functions are automatically wrapped in
context.start(), eliminating "missed context" errors - Context Isolation: Each test runs in a fresh context, preventing state leakage between tests
- Mock Subscriptions: Track atom updates and action calls using Vitest's mock functionality
- Full Vitest Compatibility: All standard Vitest utilities are re-exported for convenience
API Reference
test()
Enhanced version of Vitest's test function that automatically wraps test callbacks in Reatom's context.
NOTE: the test methods (
test.skip,test.eachand so on) are not supported, useviTestexport for this cases (viTest.skip,viTest.eachand so on)
Signature:
test(name: string, fn: () => void | Promise<void>): void
Example:
import { test, expect } from 'test'
import { atom } from '@reatom/core'
test('atom updates correctly', () => {
const counter = atom(0, 'counter')
counter.set(5)
expect(counter()).toBe(5)
})
Key Benefits:
- No need to manually call
context.start() - Automatic context isolation between tests
- Works with both sync and async tests
- Preserves all Vitest functionality
subscribe()
Creates a mock subscriber for an atom or action that tracks all updates using Vitest's mock functionality.
For atoms, the mock is called with each new state value.
For actions, the mock is called with the action's params (not the ActionState array) and returns the payload, making it easy to test action results.
Signatures:
// For actions — mock receives params, returns payload
subscribe<Params extends any[], Payload>(
target: Action<Params, Payload>,
cb?: (...params: Params) => Payload
): Mock<(...params: Params) => Payload> & { unsubscribe: Unsubscribe }
// For atoms — mock receives state
subscribe<State, T extends (state: State) => any>(
target: AtomLike<State>,
cb?: T
): Mock<T> & { unsubscribe: Unsubscribe }
Parameters:
target: A Reatom atom, computed value, or action to subscribe tocb: Optional callback function to execute on each update (defaults tonoop)
Returns:
A Vitest mock function with an attached unsubscribe method
Atom example
import { test, expect, subscribe } from 'test'
import { atom, sleep, wrap } from '@reatom/core'
test('subscribe captures batched updates', async () => {
const counter = atom(0, 'counter')
const sub = subscribe(counter)
counter.set(1)
await wrap(sleep())
counter.set(2)
await wrap(sleep())
counter.set(3)
counter.set(4)
await wrap(sleep())
expect(sub).toHaveBeenCalledTimes(4)
expect(sub).toHaveBeenLastCalledWith(4)
expect(sub.mock.calls).toEqual([[0], [1], [2], [4]])
sub.unsubscribe()
})
Atom with custom callback
test('subscribe with custom callback', () => {
const counter = atom(0, 'counter')
const results: number[] = []
const sub = subscribe(counter, (val) => {
results.push(val * 2)
})
counter.set(5)
counter.set(10)
expect(results).toEqual([0, 10, 20])
sub.unsubscribe()
})
Action example
import { test, expect, subscribe } from 'test'
import { action } from '@reatom/core'
test('subscribe tracks action calls with params and payload', () => {
const double = action((x: number) => x * 2, 'double')
const sub = subscribe(double)
double(5)
double(10)
expect(sub).toHaveBeenCalledTimes(2)
expect(sub).toHaveBeenCalledWith(5)
expect(sub).toHaveBeenLastCalledWith(10)
expect(sub).toHaveLastReturnedWith(20)
expect(sub.mock.results).toEqual([
{ type: 'return', value: 10 },
{ type: 'return', value: 20 },
])
sub.unsubscribe()
})
Action with multiple params
test('subscribe tracks multi-param actions', () => {
const add = action((a: number, b: number) => a + b, 'add')
const sub = subscribe(add)
add(2, 3)
expect(sub).toHaveBeenCalledWith(2, 3)
expect(sub).toHaveLastReturnedWith(5)
sub.unsubscribe()
})
silentQueuesErrors()
Silences unhandled errors in Reatom's queues to prevent console noise during tests that intentionally check for errors. This function overrides the default queue push behavior to catch and ignore any errors thrown within queue callbacks.
Signature:
silentQueuesErrors(): void
Example:
import { test, silentQueuesErrors } from 'test'
test('handles errors silently', () => {
silentQueuesErrors()
// Now errors in Reatom queues won't log to console
})
Re-exported Utilities
The following Vitest utilities are re-exported for convenience:
expectdescribebeforeEachafterEachbeforeAllafterAllviexpectTypeOf- original
testis aliased asviTest - All Vitest types
Usage Patterns
Basic Atom Testing
import { test, expect } from 'test'
import { atom } from '@reatom/core'
test('atom initialization', () => {
const counter = atom(0, 'counter')
expect(counter()).toBe(0)
})
test('atom updates', () => {
const counter = atom(0, 'counter')
counter.set(5)
expect(counter()).toBe(5)
})
Computed Atom Testing
import { test, expect, subscribe } from 'test'
import { atom, computed } from '@reatom/core'
test('computed atom updates', () => {
const counter = atom(0, 'counter')
const doubled = computed(() => counter() * 2, 'doubled')
const sub = subscribe(doubled)
expect(doubled()).toBe(0)
counter.set(5)
expect(doubled()).toBe(10)
expect(sub).toHaveBeenLastCalledWith(10)
sub.unsubscribe()
})
Testing Update Sequences
import { test, expect, subscribe } from 'test'
import { atom } from '@reatom/core'
test('tracks all updates in order', () => {
const counter = atom(0, 'counter')
const sub = subscribe(counter)
counter.set(1)
counter.set(2)
counter.set(3)
expect(sub.mock.calls).toEqual([[0], [1], [2], [3]])
sub.unsubscribe()
})
Action Testing
import { test, expect, subscribe } from 'test'
import { action } from '@reatom/core'
test('action payload tracking', () => {
const fetchUser = action((id: string) => ({ id, name: 'User' }), 'fetchUser')
const sub = subscribe(fetchUser)
const result = fetchUser('123')
// verify the action was called with correct params
expect(sub).toHaveBeenCalledWith('123')
// verify the returned payload
expect(sub).toHaveLastReturnedWith({ id: '123', name: 'User' })
sub.unsubscribe()
})
Async Testing
import { test, expect } from 'test'
import { atom } from '@reatom/core'
test('handles async operations', async () => {
const counter = atom(0, 'counter')
await Promise.resolve()
counter.set(10)
expect(counter()).toBe(10)
})
Migration from Standard Vitest
If you're migrating existing tests, the main changes are:
- Import
testfromtestinstead ofvitest - Remove manual
context.start()calls andclearContextcall (if used) - Remove manual
context.reset()frombeforeEachhooks (if used) - Use
subscribe()helper instead of manual atom subscription + vi.fn()
It your application entrypoint started with reatom routing system, you can run it just with the url atom reading -
urlAtom()
Configuration
To set up the test alias in your project (optional but recommended):
vitest.config.ts:
import { defineConfig } from 'vitest/config'
export default defineConfig({
resolve: {
alias: {
test: './src/reatom/test/test.ts',
},
},
})
tsconfig.json:
{
"compilerOptions": {
"paths": {
"test": ["./src/reatom/test/test.ts"]
}
}
}
Note: The exact path may vary depending on your jsrepo configuration. Adjust the paths above to match where jsrepo installed the files in your project.
Examples
The test harness is used throughout the @reatom/reusables project.
Why Use This Test Harness?
- Universal implementation: Easy to change Vitest to any other test runner
- Reduces Boilerplate: No need to manually manage context in every test
- Prevents Common Errors: Eliminates "missed context" errors that are common in Reatom testing
- Better Debugging: Mock subscriptions provide clear visibility into atom updates
- Isolation Guarantee: Each test runs in a fresh context, preventing flaky tests
- Follows Core Patterns: Aligned with testing patterns used in
@reatom/core