Testing Effector: Events, Stores, Effects, Sample, and Split with Jest

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-jest

Testing 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 header

These 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 sample guards by verifying what does and doesn't trigger the target
  • Test split routing 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.

Read more