CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/npm-redux-saga

Saga middleware for Redux to handle side effects using ES6 generators

Pending
Overview
Eval results
Files

testing.mddocs/

Testing

Tools for testing sagas in isolation, including cloneable generators and mock tasks. These utilities make it easy to test saga logic without running the full Redux-Saga middleware.

Capabilities

cloneableGenerator

Creates a cloneable generator from a saga function. This allows testing different branches of saga execution without replaying all the previous steps.

/**
 * Create cloneable generator for testing different saga branches
 * @param saga - Generator function to make cloneable
 * @returns Function that creates cloneable generator instances
 */
function cloneableGenerator<S extends Saga>(
  saga: S
): (...args: Parameters<S>) => SagaIteratorClone;

interface SagaIteratorClone extends SagaIterator {
  /** Create clone at current execution point */
  clone(): SagaIteratorClone;
}

Usage Examples:

import { cloneableGenerator } from "@redux-saga/testing-utils";
import { call, put, select } from "redux-saga/effects";

function* userSaga(action) {
  const userId = action.payload.userId;
  const user = yield call(api.fetchUser, userId);
  
  const isAdmin = yield select(getIsAdmin, user.id);
  
  if (isAdmin) {
    yield put({ type: 'ADMIN_USER_LOADED', user });
  } else {
    yield put({ type: 'REGULAR_USER_LOADED', user });
  }
}

// Test different branches without replaying setup
describe('userSaga', () => {
  it('handles admin and regular users', () => {
    const generator = cloneableGenerator(userSaga)({ 
      payload: { userId: 123 } 
    });
    
    // Common setup
    assert.deepEqual(
      generator.next().value,
      call(api.fetchUser, 123)
    );
    
    const mockUser = { id: 123, name: 'John' };
    assert.deepEqual(
      generator.next(mockUser).value,
      select(getIsAdmin, 123)
    );
    
    // Clone at decision point
    const adminClone = generator.clone();
    const regularClone = generator.clone();
    
    // Test admin branch
    assert.deepEqual(
      adminClone.next(true).value,
      put({ type: 'ADMIN_USER_LOADED', user: mockUser })
    );
    
    // Test regular user branch  
    assert.deepEqual(
      regularClone.next(false).value,
      put({ type: 'REGULAR_USER_LOADED', user: mockUser })
    );
  });
});

createMockTask

Creates a mock task object for testing saga interactions with forked tasks. The mock task can be configured to simulate different task states and results.

/**
 * Create mock task for testing saga task interactions
 * @returns MockTask that can be configured for different scenarios
 */
function createMockTask(): MockTask;

interface MockTask extends Task {
  /** @deprecated Use setResult, setError, or cancel instead */
  setRunning(running: boolean): void;
  /** Set successful result for the task */
  setResult(result: any): void;
  /** Set error result for the task */
  setError(error: any): void;
  /** Standard task methods */
  isRunning(): boolean;
  result<T = any>(): T | undefined;
  error(): any | undefined;
  toPromise<T = any>(): Promise<T>;
  cancel(): void;
  setContext(props: object): void;
}

Usage Examples:

import { createMockTask } from "@redux-saga/testing-utils";
import { fork, join, cancel } from "redux-saga/effects";

function* coordinatorSaga() {
  const task1 = yield fork(workerSaga1);
  const task2 = yield fork(workerSaga2);
  
  try {
    const results = yield join([task1, task2]);
    yield put({ type: 'ALL_TASKS_COMPLETED', results });
  } catch (error) {
    yield cancel([task1, task2]);
    yield put({ type: 'TASKS_FAILED', error });
  }
}

describe('coordinatorSaga', () => {
  it('handles successful task completion', () => {
    const generator = coordinatorSaga();
    
    // Mock the fork effects
    generator.next(); // first fork
    generator.next(); // second fork
    
    // Create mock tasks
    const mockTask1 = createMockTask();
    const mockTask2 = createMockTask();
    
    // Set successful results
    mockTask1.setResult('result1');
    mockTask2.setResult('result2');
    
    // Test join behavior
    const joinEffect = generator.next([mockTask1, mockTask2]).value;
    assert.deepEqual(joinEffect, join([mockTask1, mockTask2]));
    
    // Verify final action
    const putEffect = generator.next(['result1', 'result2']).value;
    assert.deepEqual(putEffect, put({ 
      type: 'ALL_TASKS_COMPLETED', 
      results: ['result1', 'result2'] 
    }));
  });
  
  it('handles task failures', () => {
    const generator = coordinatorSaga();
    
    generator.next(); // first fork
    generator.next(); // second fork
    
    const mockTask1 = createMockTask();
    const mockTask2 = createMockTask();
    
    // Set error on one task
    mockTask1.setError(new Error('Task failed'));
    
    // Test error handling
    const error = new Error('Task failed');
    const cancelEffect = generator.throw(error).value;
    
    assert.deepEqual(cancelEffect, cancel([mockTask1, mockTask2]));
  });
});

