Testing React connected functional components with typescript
I was never fan of tests on front-end, but fortunately it changed over time. I see big value in having automated tests regarding functionality of components. By that I mean checking if our state is correctly updated and our functions and requests are executed in proper sequence. I don't like checking exact rendering style (except that component can render without error), so testing snapshots or checking positions is not for me.
Starting with React I was fond of functional components and their hooks, and still I prefer it over class components. When I started testing there always seems to be a lot of problems and questions about how to check state of functional component or how to check if our redux store has been updated. Searching through web I've seen many topics and not answered questions, but after some time I managed to find good hybrid solution - connecting hints from many repositories and discussions. That's why I wanted to share it, to provide one source for fully testing functional components and I hope it can help someone in his fight for better code...
My component
I prepared simple component form with 3 fields. It contains my custom implementation of @material-ui components. In fields you can select bike brand, type and color (2 single selects and 1 radio buttons). Button next is saving data to redux and is inactive until all fields are filled. Each time data in field is changed, it clears fields located below and executes fetching of fields options (ie if you select brand like Kross it can have limited bike types and colors, which can not be available in Specialized bikes). We don't have back-end here, so url for fetchning bike options is faulty, which is not stopping us from testing it. I tried to use a lot of variants of useEffects and setStates, just to present test functionality. There are some comments included in whole code, to point important places.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { useState, FC, useEffect } from "react" | |
import { CommonSingleSelect } from "../../components/CommonSingleSelect"; | |
import { CommonRadioGroup } from "../../components/CommonRadioGroup"; | |
import { connect } from "react-redux"; | |
import { BikeDataType, BikeOptionsType, saveExampleData, fetchExampleOptions } from "./redux/Actions"; | |
import { CommonButton } from "../../components/CommonButton"; | |
type Props = DispatchProps & StateProps; | |
const ExampleForm: FC<Props> = (props: Props) => { | |
const [state, setState] = useState(props.selectedBike); | |
const [brands, setBrands] = useState(props.bikeData?.brands); | |
const [types, setTypes] = useState(props.bikeData?.types); | |
const [colors, setColors] = useState(props.bikeData?.colors); | |
useEffect(() => { | |
props.fetchExampleOptions(state); | |
}, | |
// eslint-disable-next-line react-hooks/exhaustive-deps | |
[]); | |
useEffect(() => { | |
setState(props.selectedBike); | |
props.fetchExampleOptions(state); | |
}, | |
// eslint-disable-next-line react-hooks/exhaustive-deps | |
[props.selectedBike]) | |
useEffect(() => { | |
setBrands(props.bikeData?.brands); | |
}, [props.bikeData?.brands]); | |
useEffect(() => { | |
setTypes(props.bikeData?.types); | |
}, [props.bikeData?.types]); | |
useEffect(() => { | |
setColors(props.bikeData?.colors); | |
}, [props.bikeData?.colors]); | |
useEffect(() => { | |
props.fetchExampleOptions(state); | |
}, | |
// eslint-disable-next-line react-hooks/exhaustive-deps | |
[state.brand, state.color, state.type]); | |
const onBikeBrandChange = (): void => { | |
setState(prevState => { | |
return { | |
...prevState, | |
type: '', | |
color: '' | |
} | |
}) | |
} | |
const onBikeTypeChange = (): void => { | |
setState(prevState => { | |
return { | |
...prevState, | |
color: '' | |
} | |
}) | |
} | |
const nextStep = (): void => { | |
props.saveExampleData(state); | |
alert(`Save data: ${JSON.stringify(state)}`); | |
} | |
const isNextButtonDisabled = (): boolean => { | |
return isStringEmpty(state.brand) || isStringEmpty(state.type) || isStringEmpty(state.color); | |
} | |
const isStringEmpty = (value: string): boolean => { | |
return !value || value.length === 0; | |
} | |
return ( | |
<div> | |
<CommonSingleSelect | |
className='inputField' | |
label={'Bike brand'} | |
options={brands ?? ['Brand1', 'Brand2']} | |
selectedValue={state.brand} | |
setSelectedValue={val => setState(prevState => { return { ...prevState, brand: val } })} | |
onChange={onBikeBrandChange} | |
/> | |
<CommonSingleSelect | |
className='inputField' | |
label={'Bike type'} | |
options={(types ?? ['Type1', 'Type2'])} | |
selectedValue={state.type} | |
setSelectedValue={val => setState(prevState => { return { ...prevState, type: val } })} | |
onChange={onBikeTypeChange} | |
/> | |
<CommonRadioGroup | |
className='inputField' | |
options={colors ?? ['Color1', 'Color2']} | |
selectedValue={state.color} | |
setSelectedValue={val => | |
setState(prevState => { return { ...prevState, color: val } }) | |
} | |
label={'Bike color'} | |
/> | |
<CommonButton | |
type={'button'} | |
onClick={nextStep} | |
disabled={isNextButtonDisabled()} | |
> | |
NEXT | |
</CommonButton> | |
</div> | |
); | |
}; | |
const mapStateToProps = (state: any) => { | |
return { | |
selectedBike: state.example.selectedBike as BikeDataType, | |
bikeData: state.example.bikeOption as BikeOptionsType, | |
}; | |
}; | |
const mapDispatchToProps = (dispatch: Function) => ({ | |
saveExampleData: (data: BikeDataType) => dispatch(saveExampleData(data)), | |
fetchExampleOptions: (selectedBike: BikeDataType) => dispatch(fetchExampleOptions(selectedBike)) | |
}); | |
type StateProps = ReturnType<typeof mapStateToProps>; | |
type DispatchProps = ReturnType<typeof mapDispatchToProps>; | |
export default connect(mapStateToProps, mapDispatchToProps)(ExampleForm); |
Unifying tests
Common container
To ensure that all of my tests would use the same configuration I created common container. Here I needed only redux provider, but in case you use ie translation or any other libraries, which must be run along with your app, it is much easier to make it once and develop once any configuration or library changes.
Here I also made override for browser window variable, because sometimes they are used in components. In my example I'm setting window.location.origin (not used here, but can be handy) and window.alert (I use it in normal application to show saved state).
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { FC, PropsWithChildren, default as React } from 'react'; | |
import { Provider } from 'react-redux'; | |
interface TestComponentContainerProps { | |
store: any; | |
} | |
type Props = PropsWithChildren<TestComponentContainerProps> | |
export const TestComponentContainer: FC<Props> = (props: Props) => { | |
//if you need to mock you window properties for test | |
Object.defineProperty(window, 'location', { | |
writable: true, | |
value: { | |
origin: 'http://test' | |
} | |
}); | |
Object.defineProperty(window, 'alert', { | |
writable: true, | |
value: jest.fn() | |
}); | |
return ( | |
<Provider store={props.store}> | |
{props.children} | |
</Provider> | |
) | |
}; |
Setting up store - what with state ?
As you probably know redux-mock-store does not implement reducers, so our actions can't update state. Then it is real problem to properly test your component and updating component state, ie when it is updated after change of value in store.
Fortunately there is the solution. Normally in MockStoreCreator we simply put our test state and in configuration we're not setting any middleware.
First thing is of course setting middleware (in my case thunk), so our actions can be properly dispatched. Then we can use a "trick" and instead of putting state in creator, we can make it function, which will call at the end reduce method. Below I present my common test store configuration, in which we can also put some common state, if it is needed to properly run our components:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import configureStore from 'redux-mock-store'; | |
import thunk from 'redux-thunk'; | |
import rootReducer from '../../redux/reducers/RootReducer'; | |
const testInitialStoreData = { | |
//if you have some common settings | |
}; | |
export const getTestStore = (store: any) => { | |
const mockStore = configureStore([thunk]); | |
//way to make your dispatched actions are executed by reducers | |
const createState = (initialState: any) => (actions: any) => | |
actions.reduce(rootReducer, initialState); | |
return mockStore(createState({ | |
...store, | |
...testInitialStoreData | |
})); | |
}; |
Building a store in test file is simple, we just create our initial state and execute method to create our test store:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const localStore = { | |
example: { | |
bikeOptions: { | |
brands: [], | |
colors: [], | |
types: [] | |
}, | |
selectedBike: { | |
brand: '', | |
type: '', | |
color: '' | |
} | |
} | |
}; | |
store = getTestStore(localStore); |
Async useEffect - how to make it work?
useEffect is asynchronous function, which causes a lot of trouble when testing. Many developers don't know, but this function has it's synchronous equivalent called useLayoutEffect. For project components I wouldn't recommend to overuse it, but in case of tests it comes as redemption.
Now we have only to create __mocks__ directory and in reacj.js put mocked import from React:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//for test we need to mock useEffect by useLayoutEffect so changes are happening | |
// synchronously after updating component | |
const React = jest.requireActual('react'); | |
module.exports = { ...React, useEffect: React.useLayoutEffect }; |
Async request - can we wait for it ?
Answer is simple - yes we can. With help of setImmediate, which runs callback functions immediately, we can just await in test, for our promise to end.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export const waitForPromises = async () => { | |
return new Promise(setImmediate); | |
}; |
Is action fired - how to test ?
Our test mockStore is providing getActions method, which we can use to check what actions were dispatched and what was they're type and data. That's why, to not duplicate code, I prepared common method using deepEqual method:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { MockStore } from 'redux-mock-store'; | |
import deepEqual from 'deep-equal'; | |
export const isActionFired = | |
(store: MockStore<any>, action: any): boolean => { | |
return store.getActions().some((storeAction: any) => | |
deepEqual(storeAction, action) | |
); | |
}; |
Mocking requests
One of the most common topics is mocking request. We can use there jest.mock and then use method mockReturnValue on our method:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
jest.mock( | |
'../../main/exampleForm/service/ExampleFormService', | |
() => { | |
return { | |
getBikeDataRequest: jest.fn() | |
} | |
}); | |
//mock return function to return anything we need for all tests | |
(getBikeDataRequest as jest.Mock).mockReturnValue(response); | |
const fetchData: BikeOptionsType = { | |
brands: expectedBrands, | |
types: expectedTypes, | |
colors: expectedColors | |
}; | |
//mock response for current test | |
(getBikeDataRequest as jest.Mock).mockReturnValue(fetchData); |
We can test !
Now we can simply write our testing scenarios using act method of react-test-renderer. Only thing which we must remember is to put every action in separate act method and always await for result of it. So our test rendered component can update up to our checking part. Below you can find one of my test. Whole described here examples and working code you can find on: https://github.com/novakDev/react-full-testing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const selectedBikeMock: BikeDataType = { | |
brand: 'Kross', | |
type: 'MTB', | |
color: 'red' | |
}; | |
// we check if probided data are saved here to redux | |
it('should save provided data', async () => { | |
//GIVEN | |
const selectedBike = selectedBikeMock; | |
//WHEN | |
await fillBikeData(selectedBike); | |
await TestRenderer.act(async () => { | |
component.root.findByType(CommonButton).props.onClick(); | |
}); | |
const actualReduxSelectedBike = store.getState().example.selectedBike; | |
//THEN | |
expect(isActionFired(store, saveExampleDataAction(selectedBike))).toBeTruthy(); | |
expect(isActionFired(store, fetchExampleOptionsAction(response))).toBeTruthy(); | |
expect(actualReduxSelectedBike).toStrictEqual(selectedBike); | |
}); | |
//normally we should have there constants so when label is changed test is still passing | |
// or provide test-id | |
const fillBikeData = async (selectedBike: BikeDataType) => { | |
await TestRenderer.act(async () => { | |
component.root.findByProps({ label: 'Bike brand' }) | |
.props.setSelectedValue(selectedBike.brand); | |
}); | |
await TestRenderer.act(async () => { | |
component.root.findByProps({ label: 'Bike type' }) | |
.props.setSelectedValue(selectedBike.type); | |
}); | |
await TestRenderer.act(async () => { | |
component.root.findByProps({ label: 'Bike color' }) | |
.props.setSelectedValue(selectedBike.color); | |
}); | |
} |
Comments
Post a Comment