Testing Atoms
Best practices and patterns for unit testing atoms and components that depend on atomic state
Overview
Testing atoms effectively requires understanding how to create isolated registry instances, provide initial values, and properly clean up after tests. The @effect/atom library provides several mechanisms specifically designed to make testing straightforward and reliable.
This guide covers strategies for unit testing atoms, testing components that depend on atomic state, and handling asynchronous atom behaviors in test environments.
Key Concepts
Test Isolation with Fresh Registries
The most important principle when testing atoms is isolation. Each test should have its own Registry instance to prevent state leakage between tests. The Registry.make() constructor creates a fresh registry that can be configured specifically for testing needs.
Initial Values for Deterministic Tests
Atoms can be pre-populated with specific values using the initialValues option, allowing you to set up precise test scenarios without triggering atom computations.
Controlled Task Scheduling
For testing asynchronous behaviors, you can provide a custom scheduleTask function to control when deferred updates occur.
Creating Test Registries
Basic Test Registry
Create a fresh registry for each test to ensure complete isolation:
import * as Registry from "@effect/atom/Registry"
import * as Atom from "@effect/atom/Atom"
describe("Counter Atom", () => {
let registry: Registry.Registry
beforeEach(() => {
registry = Registry.make()
})
afterEach(() => {
registry.dispose()
})
it("should initialize with default value", () => {
const counterAtom = Atom.make(0)
expect(registry.get(counterAtom)).toBe(0)
})
})
Pre-populating State with Initial Values
Use the initialValues option to set up specific test scenarios:
import * as Registry from "@effect/atom/Registry"
import * as Atom from "@effect/atom/Atom"
const userAtom = Atom.make<User | null>(null)
const settingsAtom = Atom.make({ theme: "light", notifications: true })
describe("User Settings", () => {
it("should handle logged-in user scenario", () => {
const testUser: User = { id: "123", name: "Test User" }
const registry = Registry.make({
initialValues: [
[userAtom, testUser],
[settingsAtom, { theme: "dark", notifications: false }]
]
})
expect(registry.get(userAtom)).toEqual(testUser)
expect(registry.get(settingsAtom).theme).toBe("dark")
registry.dispose()
})
})
Testing Derived Atoms
Derived atoms that compute values from other atoms can be tested by setting up their dependencies:
import * as Registry from "@effect/atom/Registry"
import * as Atom from "@effect/atom/Atom"
const priceAtom = Atom.make(100)
const quantityAtom = Atom.make(2)
const totalAtom = Atom.derived((get) => get(priceAtom) * get(quantityAtom))
describe("Total Calculation", () => {
it("should compute total from price and quantity", () => {
const registry = Registry.make({
initialValues: [
[priceAtom, 50],
[quantityAtom, 3]
]
})
expect(registry.get(totalAtom)).toBe(150)
registry.dispose()
})
it("should update when dependencies change", () => {
const registry = Registry.make()
expect(registry.get(totalAtom)).toBe(200) // 100 * 2
registry.set(quantityAtom, 5)
expect(registry.get(totalAtom)).toBe(500) // 100 * 5
registry.dispose()
})
})
Testing Subscriptions
Test that atoms properly notify subscribers when values change:
import * as Registry from "@effect/atom/Registry"
import * as Atom from "@effect/atom/Atom"
const countAtom = Atom.make(0)
describe("Atom Subscriptions", () => {
it("should notify subscribers on change", () => {
const registry = Registry.make()
const values: number[] = []
const unsubscribe = registry.subscribe(
countAtom,
(value) => values.push(value),
{ immediate: true }
)
registry.set(countAtom, 1)
registry.set(countAtom, 2)
registry.set(countAtom, 3)
expect(values).toEqual([0, 1, 2, 3])
unsubscribe()
registry.dispose()
})
it("should stop notifying after unsubscribe", () => {
const registry = Registry.make()
const values: number[] = []
const unsubscribe = registry.subscribe(
countAtom,
(value) => values.push(value)
)
registry.set(countAtom, 1)
unsubscribe()
registry.set(countAtom, 2)
expect(values).toEqual([1])
registry.dispose()
})
})
Testing with Effect Layer
For Effect-based applications, use layerOptions to create a properly scoped registry:
import * as Registry from "@effect/atom/Registry"
import * as Atom from "@effect/atom/Atom"
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
const testAtom = Atom.make("initial")
describe("Effect Integration", () => {
it("should work with Effect runtime", async () => {
const testLayer = Registry.layerOptions({
initialValues: [[testAtom, "test value"]]
})
const program = Effect.gen(function* () {
const registry = yield* Registry.AtomRegistry
return registry.get(testAtom)
})
const result = await Effect.runPromise(
program.pipe(Effect.provide(testLayer))
)
expect(result).toBe("test value")
})
})
Testing Async Atoms with Controlled Scheduling
Control when scheduled tasks execute for deterministic async testing:
import * as Registry from "@effect/atom/Registry"
import * as Atom from "@effect/atom/Atom"
describe("Async Atom Behavior", () => {
it("should allow controlled task scheduling", () => {
const pendingTasks: Array<() => void> = []
const registry = Registry.make({
scheduleTask: (f) => pendingTasks.push(f)
})
const asyncAtom = Atom.make(0)
// Perform operations that schedule tasks
registry.set(asyncAtom, 1)
// Manually flush pending tasks
while (pendingTasks.length > 0) {
const task = pendingTasks.shift()!
task()
}
expect(registry.get(asyncAtom)).toBe(1)
registry.dispose()
})
})
Testing Registry Reset
The reset() method allows testing state restoration:
import * as Registry from "@effect/atom/Registry"
import * as Atom from "@effect/atom/Atom"
const stateAtom = Atom.make({ count: 0, name: "" })
describe("Registry Reset", () => {
it("should reset all atoms to initial values", () => {
const registry = Registry.make()
registry.set(stateAtom, { count: 10, name: "modified" })
expect(registry.get(stateAtom).count).toBe(10)
registry.reset()
expect(registry.get(stateAtom)).toEqual({ count: 0, name: "" })
registry.dispose()
})
})
Best Practices
- Always dispose registries - Call
registry.dispose()inafterEachto prevent memory leaks - Use
initialValuesfor complex setups - Avoid multiplesetcalls by pre-populating state - Test subscriptions with
immediate: true- Capture the initial value to verify starting state - Isolate tests completely - Never share registry instances between test cases
- Use custom schedulers for async control - Collect scheduled tasks and flush them manually for deterministic testing
- Test derived atoms through their dependencies - Set up source atoms and verify computed results