Testing Redux Saga: Effects, Channels, Error Paths, and runSaga

Testing Redux Saga: Effects, Channels, Error Paths, and runSaga

Redux Saga handles side effects (API calls, timers, WebSockets) as generator functions. Testing generators is conceptually simple — call .next() and check what the generator yielded — but in practice there are three distinct approaches to choose from: effect assertion, runSaga integration, and task mocking. Each has its place.

Setup

npm install redux-saga
npm install -D jest @types/jest ts-jest redux @types/redux

Understanding the Three Approaches

Approach What it tests Speed Realism
Effect assertion Each yield in isolation Fastest Low — no actual calls
runSaga Full saga with mocked store Medium High — saga runs for real
createMockTask Saga orchestration (fork/join) Fast Medium

Effect Assertion Testing

The simplest approach: iterate the generator manually and assert what each yield produces. No I/O happens.

// sagas/userSaga.ts
import { call, put, select, takeLatest, delay } from 'redux-saga/effects'
import { fetchUserSuccess, fetchUserFailure, setLoading } from '../slices/userSlice'

interface User {
  id: number
  name: string
  email: string
}

async function getUserById(id: number): Promise<User> {
  const response = await fetch(`/api/users/${id}`)
  if (!response.ok) throw new Error('User not found')
  return response.json()
}

export function* fetchUserSaga(action: { type: string; payload: number }) {
  try {
    yield put(setLoading(true))
    const user: User = yield call(getUserById, action.payload)
    yield put(fetchUserSuccess(user))
  } catch (error: any) {
    yield put(fetchUserFailure(error.message))
  } finally {
    yield put(setLoading(false))
  }
}

export function* watchUserSaga() {
  yield takeLatest('user/fetchUser', fetchUserSaga)
}
// sagas/userSaga.test.ts
import { call, put } from 'redux-saga/effects'
import { fetchUserSaga } from './userSaga'
import {
  fetchUserSuccess,
  fetchUserFailure,
  setLoading,
} from '../slices/userSlice'
import { getUserById } from '../api/userApi'

const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' }
const action = { type: 'user/fetchUser', payload: 1 }

describe('fetchUserSaga — effect assertions', () => {
  it('dispatches setLoading, calls API, dispatches success', () => {
    const gen = fetchUserSaga(action)

    // Step 1: put(setLoading(true))
    expect(gen.next().value).toEqual(put(setLoading(true)))

    // Step 2: call(getUserById, 1)
    expect(gen.next().value).toEqual(call(getUserById, 1))

    // Step 3: put(fetchUserSuccess(user)) — inject the mock return value
    expect(gen.next(mockUser).value).toEqual(put(fetchUserSuccess(mockUser)))

    // Step 4 (finally): put(setLoading(false))
    expect(gen.next().value).toEqual(put(setLoading(false)))

    // Generator is done
    expect(gen.next().done).toBe(true)
  })

  it('dispatches setLoading, then fetchUserFailure on API error', () => {
    const gen = fetchUserSaga(action)
    const error = new Error('User not found')

    // Step 1: put(setLoading(true))
    expect(gen.next().value).toEqual(put(setLoading(true)))

    // Step 2: call — throw an error into the generator
    expect(gen.next().value).toEqual(call(getUserById, 1))
    expect(gen.throw(error).value).toEqual(put(fetchUserFailure('User not found')))

    // finally: put(setLoading(false))
    expect(gen.next().value).toEqual(put(setLoading(false)))

    expect(gen.next().done).toBe(true)
  })
})

Effect assertions are fast but brittle — they encode every yield in order. Add a yield delay(...) to the saga and every assertion after it shifts. Use this approach for critical sagas where you need exact control.

runSaga Integration Testing

runSaga executes a saga against a real (or mock) Redux store. It's better for testing end-to-end behavior without caring about internal yield order.

// sagas/userSaga.runSaga.test.ts
import { runSaga } from 'redux-saga'
import { fetchUserSaga } from './userSaga'
import * as userApi from '../api/userApi'

const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' }