Testing Patterns

Basic Saga Testing

import { call, put } from "redux-saga/effects";

function* fetchUserSaga(action) {
  try {
    const user = yield call(api.fetchUser, action.payload.id);
    yield put({ type: 'FETCH_USER_SUCCESS', user });
  } catch (error) {
    yield put({ type: 'FETCH_USER_FAILURE', error: error.message });
  }
}

describe('fetchUserSaga', () => {
  it('fetches user successfully', () => {
    const action = { payload: { id: 1 } };
    const generator = fetchUserSaga(action);
    
    // Test API call
    assert.deepEqual(
      generator.next().value,
      call(api.fetchUser, 1)
    );
    
    // Test success action
    const user = { id: 1, name: 'John' };
    assert.deepEqual(
      generator.next(user).value,
      put({ type: 'FETCH_USER_SUCCESS', user })
    );
    
    // Test completion
    assert.equal(generator.next().done, true);
  });
  
  it('handles API errors', () => {
    const action = { payload: { id: 1 } };
    const generator = fetchUserSaga(action);
    
    generator.next(); // Skip to after call
    
    // Throw error
    const error = new Error('API Error');
    assert.deepEqual(
      generator.throw(error).value,
      put({ type: 'FETCH_USER_FAILURE', error: 'API Error' })
    );
  });
});

Testing with runSaga

import { runSaga } from "redux-saga";

describe('saga integration tests', () => {
  it('dispatches correct actions', async () => {
    const dispatched = [];
    const mockStore = {
      getState: () => ({ user: { id: 1 } }),
      dispatch: (action) => dispatched.push(action)
    };
    
    await runSaga(mockStore, fetchUserSaga, { payload: { id: 1 } }).toPromise();
    
    expect(dispatched).toContainEqual({
      type: 'FETCH_USER_SUCCESS',
      user: { id: 1, name: 'John' }
    });
  });
});

Testing Channels

import { channel } from "redux-saga";
import { take, put } from "redux-saga/effects";

function* channelSaga() {
  const chan = yield call(channel);
  yield fork(producer, chan);
  yield fork(consumer, chan);
}

describe('channelSaga', () => {
  it('creates and uses channel', () => {
    const generator = channelSaga();
    
    // Test channel creation
    assert.deepEqual(
      generator.next().value,
      call(channel)
    );
    
    // Mock channel
    const mockChannel = { put: jest.fn(), take: jest.fn() };
    
    // Test producer fork
    assert.deepEqual(
      generator.next(mockChannel).value,
      fork(producer, mockChannel) 
    );
    
    // Test consumer fork
    assert.deepEqual(
      generator.next().value,
      fork(consumer, mockChannel)
    );
  });
});

Testing Error Boundaries

function* sagaWithErrorHandling() {
  try {
    yield call(riskyOperation);
    yield put({ type: 'SUCCESS' });
  } catch (error) {
    yield put({ type: 'ERROR', error: error.message });
  } finally {
    yield put({ type: 'CLEANUP' });
  }
}

describe('error handling', () => {
  it('handles errors and runs cleanup', () => {
    const generator = sagaWithErrorHandling();
    
    generator.next(); // Skip to call
    
    // Throw error
    const error = new Error('Operation failed');
    const errorAction = generator.throw(error).value;
    assert.deepEqual(errorAction, put({ 
      type: 'ERROR', 
      error: 'Operation failed' 
    }));
    
    // Test finally block
    const cleanupAction = generator.next().value;
    assert.deepEqual(cleanupAction, put({ type: 'CLEANUP' }));
  });
});

Testing Best Practices

  1. Test individual effects: Verify each yielded effect matches expected values
  2. Use cloneableGenerator: Test different branches without duplicating setup
  3. Mock external dependencies: Use mock tasks and channels for isolation
  4. Test error paths: Verify error handling using generator.throw()
  5. Test integration: Use runSaga for end-to-end saga testing
  6. Assert completion: Check generator.next().done for saga completion

Install with Tessl CLI

npx tessl i tessl/npm-redux-saga

docs

basic-effects.md

channels.md

concurrency-effects.md

helper-effects.md

index.md

middleware.md

testing.md

utilities.md

tile.json