Saga middleware for Redux to handle side effects using ES6 generators
—
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.
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 })
);
});
});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]));
});
});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' })
);
});
});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' }
});
});
});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)
);
});
});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' }));
});
});Install with Tessl CLI
npx tessl i tessl/npm-redux-saga