describe('fetchUserSaga — runSaga integration', () => {
  const dispatched: any[] = []

  const store = {
    getState: () => ({ user: { data: null, loading: false, error: null } }),
    dispatch: (action: any) => dispatched.push(action),
  }

  beforeEach(() => dispatched.splice(0))

  it('dispatches loading, success, then loading off on happy path', async () => {
    jest.spyOn(userApi, 'getUserById').mockResolvedValueOnce(mockUser)

    await runSaga(store, fetchUserSaga, { type: 'user/fetchUser', payload: 1 }).toPromise()

    expect(dispatched).toEqual([
      { type: 'user/setLoading', payload: true },
      { type: 'user/fetchUserSuccess', payload: mockUser },
      { type: 'user/setLoading', payload: false },
    ])
  })

  it('dispatches loading, failure, then loading off on API error', async () => {
    jest.spyOn(userApi, 'getUserById').mockRejectedValueOnce(new Error('Not found'))

    await runSaga(store, fetchUserSaga, { type: 'user/fetchUser', payload: 1 }).toPromise()

    expect(dispatched).toEqual([
      { type: 'user/setLoading', payload: true },
      { type: 'user/fetchUserFailure', payload: 'Not found' },
      { type: 'user/setLoading', payload: false },
    ])
  })
})

Testing Selectors Inside Sagas

When a saga reads from the store via select(), pass getState to runSaga:

// sagas/authSaga.ts
import { call, put, select } from 'redux-saga/effects'

export const selectToken = (state: any) => state.auth.token

export function* refreshTokenSaga() {
  const token: string = yield select(selectToken)
  if (!token) {
    yield put({ type: 'auth/redirectToLogin' })
    return
  }
  const refreshed: string = yield call(refreshToken, token)
  yield put({ type: 'auth/setToken', payload: refreshed })
}
import { runSaga } from 'redux-saga'
import { refreshTokenSaga } from './authSaga'
import * as authApi from '../api/authApi'

describe('refreshTokenSaga', () => {
  it('redirects to login when no token in state', async () => {
    const dispatched: any[] = []
    await runSaga(
      {
        getState: () => ({ auth: { token: null } }),
        dispatch: (a: any) => dispatched.push(a),
      },
      refreshTokenSaga
    ).toPromise()

    expect(dispatched).toContainEqual({ type: 'auth/redirectToLogin' })
  })

  it('refreshes token when present in state', async () => {
    jest.spyOn(authApi, 'refreshToken').mockResolvedValueOnce('new-token')
    const dispatched: any[] = []

    await runSaga(
      {
        getState: () => ({ auth: { token: 'old-token' } }),
        dispatch: (a: any) => dispatched.push(a),
      },
      refreshTokenSaga
    ).toPromise()

    expect(dispatched).toContainEqual({
      type: 'auth/setToken',
      payload: 'new-token',
    })
  })
})

Testing Channels

Channels handle async event streams — WebSockets, polling, browser events. Test them by creating the channel manually and feeding events in:

// sagas/websocketSaga.ts
import { eventChannel, END } from 'redux-saga'
import { take, put, call, race } from 'redux-saga/effects'

export function createWebSocketChannel(url: string) {
  return eventChannel(emit => {
    const ws = new WebSocket(url)
    ws.onmessage = event => emit(JSON.parse(event.data))
    ws.onerror = () => emit(END)
    ws.onclose = () => emit(END)
    return () => ws.close()
  })
}

export function* websocketSaga(url: string) {
  const channel: ReturnType<typeof createWebSocketChannel> = yield call(
    createWebSocketChannel,
    url
  )

  while (true) {
    const { message, cancel } = yield race({
      message: take(channel),
      cancel: take('ws/disconnect'),
    })

    if (cancel || message === END) {
      channel.close()
      break
    }

    yield put({ type: 'ws/messageReceived', payload: message })
  }
}
// sagas/websocketSaga.test.ts
import { runSaga, eventChannel, END } from 'redux-saga'
import { websocketSaga } from './websocketSaga'
import * as sagaModule from './websocketSaga'

describe('websocketSaga', () => {
  it('dispatches messageReceived for each incoming message', async () => {
    const dispatched: any[] = []
    const messages = [
      { type: 'ping', data: 'hello' },
      { type: 'data', data: 'world' },
    ]

    // Create a manual channel that emits test messages then ends
    const fakeChannel = eventChannel(emit => {
      messages.forEach(m => emit(m))
      emit(END)
      return () => {}
    })

    jest.spyOn(sagaModule, 'createWebSocketChannel').mockReturnValue(fakeChannel)

    await runSaga(
      { getState: () => ({}), dispatch: (a: any) => dispatched.push(a) },
      websocketSaga,
      'ws://localhost:8080'
    ).toPromise()

    expect(dispatched).toEqual([
      { type: 'ws/messageReceived', payload: messages[0] },
      { type: 'ws/messageReceived', payload: messages[1] },
    ])
  })
})

Testing with createMockTask

createMockTask is useful when testing sagas that fork other sagas and wait for them:

// sagas/orchestratorSaga.ts
import { fork, join, put } from 'redux-saga/effects'

