Testing Effector: Events, Stores, Effects, Sample, and Split with Jest
Effector is a reactive state manager built on the event-sourcing model. Unlike Redux, Effector has no single store — instead, you compose granular units: events, stores, and effects. This makes testing extremely straightforward: each unit is independently testable with no boilerplate.
The key testing primitive is fork + allSettled, which gives you fully isolated test scopes without global state leaking between tests.
Setup
npm install effector effector-react
npm install -D jest @types/jest ts-jestTesting Events
Events in Effector are just callable functions that emit a typed payload. Test them by subscribing to see what's emitted:
// models/counter.ts
import { createEvent, createStore } from 'effector'
export const increment = createEvent()
export const decrement = createEvent()
export const reset = createEvent()
export const setCount = createEvent<number>()
export const $count = createStore(0)
.on(increment, count => count + 1)
.on(decrement, count => count - 1)
.on(reset, () => 0)
.on(setCount, (_, value) => value)// models/counter.test.ts
import { createWatch, fork, allSettled } from 'effector'
import { increment, decrement, reset, setCount, $count } from './counter'
describe('counter events and store', () => {
it('increment increases count by 1', async () => {
const scope = fork()
await allSettled(increment, { scope })
expect(scope.getState($count)).toBe(1)
})
it('decrement decreases count by 1', async () => {
const scope = fork({ values: [[$count, 5]] })
await allSettled(decrement, { scope })
expect(scope.getState($count)).toBe(4)
})
it('reset sets count to 0 regardless of current value', async () => {
const scope = fork({ values: [[$count, 100]] })
await allSettled(reset, { scope })
expect(scope.getState($count)).toBe(0)
})
it('setCount sets count to the provided value', async () => {
const scope = fork()
await allSettled(setCount, { scope, params: 42 })
expect(scope.getState($count)).toBe(42)
})
it('consecutive events accumulate correctly', async () => {
const scope = fork()
await allSettled(increment, { scope })
await allSettled(increment, { scope })
await allSettled(decrement, { scope })
expect(scope.getState($count)).toBe(1)
})
})fork() creates an isolated scope. allSettled runs an event and waits for all reactive computations to settle. Tests never share state because each has its own scope.
Testing Stores
Stores are derived from events. Test them with initial values set via fork({ values: [...] }):
// models/userProfile.ts
import { createEvent, createStore, combine } from 'effector'
export const setName = createEvent<string>()
export const setEmail = createEvent<string>()
export const setAge = createEvent<number>()
export const clearProfile = createEvent()
export const $name = createStore('').on(setName, (_, v) => v).reset(clearProfile)
export const $email = createStore('').on(setEmail, (_, v) => v).reset(clearProfile)
export const $age = createStore(0).on(setAge, (_, v) => v).reset(clearProfile)
export const $profile = combine({
name: $name,
email: $email,
age: $age,
})
export const $isProfileComplete = $profile.map(
({ name, email, age }) => name.length > 0 && email.includes('@') && age >= 18
)// models/userProfile.test.ts
import { fork, allSettled } from 'effector'
import {
setName, setEmail, setAge, clearProfile,
$profile, $isProfileComplete,
} from './userProfile'
describe('$profile store', () => {
it('combines name, email, age into profile object', async () => {
const scope = fork()
await allSettled(setName, { scope, params: 'Alice' })
await allSettled(setEmail, { scope, params: 'alice@example.com' })
await allSettled(setAge, { scope, params: 25 })
expect(scope.getState($profile)).toEqual({
name: 'Alice',
email: 'alice@example.com',
age: 25,
})
})
it('clearProfile resets all fields', async () => {
const scope = fork({
values: [
[$profile, { name: 'Alice', email: 'alice@example.com', age: 25 }],
],
})
await allSettled(clearProfile, { scope })
expect(scope.getState($profile)).toEqual({ name: '', email: '', age: 0 })
})
})
describe('$isProfileComplete', () => {
it('returns false for empty profile', async () => {
const scope = fork()
expect(scope.getState($isProfileComplete)).toBe(false)
})
it('returns false when email has no @', async () => {
const scope = fork()
await allSettled(setName, { scope, params: 'Bob' })
await allSettled(setEmail, { scope, params: 'notanemail' })
await allSettled(setAge, { scope, params: 20 })
expect(scope.getState($isProfileComplete)).toBe(false)
})
it('returns false when age is under 18', async () => {
const scope = fork()
await allSettled(setName, { scope, params: 'Bob' })
await allSettled(setEmail, { scope, params: 'bob@test.com' })
await allSettled(setAge, { scope, params: 17 })
expect(scope.getState($isProfileComplete)).toBe(false)
})
it('returns true for valid complete profile', async () => {
const scope = fork()
await allSettled(setName, { scope, params: 'Carol' })
await allSettled(setEmail, { scope, params: 'carol@test.com' })
await allSettled(setAge, { scope, params: 30 })
expect(scope.getState($isProfileComplete)).toBe(true)
})
})Testing Effects
Effects wrap async operations and automatically emit three derived events: effect, effect.done, and effect.fail. Test all three paths:
// models/authModel.ts
import { createEffect, createStore, createEvent } from 'effector'
interface LoginParams {
email: string
password: string
}
interface User {
id: number
email: string
token: string
}
export async function loginRequest(params: LoginParams): Promise<User> {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
})
if (!response.ok) throw new Error('Invalid credentials')
return response.json()
}
export const loginFx = createEffect(loginRequest)
export const logout = createEvent()
export const $user = createStore<User | null>(null)
.on(loginFx.done, (_, { result }) => result)
.reset(logout)
export const $loginError = createStore<string | null>(null)
.on(loginFx.fail, (_, { error }) => error.message)
.reset(loginFx)
export const $isLoading = loginFx.pending// models/authModel.test.ts
import { fork, allSettled } from 'effector'
import { loginFx, logout, $user, $loginError, $isLoading } from './authModel'
const mockUser = { id: 1, email: 'alice@example.com', token: 'tok123' }
describe('loginFx effect', () => {
it('stores user on successful login', async () => {
const scope = fork({
handlers: [
[loginFx, async () => mockUser],
],
})
await allSettled(loginFx, {
scope,
params: { email: 'alice@example.com', password: 'secret' },
})
expect(scope.getState($user)).toEqual(mockUser)
expect(scope.getState($loginError)).toBeNull()
})
it('stores error message on failed login', async () => {
const scope = fork({
handlers: [
[loginFx, async () => { throw new Error('Invalid credentials') }],
],
})
await allSettled(loginFx, {
scope,
params: { email: 'bad@example.com', password: 'wrong' },
})
expect(scope.getState($user)).toBeNull()
expect(scope.getState($loginError)).toBe('Invalid credentials')
})
it('clears error on new login attempt', async () => {
const scope = fork({
values: [[$loginError, 'previous error']],
handlers: [
[loginFx, async () => mockUser],
],
})
await allSettled(loginFx, {
scope,
params: { email: 'alice@example.com', password: 'secret' },
})
expect(scope.getState($loginError)).toBeNull()
})
it('clears user on logout', async () => {
const scope = fork({
values: [[$user, mockUser]],
})
await allSettled(logout, { scope })
expect(scope.getState($user)).toBeNull()
})
})The handlers option in fork lets you replace effect implementations per-test. No global mocking, no jest.spyOn — just pass a replacement handler.
Testing sample
sample is Effector's core operator for connecting units reactively. It fires a target when a clock triggers and a guard is satisfied:
// models/searchModel.ts
import { createEvent, createStore, createEffect, sample } from 'effector'
export const queryChanged = createEvent<string>()
export const searchFx = createEffect(async (query: string) => {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
return response.json()
})
export const $query = createStore('').on(queryChanged, (_, v) => v)
export const $results = createStore<string[]>([]).on(
searchFx.doneData,
(_, results) => results
)
// Only search when query is at least 3 characters
sample({
clock: queryChanged,
source: $query,
filter: query => query.length >= 3,
target: searchFx,
})// models/searchModel.test.ts
import { fork, allSettled } from 'effector'
import { queryChanged, searchFx, $query, $results } from './searchModel'
const mockResults = ['apple', 'application', 'apply']
describe('search sample logic', () => {
it('does not trigger searchFx for queries under 3 chars', async () => {
let callCount = 0
const scope = fork({
handlers: [
[searchFx, async (q: string) => { callCount++; return [] }],
],
})
await allSettled(queryChanged, { scope, params: 'ab' })
expect(callCount).toBe(0)
expect(scope.getState($results)).toHaveLength(0)
})
it('triggers searchFx for queries of 3+ chars', async () => {
const scope = fork({
handlers: [
[searchFx, async () => mockResults],
],
})
await allSettled(queryChanged, { scope, params: 'app' })
expect(scope.getState($results)).toEqual(mockResults)
})
it('queries stay in $query store regardless of filter', async () => {
const scope = fork({
handlers: [[searchFx, async () => []]],
})
await allSettled(queryChanged, { scope, params: 'a' })
expect(scope.getState($query)).toBe('a')
})
})Testing split
split routes events to different targets based on conditions:
// models/notificationModel.ts
import { createEvent, split, createStore } from 'effector'
export interface Notification {
type: 'success' | 'error' | 'info'
message: string
}
export const notificationReceived = createEvent<Notification>()
export const { successNotification, errorNotification, infoNotification } = split(
notificationReceived,
{
successNotification: n => n.type === 'success',
errorNotification: n => n.type === 'error',
infoNotification: n => n.type === 'info',
}
)
export const $successCount = createStore(0).on(successNotification, c => c + 1)
export const $errorCount = createStore(0).on(errorNotification, c => c + 1)// models/notificationModel.test.ts
import { fork, allSettled } from 'effector'
import {
notificationReceived,
$successCount,
$errorCount,
} from './notificationModel'
describe('split — notification routing', () => {
it('routes success notifications to $successCount', async () => {
const scope = fork()
await allSettled(notificationReceived, {
scope,
params: { type: 'success', message: 'Saved!' },
})
expect(scope.getState($successCount)).toBe(1)
expect(scope.getState($errorCount)).toBe(0)
})
it('routes error notifications to $errorCount', async () => {
const scope = fork()
await allSettled(notificationReceived, {
scope,
params: { type: 'error', message: 'Failed!' },
})
expect(scope.getState($errorCount)).toBe(1)
expect(scope.getState($successCount)).toBe(0)
})
it('accumulates counts across multiple notifications', async () => {
const scope = fork()
await allSettled(notificationReceived, {
scope,
params: { type: 'success', message: 'OK' },
})
await allSettled(notificationReceived, {
scope,
params: { type: 'success', message: 'Also OK' },
})
await allSettled(notificationReceived, {
scope,
params: { type: 'error', message: 'Error' },
})
expect(scope.getState($successCount)).toBe(2)
expect(scope.getState($errorCount)).toBe(1)
})
})Testing with React
Effector works with React via effector-react. Test components with Provider from effector-react/scope:
// components/Counter.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { fork, allSettled } from 'effector'
import { Provider } from 'effector-react/scope'
import Counter from './Counter'
import { $count, increment, decrement } from '../models/counter'
describe('Counter component', () => {
it('renders current count', async () => {
const scope = fork({ values: [[$count, 5]] })
render(
<Provider value={scope}>
<Counter />
</Provider>
)
expect(screen.getByText('5')).toBeInTheDocument()
})
it('increments when + button clicked', async () => {
const scope = fork({ values: [[$count, 0]] })
render(
<Provider value={scope}>
<Counter />
</Provider>
)
fireEvent.click(screen.getByText('+'))
expect(scope.getState($count)).toBe(1)
})
})Why fork() Is Better Than Global State
Without fork, Effector units hold global state that persists between tests:
// BAD — global state leaks between tests
test('first test', async () => {
await allSettled(increment) // global $count is now 1
})
test('second test', async () => {
// $count starts at 1, not 0 — tests are coupled!
await allSettled(increment)
expect($count.getState()).toBe(1) // fails, it's 2
})
// GOOD — each test has its own scope
test('first test', async () => {
const scope = fork()
await allSettled(increment, { scope }) // scope.$count = 1
}) // scope is garbage collected
test('second test', async () => {
const scope = fork() // fresh scope, $count = 0
await allSettled(increment, { scope })
expect(scope.getState($count)).toBe(1) // passes
})Always use fork in tests.
End-to-End Validation with HelpMeTest
Effector unit tests confirm your reactive logic is correct, but verifying the full user flow — form interactions, async loading states, error messages appearing and dismissing — requires a browser. HelpMeTest lets you write those tests in plain English:
Go to /login
Enter email "user@example.com"
Enter password "wrongpassword"
Click "Sign In"
Verify error message "Invalid credentials" appears
Enter correct password "correct123"
Click "Sign In"
Verify redirect to /dashboard
Verify username "user@example.com" appears in headerThese tests run against your actual deployed app and catch component wiring bugs that even well-tested Effector models can't prevent.
Summary
Effector testing best practices:
- Always use
fork()— never test against global unit state - Use
allSettled— it waits for all reactive chains to settle before asserting - Set initial values with
fork({ values: [[$store, value]] }) - Mock effect handlers with
fork({ handlers: [[fx, mockFn]] })— no global spies needed - Test
sampleguards by verifying what does and doesn't trigger the target - Test
splitrouting by checking which branch stores get updated
Effector's design makes testing a natural fit: units are independent, effects are injectable, and fork provides perfect isolation.