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
CodePudding user response:
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())
});
CodePudding user response:
There are some Redux rules that are being broken here:
- 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.
- 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'});