Sequential Sagas with unit test — Redux saga (Beginner)

If you have a requirement to perform sequential API calls using Redux saga, this is how I solved it.

My requirement

The entity hierarchy in my application is Customer -> BillingAccount -> Subscriber. Which is, a customer has multiple billing accounts, and a billing account has multiple subscribers. My APIs are ‘subscribers’, ‘billingAccounts’ and ‘customers’. So fetching a subscriber will get the subscriber details with the ‘billing account id’. Using the ‘baid’, I fetch the billing account details to get the ‘customer number’ and then use the ‘customer number’ to fetch the customer details.

You could have different requirement but the general idea is we are fetching three different APIs by using a value from the response to make another API call. Simple!

Plan

I have decided to use a watch for the ‘Subscriber’ fetch. The watch has an ‘actionChannel’ which takes the ‘GET_SUBSCRIBER’ action and use ‘yield call’ to call the ‘fetchSubscriebrDetails’ saga. On resume of the call, I check if the response has a ‘baid’, which I use for my next call ‘fetchBillingAccountDetails’. Similarly, on resume, check if we have ‘customerId’ in the response and dispatch the next action ‘GET_CUSTOMER’ and call ‘fetchCustomerDetails’ saga.

The watcher
export function* subscriberWatcher() {
const subChannel = yield actionChannel(types.GET_SUBSCRIBER);
while (true) {
const action = yield take(subChannel);
yield call(fetchSubscriberDetails, action);
const baid = yield select(getSubscriberBaid);
if (baid) {
yield put({ type: types.GET_BILLING_ACCOUNT });
yield call(fetchBillingAccountDetails, baid });
}
const customerId = yield select(getCustomerId);
if (customerId) {
yield put({ type: types.GET_CUSTOMER });
yield call(fetchCustomerDetails, customerId });
}
}
}

The code is pretty self explanatory, but the key is to use blocking ‘call’ to fetch which will wait until the called generator function is finished(with success or error).

  1. The channel takes the ‘GET_SUBSCRIBER’ action and returns a plain object. The object has the ‘msisdn’ the subscriber number to be fetched.
  2. A blocking call to ‘fetchSubscriberDetails’ saga is made by passing the object.
  3. When the parent resumes, we are using a selector to get the ‘baid’, the billing account number from the state. Note: You can also return the response from the fetch saga to get the required vale but I chose to use the state.
  4. If a ‘baid’ exists, a ‘GET_BILLING_ACCOUNT’ action is dispatched and a blocking call to ‘fetchBillingAccountDetails’ saga is made by passing the ‘baid’.
  5. When the parent resumes, we are using a selector to get the ‘customerId’ from the state.
  6. If the ‘customerId’ exists, we are making a blocking call to ‘fetchCustomerDetails’ saga by passing the ‘customerId’.
The sagas
function* fetchSubscriberDetails(action) {
try {
const { msisdn } = action;
const subscriberDetails = yield axios.get(`${ENDPOINTS.SUBSCRIBER.GET.URL}${msisdn}`).then(response => response.data);
yield put({ type: types.SUBSCRIBER_RECEIVED, data: subscriberDetails });
} catch (error) {
yield put({ type: types.SUBSCRIBER_REQUEST_FAILED, error });
}
}
export function* fetchBillingAccountDetails(action) {
try {
const { baid } = action;
const billingAccountDetails = yield axios.get(`${ENDPOINTS.BILLING_ACCOUNT.GET.URL}${baid}`).then(response => response.data);
yield put({ type: BILLING_ACCOUNT_RECEIVED, data: billingAccountDetails });
} catch (error) {
yield put({ type: BILLING_ACCOUNT_REQUEST_FAILED, error });
}
}
export function* fetchCustomerDetails(action) {
try {
const { customerId } = action;
const customerDetails = yield axios.get(`${ENDPOINTS.CUSTOMER.GET.URL}${customerId}`).then(response => response.data);
yield put({ type: CUSTOMER_RECEIVED, data: customerDetails });
} catch (error) {
yield put({ type: CUSTOMER_REQUEST_FAILED, error });
}
}
The selector

The selectors get the required value form the state, in this case I get the ‘baid’ from the ‘subscriber’ and ‘customerId’ from the ‘customer’.

export const getSubscriberBaid = (state) => {
state.subscriber.subscriberDetails ? state.subscriber.subscriberDetails.baid : null;
};
export const getCustomerId = state => (state.subscriber.billingDetails ? state.subscriber.billingDetails.customerId : null);

The selectors can be called in the sagas using the ‘select’ effect.

const baid = yield select(getSubscriberBaid);
const customerId = yield select(getCustomerId);

If you want to have a look at the reducers, actions and other files please refer to my previous post ‘How to CRUD using React/Redux/Redux Sagas’.

Unit test with Jest

Unit test with conditional statement was tricky to understand. But it goes like this..

describe('GET_SUBSCRIBER Watch', () => {
const generator = subscriberWatcher();
const mockChannel = channel();
const actChannel = actionChannel(types.GET_SUBSCRIBER);
const payload = { msisdn: '12345'};
const baid = '3254';
const customerId = '9878';

it('can create an action channel and call fetch subscriber', () => {
expect(generator.next().value).toEqual(actChannel);
expect(generator.next(mockChannel).value).toEqual(take(mockChannel));
expect(generator.next(payload).value).toEqual(call(fetchSubscriberDetails, payload));
});
it('can call fetch billingAccount', () => {
expect(generator.next().value).toEqual(select(getSubscriberBaid));
expect(generator.next(baid).value).toEqual(put({ type: types.GET_BILLING_ACCOUNT }));
expect(generator.next().value).toEqual(call(fetchBillingAccountDetails, { baid }));
});
it('can call fetch customer', () => {
expect(generator.next().value).toEqual(select(getCustomerId));
expect(generator.next(customerId).value).toEqual(put({ type: types.GET_CUSTOMER }));
expect(generator.next().value).toEqual(call(fetchCustomerDetails, { customerId }));
});
});

Thanks for reading. Cheers!