export function* taskA() { /* ... */ }
export function* taskB() { /* ... */ }

export function* orchestratorSaga() {
  const taskAHandle: any = yield fork(taskA)
  const taskBHandle: any = yield fork(taskB)
  yield join([taskAHandle, taskBHandle])
  yield put({ type: 'orchestrator/allDone' })
}
import { fork, join, put } from 'redux-saga/effects'
import { createMockTask } from '@redux-saga/testing-utils'
import { orchestratorSaga, taskA, taskB } from './orchestratorSaga'

describe('orchestratorSaga — effect assertions', () => {
  it('forks both tasks, joins them, then dispatches allDone', () => {
    const gen = orchestratorSaga()
    const mockTaskA = createMockTask()
    const mockTaskB = createMockTask()

    // fork(taskA)
    expect(gen.next().value).toEqual(fork(taskA))

    // fork(taskB)
    expect(gen.next(mockTaskA).value).toEqual(fork(taskB))

    // join([taskAHandle, taskBHandle])
    expect(gen.next(mockTaskB).value).toEqual(join([mockTaskA, mockTaskB]))

    // put({ type: 'orchestrator/allDone' })
    expect(gen.next().value).toEqual(put({ type: 'orchestrator/allDone' }))

    expect(gen.next().done).toBe(true)
  })
})

Testing takeLatest / takeEvery Watcher Sagas

For watcher sagas, assert the takeLatest or takeEvery effect directly:

import { takeLatest, takeEvery } from 'redux-saga/effects'
import { watchUserSaga, fetchUserSaga } from './userSaga'

describe('watchUserSaga', () => {
  it('uses takeLatest for fetchUser action', () => {
    const gen = watchUserSaga()
    expect(gen.next().value).toEqual(
      takeLatest('user/fetchUser', fetchUserSaga)
    )
    expect(gen.next().done).toBe(true)
  })
})

Error Path Patterns

Always test the error path explicitly. Sagas can fail silently if try/catch blocks are missing or swallow errors:

// sagas/orderSaga.ts
import { call, put, retry } from 'redux-saga/effects'

export function* submitOrderSaga(action: { payload: any }) {
  try {
    // Retry up to 3 times with 2s delay
    const result: any = yield retry(3, 2000, submitOrder, action.payload)
    yield put({ type: 'orders/submitted', payload: result })
  } catch (error: any) {
    yield put({
      type: 'orders/submitFailed',
      payload: { message: error.message, retriesExhausted: true },
    })
  }
}
import { retry, put } from 'redux-saga/effects'
import { submitOrderSaga } from './orderSaga'
import * as orderApi from '../api/orderApi'

describe('submitOrderSaga error handling', () => {
  it('retries 3 times then dispatches submitFailed', async () => {
    jest
      .spyOn(orderApi, 'submitOrder')
      .mockRejectedValue(new Error('Service unavailable'))

    const dispatched: any[] = []
    await runSaga(
      { getState: () => ({}), dispatch: (a: any) => dispatched.push(a) },
      submitOrderSaga,
      { payload: { items: [] } }
    ).toPromise()

    const failure = dispatched.find(a => a.type === 'orders/submitFailed')
    expect(failure).toBeDefined()
    expect(failure.payload.retriesExhausted).toBe(true)
    expect(failure.payload.message).toBe('Service unavailable')
  })
})

When to Use Each Approach

Use effect assertions when:

  • The saga is short and the yield sequence is stable
  • You need exact control over what each step returns
  • Testing watcher sagas (takeLatest, takeEvery)

Use runSaga when:

  • You care about what actions were dispatched, not how
  • The saga reads from state via select()
  • Integration-level verification is more valuable than step-by-step

Use createMockTask when:

  • Testing orchestration sagas that fork and join
  • You need to simulate task cancellation or completion states

Browser-Level Validation with HelpMeTest

Saga tests verify side effects at the Redux level, but they can't catch bugs where the saga is wired to the wrong action type or the wrong component never dispatches at all. HelpMeTest fills that gap:

Go to /checkout
Fill in order details
Click "Place Order"
Verify loading indicator appears
Verify "Order confirmed" message appears within 5 seconds
Verify order appears in /orders history

These end-to-end tests run against a real browser and catch wiring bugs that unit tests miss.

Summary

Redux Saga testing has three layers:

  • Effect assertions — manual gen.next() loops, fast, best for stable short sagas and watcher assertions
  • runSaga — real execution with mocked APIs, best for verifying dispatched action sequences
  • createMockTask — for fork/join orchestration testing

Always test the error path. Sagas with silent catch blocks that discard errors are the hardest bugs to find in production.

Read more