Published on

How to write tests for Redux Reducers in React.js/Next.js Apps!

Hi guys, In this post I'll explain how to test the the Reducers in the Redux state management portion of your React/Next.js applications.

Redux and React logos

Setup

First, let's use a sample application for this Redux testing tutorial. Here is a GitHub repo containing a Next.js/Redux app that we can use for this tutorial: https://github.com/ShehanAT/SaaS-Product-App

Start by cloning the project and opening it up in VSCode or a similar coding editor.

Note: If you want to follow along with a different Next.js app or even a React.js app feel free to do so. The tests to be covered in this blog post are also applicable for React.js apps because the Next.js framework is built on top of the React.js library

App Info

Before we get to writing the tests for the Redux Reducers, I'll give a brief overview of how Redux in used in the application mentioned above:

  • This application is an ecommerce web app built using Next.js and uses Supabase as its backend and database
  • It uses Redux to manage the following aspects of the application:
    • Shopping cart
    • Customer orders
    • Store products
    • User Sign in status & user info

Here is the store.js file for this application so that you can get a better idea of the reducers we'll be testing:

import { createStore, combineReducers, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
import Cookie from 'js-cookie';
import {
    productListReducer,
    productSaveReducer,
    productDeleteReducer,
    productDetailsReducer,
    productReviewSaveReducer,
    productUpdateReducer,
} from '../reducers/productReducers';
import {
    useSigninReducer,
    userUpdateReducer,
    userRegisterReducer,
    userSigninReducer,
    userGetReducer,
} from '../reducers/userReducers';
import {
    cartReducer
} from '../reducers/cartReducers';
import {
    orderCreateReducer,
    orderListReducer,
    orderDeleteReducer,
    orderPayReducer,
    orderDetailsReducer,
    shippingDetailsReducer,
    myOrderListReducer,
} from '../reducers/orderReducers';

const userInfo = Cookie.getJSON('userInfo') || null;
const cartItems = Cookie.getJSON('cartItems') || [];

const initialState = {
    cart: { cartItems, shipping: {}, payment: {} },
    userSignin: { userInfo },
};

let composeEnhancers = compose;
if (typeof window !== 'undefined') {
  composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
}

const reducer = combineReducers({
    productList: productListReducer,
    productSave: productSaveReducer,
    productDelete: productDeleteReducer,
    productDetails: productDetailsReducer,
    productReviewSave: productReviewSaveReducer,
    productUpdate: productUpdateReducer,
    userSignin: userSigninReducer,
    userGet: userGetReducer,
    userRegister: userRegisterReducer,
    userUpdate: userUpdateReducer,
    cart: cartReducer,
    orderCreate: orderCreateReducer,
    orderList: orderListReducer,
    orderDelete: orderDeleteReducer,
    orderPay: orderPayReducer,
    orderDetails: orderDetailsReducer,
    myOrderList: myOrderListReducer,
    shippingDetails: shippingDetailsReducer,
});

const store = createStore(
    reducer,
    initialState,
    composeEnhancers(applyMiddleware(thunk))
);

export default store;

Also here is a link to the reducer files in the application if you want to view the reducers we'll be testing: https://github.com/ShehanAT/SaaS-Product-App/tree/master/saas-product-app/src/reducers

Development

First let's build out the /src/tests/reduxTesting/reducerTesting/cartReducer.spec.js file:

const cartReducers = require("../../../reducers/cartReducers");
const cartReducer = cartReducers.cartReducer;
import { CART_ADD_ITEM, CART_REMOVE_ITEM, CART_SAVE_SHIPPING, CART_SAVE_PAYMENT } from "../../../constants/cartConstants";

describe('Testing Cart reducers', () => {

    const mockCartState = { cartItems: [], shipping: {}, payment: {} }


        it("Given cartReducer" +
        "When empty cartItems is passed into cartReducer" +
        "Then cartItems should be empty", () => {
        const expectedResult = {
            cartItems: [
                {
                    "countInStock": 7,
                    "image": "https://static-content-1.boomfit.com/5490-large_default/kettlebell-12kg.jpg?auto=format&fit=crop&w=2560&q=100",
                    "name": "Kettlebell",
                    "price": 15,
                    "productId": 2,
                    "qty": 1,
                }
            ]
        };
        const mockAction = {
            type: CART_ADD_ITEM,
            payload: {
                countInStock: 7,
                image: "https://static-content-1.boomfit.com/5490-large_default/kettlebell-12kg.jpg?auto=format&fit=crop&w=2560&q=100",
                name: "Kettlebell",
                price: 15,
                productId: 2,
                qty: 1,
            }
        }

        const cartAddItemResult = cartReducer(mockCartState, mockAction);
        expect(cartAddItemResult).toEqual(expectedResult);
    });

Now for an explanation of what we just added:

  • First we import the cartReducer via require("../../../reducers/cartReducers");
  • Then we import the text constants required to validate our reducers from '../../../constants/cartConstants'
  • The describe() method is used to, as the name implies, describe the test suite that the file contains
  • Next a mocked state variable called mockCartState is defined. This variable is used as the default state when testing our reducers
  • The it() method is used to identify each individual test. Any code inside it will be run whenever running the command: jest
  • Now, the expectedResult variable is used as a comparison object, against which the real result from the cartReducer can be compared to. For now, it contains a single product(a Kettlebell priced at $15)
  • The mockAction is the second argument that is to be passed into the cartReducer. It contains the type of action(CART_ADD_ITEM) and the payload(which is the same kettlebell product mentioned in the above point)
  • After calling cartReducer(mockCartState, mockAction) and saving its result to cartAddItemResult, we can now compare cartAddItemResult to expectedResult. If the assertion passes we can conclude that the cartReducer works as expected when passing in the CART_ADD_ITEM action
  • This first test is designed to test the CART_ADD_ITEM action for the cartReducer. The redux action that uses it is responsible for adding cart items to the cartItems Redux store variable

Now let's move on to the next test for the /src/tests/reduxTesting/reducerTesting/cartReducer.spec.js file:

  it("Given cartReducer" +
       "When removing one item from cartItems" +
       "Then cartItems should be of length zero", () => {
        const expected = { cartItems: [], payment: {}, shipping: {} };
        const mockAction = {
            type: CART_ADD_ITEM,
            payload: {
                productId: 1
            }
        };
        const actual = cartReducer(mockCartState, mockAction);

        expect(actual.cartItems.length).toEqual(1);

        const mockDeleteAction = {
            type: CART_REMOVE_ITEM,
            payload: {
                productId: 1
            }
        }
        const deleteItemActual = cartReducer(mockCartState, mockDeleteAction);
        expect(deleteItemActual.cartItems.length).toEqual(0);
    });

Now for an explanation of what we just added:

  • This test is designed to test the delete cart item functionality of the cartReducer Redux reducer
  • First we add a cart item to the cartItems Redux store variable via the action type CART_ADD_ITEM and the payload containing an item. Note that this item contains only the productId variable as an attribute for the sake of brevity
  • Then we make an assertion on whether the insertion above worked by checking that the length of the actual.cartItems list has a length of 1
  • Now, we call CART_REMOVE_ITEM action type in order to delete the newly added product. The payload of this action containing the same productId so that the newly added item can be targeted
  • Finally, in order to make sure that the item was truly deleted we end with an assertion that the actual.cartItems list should have a length of 0

Now let's move on to the next test for the /src/tests/reduxTesting/reducerTesting/cartReducer.spec.js file:

    it("Given cartReducer" +
       "When saving shipping item in cartItems" +
       "Then cartItems should return shipping item in the payload",() => {

        const mockSaveShippingAction = {
            type: CART_SAVE_SHIPPING,
            payload: {
                address: "123 Governor St",
                city: "Toronto",
                postalCode: "N8E 3T4",
                country: "CAN",
            }
        }

        const saveShippingItem = cartReducer(mockCartState, mockSaveShippingAction);

        expect(saveShippingItem.shipping.address).toEqual(mockSaveShippingAction.payload.address);
        expect(saveShippingItem.shipping.city).toEqual(mockSaveShippingAction.payload.city);
        expect(saveShippingItem.shipping.postalCode).toEqual(mockSaveShippingAction.payload.postalCode);
        expect(saveShippingItem.shipping.country).toEqual(mockSaveShippingAction.payload.country);
    });

Now for an explanation of what we just added:

  • This test is designed to test the ability to save an instance of shipping information in the cart Redux store variable
  • First, we create the shipping action containing the CART_SAVE_SHIPPING action type and the payload containing an instance of shipping information
  • Then the cartReducer() is called with the mocked shipping action and the result is saved to the saveShippingItem variable
  • Finally, we make four assertions that check for the consistency of the shipping address, city, postal code, and country across both the reducer's result and the mocked action's payload

Now let's move on to testing the Orders reducer. Navigate to the following file: /src/tests/reduxTesting/reducerTesting/orderReducer.spec.js file and add the following code:

import { orderCreateReducer, orderListReducer, orderDeleteReducer, orderPayReducer, orderDetailsReducer, myOrderListReducer, shippingDetailsReducer } from "../../../reducers/orderReducers";
import { listOrders } from "../../../actions/orderActions";
import { ORDER_CREATE_FAIL, ORDER_CREATE_SUCCESS, ORDER_LIST_SUCCESS } from "../../../constants/orderConstants";
import { isTypedArray } from "util/types";
import { useDispatch } from 'react-redux';


describe("Testing Order reducers", () => {

    const mockCartState = { cartItems: [], shipping: {}, payment: {} };

    it("Given orderReducer" +
        "When creating order" +
        "Then order should be returned as the payload", () => {
        const mockAction = {
            type: ORDER_CREATE_SUCCESS,
            payload: {
                orderItems: [
                    {
                        countInStock: 5,
                        image: "https://media.istockphoto.com/photos/cement-bags-pile-picture-id476199756",
                        name: "Cement",
                        price: 20,
                        productId: 12,
                        qty: 1,
                    },
                    {
                        countInStock: 7,
                        image: "https://static-content-1.boomfit.com/5490-large_default/kettlebell-12kg.jpg?auto=format&fit=crop&w=2560&q=100",
                        name: "Kettlebell",
                        price: 15,
                        productId: 2,
                        qty: 1,
                    },
                    {
                        countInStock: 10,
                        image: "https://cdn.runningshoesguru.com/wp-content/uploads/2018/08/Brooks-Ghost-11-Lateral-Side.jpg",
                        name: "Running Shoes",
                        price: 50,
                        productId: 3,
                        qty: 1,
                    }
                ],
                shipping: {
                    address: "123 St James",
                    city: "Hamilton",
                    country: "Canada",
                    postalCode: "N9B4T5"
                },
                payment: {
                    paymentMethod: "Stripe"
                },
                itemsPrice: 30,
                shippingPrice: 30,
                taxPrice: 10,
                totalPrice: 70,
                userEmail: "jim@gmail.com"
            }
        }

        const expectedResult = {
            success: true,
            loading: false,
            order: {
                orderItems: [
                    {
                        countInStock: 5,
                        image: "https://media.istockphoto.com/photos/cement-bags-pile-picture-id476199756",
                        name: "Cement",
                        price: 20,
                        productId: 12,
                        qty: 1,
                    },
                    {
                        countInStock: 7,
                        image: "https://static-content-1.boomfit.com/5490-large_default/kettlebell-12kg.jpg?auto=format&fit=crop&w=2560&q=100",
                        name: "Kettlebell",
                        price: 15,
                        productId: 2,
                        qty: 1,
                    },
                    {
                        countInStock: 10,
                        image: "https://cdn.runningshoesguru.com/wp-content/uploads/2018/08/Brooks-Ghost-11-Lateral-Side.jpg",
                        name: "Running Shoes",
                        price: 50,
                        productId: 3,
                        qty: 1,
                    }
              ],
                shipping: {
                        address: "123 St James",
                        city: "Hamilton",
                        country: "Canada",
                        postalCode: "N9B4T5"
                    },
                payment: {
                    paymentMethod: "Stripe"
                },
                itemsPrice: 30,
                shippingPrice: 30,
                taxPrice: 10,
                totalPrice: 70,
                userEmail: "jim@gmail.com"
            }
        };

        const orderCreateResult = orderCreateReducer(mockCartState, mockAction);

        expect(orderCreateResult).toEqual(expectedResult);
    });

    ...

});

Now for an explanation for the first test we just added:

  • This test is designed to test the order create success use case. Whenever an order is successfully created, that order should be returned as an object in the payload
  • First, we create a mock order object containing three order items, along with their shipping and payment items. Also, the total price of the items(itemsPrice), price of shipping(shippingPrice), price of tax(taxPrice) and total price of the order(totalPrice) are also included
  • Similar to previously covered test cases, we create an expected result object containing the same order object as the one included in the mock order object in order to conduct the assertion
  • After saving the result from orderCreateReducer(mockCartState, mockAction) to the orderCreateResult variable, we finally assert that the orderCreateResult object should be equal to the expectedResult object in order for the test case to pass

On to the second test for the /src/tests/reduxTesting/reducerTesting/orderReducer.spec.js file:

  it("Given orderReducer" +
       "When passing a list of orders to orderListReducer method" +
       "Then a list of orders should be returned as the payload"
        , () => {

        const mockCartState = { cartItems: [], shipping: {}, payment: {} };

        const mockAction = {
            type: ORDER_LIST_SUCCESS,
            payload: {
                orders: [
                    {
                        countInStock: 5,
                        image: "https://media.istockphoto.com/photos/cement-bags-pile-picture-id476199756",
                        name: "Cement",
                        price: 20,
                        productId: 12,
                        qty: 1,
                    },
                    {
                        countInStock: 7,
                        image: "https://static-content-1.boomfit.com/5490-large_default/kettlebell-12kg.jpg?auto=format&fit=crop&w=2560&q=100",
                        name: "Kettlebell",
                        price: 15,
                        productId: 2,
                        qty: 1,
                    },
                    {
                        countInStock: 10,
                        image: "https://cdn.runningshoesguru.com/wp-content/uploads/2018/08/Brooks-Ghost-11-Lateral-Side.jpg",
                        name: "Running Shoes",
                        price: 50,
                        productId: 3,
                        qty: 1,
                    }
                ],
                loading: false
            }
        };
        const actual = orderListReducer(mockCartState, mockAction);

        expect(actual.orders.orders.length).toEqual(3);
    });

Now for an explanation for the test we just added:

  • This test is designed to validate that when we call the Order reducer for a list of orders, that three order objects are received in the response object
  • First, we create three order items with their required fields in the payload field of the mocked action object. Note that the type value of ORDER_LIST_SUCCESS is indicates to the reducer on which action to perform
  • Then we simply check the length of the response object and assert that it should be three

Next let's cover a test in the /src/tests/reduxTesting/reducerTesting/productReducer.spec.js file:

...
// See the GitHub repo mentioned above for the full file

        it("Given productReducer" +
        "When calling productSaveReducer" +
        "Then the saved product should be returned", () => {
            const mockAction = {
                type: PRODUCT_SAVE_SUCCESS,
                payload: {
                    product: {
                        productId: 1,
                        name: "Cement",
                        image: "https://media.istockphoto.com/photos/cement-bags-pile-picture-id476199756",
                        price: 20,
                        countInStock: 5,
                        qty: 2,
                    }
                }
            }

            const expectedResult = {
                loading: false,
                product: {
                    product: {
                        productId: 1,
                        name: "Cement",
                        image: "https://media.istockphoto.com/photos/cement-bags-pile-picture-id476199756",
                        price: 20,
                        countInStock: 5,
                        qty: 2,
                    }
                },
                success: true,
            }
            const actualResult = productSaveReducer(mockCartState, mockAction);
            expect(expectedResult).toEqual(actualResult);
        });
...

Now for an explanation of the above code:

  • This test is designed to test the product saving functionality of the Product reducer
  • First, we pass in a sample product object in the payload field of the mock action
  • Then, an expected result object containing the same product object is created
  • Finally, we compare the resulting object with the expected result object and assert that they should be equal

Continuing with the /src/tests/reduxTesting/reducerTesting/productReducer.spec.js file:

  it("Given productReducer" +
        "When calling productDeleteReducer" +
        "Then the identified product should be deleted and a success indicator should be returned", () => {
            const mockAction = {
                type: PRODUCT_DELETE_SUCCESS,
                payload: {
                    productId: 1
                }
            }

            const expectedResult = {
                loading: false,
                success: true,
                product: { productId: 1}
            }
            const actualResult = productDeleteReducer(mockCartState, mockAction);
            console.log(actualResult);
            expect(expectedResult).toEqual(actualResult);
    });

Now for an explanation of the above test:

  • This test is designed to test the product delete functionality of the Product reducer
  • First, we create a mocked action object containing the productId of the product to be deleted
  • Next, an expected result object is created containing the productId of the product to be deleted
  • Finally, after calling productDeleteReducer(mockCartState, mockAction) and saving its result to the actualResult variable, we compare the actualResult variable to the expectedResult variable. Doing so ensures that the deleted product's productId is contained in the actual result

On to the final reducer file in our testing tutorial: /src/tests/reduxTesting/reducerTesting/productReducer.spec.js:

...

it("Given userReducer" +
    "When calling userSigninReducer" +
    "Then an user info object should be returned after sign in success", () => {
        const mockAction = {
            type: USER_SIGNIN_SUCCESS,
            payload: {
                user: {
                    userId: 1
                }
            }
        }

        const expectedResult = {
            userLoading: false,
            userInfo: {
                user: {
                    userId: 1
                }
            }
        }

        const actualResult = userSigninReducer(mockCartState, mockAction);

        expect(expectedResult).toEqual(actualResult);

    });
...

Now for an explanation for this test case:

  • This test case is designed to simualate a user sign in attempt. Apon a successful sign in attempt by the user, the userSigninReducer should return the userId of that specific user
  • As usual a mock action containing a userId in the payload field is created and included in the call to the user sign in reducer: userSigninReducer(mockCartState, mockAction)
  • The actual result is then compared with the expected result in order to assert that the same userId value should be present in both objects

Ok, if you made it this far Congrats! You now have an understanding of how to test Redux reducers for React.js/Next.js applications.

Conclusion

Well that's it for this post! Thanks for following along in this article and if you have any questions or concerns please feel free to post a comment in this post and I will get back to you when I find the time.

If you found this article helpful please share it and make sure to follow me on Twitter and GitHub, connect with me on LinkedIn and subscribe to my YouTube channel.