Redux store not being populated when mocking API call

Viewed 579

I wrote a Register component in react, it is a simple form that on submit will post to an API. The call to the API will return an object with certain data, this data will be then added to the redux store.

I wrote some tests for this. I'm using Mock Service Worker (MSW) to mock the API call. This is my first time for writing these kind of tests so I'm not sure if I'm doing anything wrong, but my understanding was that MSW would intercept the call to the API and return whatever I specify in the MSW config, after that it should follow the regular flow.

Here's my reducer:

const authReducer = (state = INITIAL_STATE, action) => {
    switch (action.type) {

        case actionTypes.REGISTER_NEW_USER:
            const newUser = new User().register(
                action.payload.email,
                action.payload.firstName,
                action.payload.lastName,
                action.payload.password
            )
            console.log("User registered data back:");
            console.log(newUser);
            return {
                ...state,
                'user': newUser
            }
        default:
            return state;
    }
}

this is my User class where the actual call is performed:

import axios from "axios";
import { REGISTER_API_ENDPOINT } from "../../api";

export default class User {

    /**
     * Creates a new user in the system
     *
     * @param {string} email - user's email address
     * @param {string} firstName - user's first name
     * @param {string} lastName - user's last name
     * @param {string} password - user's email address
     */
    register(email, firstName, lastName, password) {
        // console.log("registering...")
        axios.post(REGISTER_API_ENDPOINT, {
            email,
            firstName,
            lastName,
            password
        })
            .then(function (response) {
                return {
                    'email': response.data.email,
                    'token': response.data.token,
                    'active': response.data.active,
                    'loggedIn': response.data.loggedIn,
                }
            })
            .catch(function (error) {
                console.log('error');
                console.log(error);
            });
    }
}

this is my action creator:

export function createNewUser(userData) {
    return {
        type: REGISTER_NEW_USER,
        payload: userData
    }
}

this is the onSubmit method in my Register component:

const onSubmit = data => {
        // console.log(data);
        if (data.password !== data.confirmPassword) {
            console.log("Invalid password")
            setError('password', {
                type: "password",
                message: "Passwords don't match"
            })
            return;
        }

        // if we got up to this point we don't need to submit the password confirmation
        // todo but we might wanna pass it all the way through to the backend TBD
        delete data.confirmPassword

        dispatch(createNewUser(data))
    }

and this is my actual test:

