Concept
Every prior topic in this saga path emphasized that call(), put(), take(), and the rest produce plain, inspectable effect descriptions rather than performing real work directly. This topic is the payoff of that design: it makes testing a saga's logic dramatically simpler than testing equivalent async/await or createAsyncThunk code, because a test can drive the generator by hand and assert on exactly what it intends to do, without any of it actually happening.
function* fetchUserSaga(action) {
try {
const user = yield call(api.getUser, action.payload);
yield put({ type: "USER_LOADED", payload: user });
} catch (e) {
yield put({ type: "USER_FAILED", error: e.message });
}
}Confirmed: stepping through the success path with plain .next() calls
const gen = fetchUserSaga({ payload: 5 });
const step1 = gen.next();
console.log(step1.value);
// { "@@redux-saga/IO": true, type: "CALL", payload: { fn: api.getUser, args: [5] } }
const step2 = gen.next({ id: 5, name: "Ada" }); // MANUALLY supply the "resolved" value, no real call happened
console.log(step2.value);
// { "@@redux-saga/IO": true, type: "PUT", payload: { action: { type: "USER_LOADED", payload: { id: 5, name: "Ada" } } } }function* fetchUserSaga(action) {const user = yield call(api.getUser, action.payload);yield put({ type: 'USER_LOADED', payload: user });}store.dispatch({ type: 'FETCH_USER', payload: 1 });
The saga middleware watches for matching actions, takeEvery starts a new run of fetchUserSaga each time FETCH_USER is dispatched.
Confirmed by running this exact test: calling gen.next() the first time returns the call() effect description, a plain object, comparable directly with something like expect(step1.value).toEqual(call(api.getUser, 5)) in a real test framework. Calling gen.next({ id: 5, name: "Ada" }) the second time manually supplies the value the real api.getUser call would eventually have resolved to, this is the critical trick: the test controls exactly what "the call resolved to X" means, without api.getUser ever actually running. The saga then correctly produces its put() effect using that injected value, provable with a second plain-object comparison.
Confirmed: testing the error path with gen.throw()
const gen2 = fetchUserSaga({ payload: 5 });
gen2.next(); // advance to the paused `yield call(...)`
const step2 = gen2.throw(new Error("network down")); // INJECT a simulated failure at that exact point
console.log(step2.value);
// { "@@redux-saga/IO": true, type: "PUT", payload: { action: { type: "USER_FAILED", error: "network down" } } }Confirmed by running this exact sequence: gen.throw(error) injects an error at the generator's currently paused position, exactly as if the real call(api.getUser, ...) had actually rejected with that error at that point, and the saga's own try/catch correctly routes into the catch block, producing the USER_FAILED put() effect. This tests the entire error-handling path with zero real network failure simulation, zero mocked rejected Promises, zero timing concerns, just a direct method call on the generator object.
Why this is meaningfully simpler than testing equivalent async/await code
Testing an equivalent createAsyncThunk or plain async function typically requires mocking fetch (or whatever the real dependency is) to control its resolved/rejected value, then awaiting the whole operation and asserting on the resulting state or dispatched actions, real Promise resolution timing is involved, and the mock has to correctly emulate the dependency's actual interface. Testing a saga this way skips all of that: there's no mock of api.getUser at all in the tests above, the function is never called, referenced only as data inside the yielded call() effect, and no await, no Promise timing, no async test runner configuration needed. The entire test is synchronous, plain-object comparisons, driving the generator one .next()/.throw() at a time.
Try It
Predict the outcome before checking the solution.
function* watcherSaga() {
yield takeEvery("FETCH", fetchUserSaga);
}