I am using React together with Redux thunks to manage the global state in my application. I have a component that receives one of it's variables from the Redux store. This variable is used to trigger a useEffect which dispatches an API call.
I am writing some tests for this workflow. I want my tests to check that the useEffect is triggered when the Redux store variable is updated.
This is an example of my component:
import React, { useEffect, useMemo, useState } from "react";
import { useAppDispatch } from "../hooks/useAppDispatch";
import { useAppSelector } from "../hooks/useAppSelector";
import { fetchData, storeFilters } from "../slices/dataSlice";
import { filtersSelector } from "../selectors";
const MyComponent = () => {
const dispatch = useAppDispatch();
const { filters } = useAppSelector(filtersSelector);
const [year, setYear] = useState((new Date()).getFullYear());
const [period, setPeriod] = useState("all");
useEffect(() => {
console.log("Dispatching fetchData: ", filters);
dispatch(
fetchData({
filters: filters
})
);
}, [filters]);
const changeYear = (e) => {
console.log("Year was changed: ", e.target.value);
setYear(parseInt(e.target.value));
}
const changePeriod = (e) => {
console.log("Period was changed: ", e.target.value);
setPeriod(e.target.value);
}
const applyFilters = () => {
const newFilters = {
year: year,
period: period
};
console.log("Apply button was clicked, dispatching filters to store: ", newFilters);
dispatch(
storeFilters(newFilters)
);
}
const years = useMemo(() => {
const thisYear = (new Date()).getFullYear();
const yearArr = [];
for (let year = thisYear; year < thisYear + 11; year++) {
yearArr.push(year);
}
return yearArr
}, []);
return (
<div className="componentContainer s-componentContainer">
<div className="filtersContainer s-filtersContainer">
<select name="year" id="yearSelect s-yearSelect" onChange={changeYear}>
{years.map(y => <option key={y} className="selectYearOption s-selectYearOption" value={y}>{y}</option>)}
</select>
<select name="period" id="periodSelect s-periodSelect" onChange={changePeriod}>
<option className="selectPeriodOption s-selectPeriodOption" value="all">All</option>
<option className="selectPeriodOption s-selectPeriodOption" value="winter">Winter</option>
<option className="selectPeriodOption s-selectPeriodOption" value="spring">Spring</option>
<option className="selectPeriodOption s-selectPeriodOption" value="summer">Summer</option>
<option className="selectPeriodOption s-selectPeriodOption" value="autumn">Autumn</option>
</select>
<button onClick={applyFilters}>Apply Filters</button>
</div>
<div className="mainContainer s-mainContainer">
{Object.keys(filters).map(objKey => <div key={objKey}>{objKey} value selected: {filters[objKey]}</div>)}
</div>
</div>
);
};
export default MyComponent;
This works in a regular browser with the store updated as it should and the component and useEffect triggered as expected. But when running tests, this does not trigger correctly.
In a browser I can see that all console.log(...); are printed correctly and as expected. I can also see in the network tab that the API call is made when the page is loaded and when the store is updated.
The store and slice functions are written as follows:
import { createSlice } from "@reduxjs/toolkit";
import { thunkFactory } from "../utils/ThunkFactory";
import AppConstants from "../utils/AppConstants";
interface Filters {
year: number;
period: string;
}
interface Notification {
key: string;
type: string;
}
interface FiltersSliceState {
loading: boolean;
notification: Notification | null;
filters: Filters;
}
export const initialState: FiltersSliceState = {
loading: false,
notification: null,
filters: {
year: (new Date()).getFullYear(),
period: "all"
}
};
export const fetchData = () => {
console.log("Recieved fetch call, making actual call")
return thunkFactory("fetchDataThunk", AppConstants.API_URL + "/data/all", "get");
};
const filtersSlice = createSlice({
name: "filters",
initialState: initialState,
reducers: {
storeValues: (state, { payload }) => {
console.log("Received store filters call, storing filter values: ", payload);
state.filters = payload;
}
},
extraReducers: (builder) => {
builder
.addCase(fetchData.pending, (state) => {
state.loading = true;
state.notification = null;
})
.addCase(fetchData.fulfilled, (state, action) => {
console.log("Data received: ", action.payload);
// Do something with action.payload
state.loading = false;
state.notification = null;
})
.addCase(fetchData.rejected, (state, action) => {
state.loading = false;
state.notification = { key: action.payload as string, type: "ERROR" };
});
}
});
export const { storeValues } = filtersSlice.actions;
export default filtersSlice.reducer;
As before this works as expected in the browser.
So I have written my test like this:
import React from "react";
import configureStore from "redux-mock-store";
import thunk from "redux-thunk";
import { act } from "react-dom/test-utils";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Provider } from "react-redux";
import MyComponent from "../components/MyComponent";
export const filtersMock = {
loading: false,
notification: null,
filters: {
period: "all"
}
};
const mockStore = configureStore([thunk]);
export const getMockStore = () => mockStore({ filters: filtersMock });
describe("MyComponent", () => {
let store;
const getRenderedComponent = () => {
store = getMockStore();
return render(
<Provider store={store}>
<MyComponent />
</Provider>
);
};
it("updating filters triggers new data fetch", async () => {
const { baseElement } = getRenderedComponent();
const selectField = baseElement.querySelector("#s-periodSelect");
await waitFor(() => {
act(() => {
fireEvent.click(selectField);
});
});
const options = baseElement.querySelectorAll(".s-selectOption");
const selectOption = options[1];
await waitFor(() => selectOption);
await waitFor(() => {
act(() => {
fireEvent.click(selectOption);
});
});
const applyButton = await screen.findByText(/Apply Filters/i);
await waitFor(() => {
act(() => {
fireEvent.click(applyButton);
});
});
});
Comparing the flow with printed messages from the browser and the test, the following happens:
| Action | Browser result | Jest testing result |
|---|---|---|
| Page loads | OK | OK |
useEffect triggered first time (because of component load) |
OK | OK |
"Dispatching fetchData ..." message logged |
OK | OK |
| Call to slice function to make API call | OK | OK |
"Recieved fetch call ..." message logged |
OK | OK |
| API call made | OK | Assume OK, not implemented in test |
"Data received: ..." message logged |
OK | Assume OK, not implemented in test |
| Period selected in the dropdown | OK | OK |
"Period was changed: ..." message logged |
OK | OK |
| "Apply Filters" button clicked | OK | OK |
"Apply button was clicked ..." message logged |
OK | OK, data looks OK (correctly changed period) |
| Call made to Redux store to update filters | OK | Unknown |
"Received store filters call ..." message logged |
OK | Not OK |
| Filters updated in store, broadcasting store change | OK | Not OK |
Component useAppSelector received store change, updated filters value |
OK | Not OK |
useEffect triggered second time (because of filter variable changed) |
OK | Not OK |
"Dispatching fetchData ..." message logged |
OK | Not OK |
| Call to slice function to make API call | OK | Not OK |
"Recieved fetch call ..." message logged |
OK | Not OK |
| API call made | OK | Assume not OK, not implemented in test |
So my question here is: Why is the dispatch call to the redux store either not dispatched by the component or not received by the store?