describe('Register page functionality', () => {

    const server = setupServer(
        rest.post(REGISTER_API_ENDPOINT, (req, res, ctx) => {
            console.log("HERE in mock server call")
            // Respond with a mocked user object
            return res(
                ctx.status(200),
                ctx.json({
                'email': faker.internet.email(),
                'token': faker.datatype.uuid(),
                'active': true,
                'loggedIn': true,
            }))
        })
    )

    // Enable API mocking before tests
    beforeEach(() => server.listen());

    // Reset any runtime request handlers we may add during the tests.
    afterEach(() => server.resetHandlers())

    // Disable API mocking after the tests are done.
    afterAll(() => server.close())


    it('should perform an api call for successful registration', async () => {

        // generate random data to be used in the form
        const email = faker.internet.email();
        const firstName = faker.name.firstName();
        const lastName = faker.name.lastName();
        const password = faker.internet.password();

        // Render the form
        const { store } = renderWithRedux(<Register />);

        // Add values to the required input fields
        const emailInput = screen.getByTestId('email-input')
        userEvent.type(emailInput, email);

        const firstNameInput = screen.getByTestId('first-name-input');
        userEvent.type(firstNameInput, firstName);

        const lastNameInput = screen.getByTestId('last-name-input');
        userEvent.type(lastNameInput, lastName);

        const passwordInput = screen.getByTestId('password-input');
        userEvent.type(passwordInput, password);
        const confirmPasswordInput = screen.getByTestId('confirm-password-input');
        userEvent.type(confirmPasswordInput, password);

        // Click on the Submit button
        await act(async () => {
            userEvent.click(screen.getByTestId('register-submit-button'));

            // verify the store was populated
            console.log(await store.getState())
        });
    });

So I was expecting my call to be intercepted whenever the REGISTER_API_ENDPOINT url is detected, and the value of the mocked call to be added to my redux state instead of the value of the actual API call in register method but that doesn't seem to be happening. If that's not the way to test a value in the store, how else can I achieve that?

So at the end of my test, when printing the store I was expecting to see:

{ auth: { user:
{
                'email': faker.internet.email(),
                'token': faker.datatype.uuid(),
                'active': true,
                'loggedIn': true,
            }
}

but instead I'm seeing:

 { auth: { user: null } }

Is this the right approach for this test?

Thanks


EDIT

Doing some refactoring based on the comments. Now my onSubmit method looks like:

const onSubmit = async data => {

        if (data.password !== data.confirmPassword) {
            console.log("Invalid password")
            setError('password', {
                type: "password",
                message: "Passwords don't match"
            })
            return;
        }

        // if we got up to this point we don't need to submit the password confirmation
        // todo but we might wanna pass it all the way through to the backend TBD
        delete data.confirmPassword

        let user = new User()
        await user.register(data).
        then(
            data => {
                // console.log("Response:")
                // console.log(data)
                // create cookies
                cookie.set("user", data.email);
                cookie.set("token", data.token);
                dispatch(createNewUser(data))
            }
        ).catch(err => console.log(err))

Notice that now I'm dispatching the response from User.register in here instead of doing it in User.register. Also notice that this function is now async and await for the register function call to be finalized, at that moment it'll populate the store.

The register method now looks like the following:

async register(data) {

        let res = await axios.post(REGISTER_API_ENDPOINT, {
             'email': data.email,
             'firstName': data.firstName,
             'lastName': data.lastName,
             'password': data.password
        })
            .then(function (response) {
                return response
            })
            .catch(function (error) {
                console.log('error');
                console.log(error);
            });

        return await res.data;
    }

now it's only in charge of performing the API call and returning the response.

The reducer was also simplified not to have any side effect changes, so it looks like:

const authReducer = (state = INITIAL_STATE, action) => {
    switch (action.type) {

        case actionTypes.REGISTER_NEW_USER:
            const newUser = action.payload
            return {
                ...state,
                'user': newUser
            }
        default:
            return state;
    }
}

my test is mostly the same, the only difference is the part where I'm inspecting the store value:

// Click on the Submit button
        await act(async () => {
            userEvent.click(screen.getByTestId('register-submit-button'));
        });

        await waitFor(() => {
            // verify the store was populated
            console.log("Store:")
            console.log(store.getState())
        })

Now, this sometimes work and sometimes does not. Meaning, sometimes I get correct store printed as follows:

 console.log
      Store:

      at test/pages/Register.test.js:219:21

    console.log
      {
        auth: {
          user: {
            email: 'Selena.Tremblay@hotmail.com',
            token: '1a0fadc7-7c13-433b-b86d-368b4e2311eb',
            active: true,
            loggedIn: true
          }
        }
      }

      at test/pages/Register.test.js:220:21

but sometimes I'm getting null:

 console.log
      Store:

      at test/pages/Register.test.js:219:21

    console.log
      { auth: { user: null } }

      at test/pages/Register.test.js:220:21

I guess I'm missing some async code somewhere but I cannot put a pin on where is it.

2 Answers

There are some Redux rules that are being broken here:

  1. Don't do side effects in reducers: reducers should be pure functions: for the same input, return always the same output. This is not the place to do API calls.
  2. State should be immutable: you should never change a state value by reference, always provide a new state with a new object containing the changes.

So, the classical redux approach would be to have three actions in Redux: REGISTER_USER, REGISTER_USER_SUCCEEDED, REGISTER_USER_FAILED .

reducer:

const authReducer = (state = INITIAL_STATE, action) => {
    switch (action.type) {

        case actionTypes.REGISTER_USER:
            return {
                ...state,
                status: 'loading'
            }
        case actionTypes.REGISTER_USER_SUCCEEDED:
            return {
                ...state,
                status: 'idle',
                user: action.user 
            }
        case actionTypes.REGISTER_USER_FAILED:
            return {
                ...state,
                status: 'error'
            }
        default:
            return state;
    }
}

Then, async work should be done in your event handlers:

onSubmit:

const onSubmit = async data => {
        // ...
        dispatch(registerNewUser());
        const user = new User()
        try {
          await user.register(data);
          dispatch(registerNewUserSucceeded(user));
        } catch(e) {
          console.error(e);
          dispatch(registerNewUserFailed());
        }
    }

**Don't forget to return the promise from axios inside your register function, so you can await on the promise. Currently, you are only calling axios, but not updating or returning anything...

What's great about this, is that testing your store doesn't require you to do any network calls! You could ditch MSW (although it's a great lib, just not needed here).

In your tests, just check your store state before and after every transition:

const mockUser = {...} // provide a mock user for your test
const store = createStore(authReducer);
store.dispatch(registerNewUserSucceeded(mockUser);
expect(store.getState()).toEqual({user: mockUser, status: 'idle'});

Edit

In response to the asker's edit, there is now a bug because of the confusing combination of await with .then. Specifically, in onSubmit, you are doing both await and .then on the same promise. In this case, there is a race condition. The .then call happens first, and after that the await happens. So instead of await user.register(data).then(...):

const onSubmit = async data => {
    // ...
    try {
        await user.register(data);
    } catch(e) {
        console.log(e);
    }
    dispatch(createNewUser(data));
}

Here I'm only using await. the try/catch clause is instead of calling .catch on the promise. using await lets you write as if you are writing synchronic code, so just write whatever you would put inside .then on the next line after an await expression.

Also in your register function:

async register(data) {
    try {
        let res = await axios.post(...);
        return res; 
    } catch(e) {
        console.log("error: ", e);
    }
}

The state won't be updated instantly, as the server call is a promise. You should await something on the page the indicates the process is complete like this:

        // Click on the Submit button
        await act(async () => {
            userEvent.click(screen.getByTestId('register-submit-button'));

            await wait(() => getByText('Some text that appears after success '));

            // verify the store was populated
            console.log(await store.getState())
        });

Or you can wait for the update:

        // Click on the Submit button
        await act(async () => {
            userEvent.click(screen.getByTestId('register-submit-button'));
            
            await act(() => sleep(500));

            // verify the store was populated
            console.log(await store.getState())
        });

Related