Let’s start with the most obvious question – What is PropTech? The name PropTech is an amalgamation of the words “property” and “technology”, and is also commonly referred to […]
When I’ve started my journey with React learning, using tools like redux, redux-thunks, reselect, or different helpful libraries came up really easy, except unit testing. Every time I’ve started writing tests for a new application I’ve dropped doing it after testing some amount of components. Mostly because I wasn’t convinced that my tests are useful for the software development cycle. The reason that happened is actually really trivial. I’ve focused on testing stuff that doesn’t matter for the end-user. I’ve been mostly focused on testing implementation details like component’s local state changes, redux reducers, actions, and so on.
The main reason is the lack of knowledge and experience. If you don’t know how good tests look like it’s harder to build them by yourself.
What’s more, the library I’ve picked didn’t help. The enzyme library is great. However, offering as many possibilities for newcomers as it does may lead to the catastrophe…
So I wrote multiple implementation details tests of my components. But the further I go, the more I noticed how useless these tests are for me. Eventually, I stopped writing tests at all. However, after some time of not testing my applications, I’ve discovered a library released by Kent C. Dodds – React Testing Library. Thanks to its simplicity, React Testing Library helps you understand what is important for app users and what you should test.
I’ve read docs, wrote some tests, and realized that it actually works. So if you’ve been in the same situation and didn’t know how to write valuable tests this article is for you.
If you want to achieve confidence by unit testing, you have to start thinking like clients of your application do. Try to cover as many user’s flows for every story as possible. Testing the weirdest edge cases will be still better than focusing on covering 100% of every function in your code.
The plan is to implement the same app but with two ways of implementing state management. The first one will be using old fashioned redux and Class Components state, while the second one will be using function components with hooks and context API for the global app state. The goal is to have two apps which will work exactly the same but implemented using different approaches. The most important is to keep the same unit tests for both of them.
So imagine you’re getting a nice design from your Product Designer and requirements from the Project Manager and task for that can look pretty much like that:
Hi buddy, we have work to do for our client. We need to implement ASAP, to be honest, we needed it yesterday 🙂 Here’s the list of functionalities of our app:
If you are lucky and have requirements listed clearly you can immediately start with implementing features. Even better will be TDD approach – start with tests and then implement it in React code.
describe("App", () => { it("renders", () => { render(<Main />); }); it("it has scene with sky and ground", () => { const { getByTestId } = render(<Main />); expect(getByTestId("Sky")).not.toBeNull(); expect(getByTestId("Ground")).not.toBeNull(); }); it("has only one rocket on app init", () => { const { getAllByTestId } = render(<Main />); expect(getAllByTestId("Rocket").length).toBe(1); }); it("should add rocket on button click", () => { const { getAllByTestId, getByText } = render(<Main />); expect(getAllByTestId("Rocket").length).toBe(1); fireEvent.click(getByText(/add rocket/i)); expect(getAllByTestId("Rocket").length).toBe(2); }); it("shouldn't allow to have more that 4 rockets", () => { const { getAllByTestId, getByText } = render(<Main />); const addBtn = getByText(/add rocket/i); expect(getAllByTestId("Rocket").length).toBe(1); fireEvent.click(addBtn); // add 2 fireEvent.click(addBtn); // add 3 fireEvent.click(addBtn); // add 4 expect(addBtn).toBeDisabled(); fireEvent.click(addBtn); // add 4 expect(getAllByTestId("Rocket").length).toBe(4); }); it("should allow remove rockets if more than one", () => { const { getAllByTestId, getByText } = render(<Main />); const rmBtn = getByText(/remove rocket/i); const addBtn = getByText(/add rocket/i); expect(rmBtn).toBeDisabled(); fireEvent.click(addBtn); // add rocket fireEvent.click(addBtn); // add rocket fireEvent.click(addBtn); // add rocket expect(getAllByTestId("Rocket").length).toBe(4); fireEvent.click(rmBtn); expect(getAllByTestId("Rocket").length).toBe(3); fireEvent.click(rmBtn); expect(getAllByTestId("Rocket").length).toBe(2); fireEvent.click(rmBtn); expect(getAllByTestId("Rocket").length).toBe(1); fireEvent.click(rmBtn); expect(getAllByTestId("Rocket").length).toBe(1); }); it("changes toggles night/day mode", () => { const { getByTestId } = render(<Main />); const ground = getByTestId("Ground"); const sky = getByTestId("Sky"); const btn = getByTestId("LightModeBtn"); expect(ground).toHaveClass("ground--day"); expect(sky).toHaveClass("sky--day"); expect(btn).toHaveTextContent(/night mode: on/i); fireEvent.click(btn); expect(ground).toHaveClass("ground--night"); expect(sky).toHaveClass("sky--night"); expect(btn).toHaveTextContent(/night mode: off/i); }); it("changes rocket color on click", () => { utils.getRandomRGB = jest.fn().mockReturnValueOnce("red"); const { getByTestId } = render(<Main />); const rocket = getByTestId("Rocket"); describe("App", () => { it("renders", () => { render(<Main />); }); it("it has scene with sky and ground", () => { const { getByTestId } = render(<Main />); expect(getByTestId("Sky")).not.toBeNull(); expect(getByTestId("Ground")).not.toBeNull(); }); it("has only one rocket on app init", () => { const { getAllByTestId } = render(<Main />); expect(getAllByTestId("Rocket").length).toBe(1); }); it("should add rocket on button click", () => { const { getAllByTestId, getByText } = render(<Main />); expect(getAllByTestId("Rocket").length).toBe(1); fireEvent.click(getByText(/add rocket/i)); expect(getAllByTestId("Rocket").length).toBe(2); }); it("shouldn't allow to have more that 4 rockets", () => { const { getAllByTestId, getByText } = render(<Main />); const addBtn = getByText(/add rocket/i); expect(getAllByTestId("Rocket").length).toBe(1); fireEvent.click(addBtn); // add 2 fireEvent.click(addBtn); // add 3 fireEvent.click(addBtn); // add 4 expect(addBtn).toBeDisabled(); fireEvent.click(addBtn); // add 4 expect(getAllByTestId("Rocket").length).toBe(4); }); it("should allow remove rockets if more than one", () => { const { getAllByTestId, getByText } = render(<Main />); const rmBtn = getByText(/remove rocket/i); const addBtn = getByText(/add rocket/i); expect(rmBtn).toBeDisabled(); fireEvent.click(addBtn); // add rocket fireEvent.click(addBtn); // add rocket fireEvent.click(addBtn); // add rocket expect(getAllByTestId("Rocket").length).toBe(4); fireEvent.click(rmBtn); expect(getAllByTestId("Rocket").length).toBe(3); fireEvent.click(rmBtn); expect(getAllByTestId("Rocket").length).toBe(2); fireEvent.click(rmBtn); expect(getAllByTestId("Rocket").length).toBe(1); fireEvent.click(rmBtn); expect(getAllByTestId("Rocket").length).toBe(1); }); it("changes toggles night/day mode", () => { const { getByTestId } = render(<Main />); const ground = getByTestId("Ground"); const sky = getByTestId("Sky"); const btn = getByTestId("LightModeBtn"); expect(ground).toHaveClass("ground--day"); expect(sky).toHaveClass("sky--day"); expect(btn).toHaveTextContent(/night mode: on/i); fireEvent.click(btn); expect(ground).toHaveClass("ground--night"); expect(sky).toHaveClass("sky--night"); expect(btn).toHaveTextContent(/night mode: off/i); }); it("changes rocket color on click", () => { utils.getRandomRGB = jest.fn().mockReturnValueOnce("red"); const { getByTestId } = render(<Main />); const rocket = getByTestId("Rocket"); expect(rocket).toHaveStyle("fill: red"); fireEvent.click(rocket); expect(rocket).toHaveStyle("fill: yellow"); }); }); expect(rocket).toHaveStyle("fill: red"); fireEvent.click(rocket); expect(rocket).toHaveStyle("fill: yellow"); }); });
So we wrote our tests and now we can start to implement it with redux and class components.
We will start by writing our reducer and actions as we made the decision to manage our global state with redux.
export const reducer = (state = {}, action) => { switch (action.type) { case 'ADD_ROCKET': return { ...state, rockets: [...state.rockets, state.rockets.length + 1] } case 'REMOVE_ROCKET': const rockets = [...state.rockets] rockets.pop() return { ...state, rockets } case 'TOGGLE_NIGHT_MODE': return { ...state, nightMode: !state.nightMode } default: return state } } const addRocket = () => ({ type: 'ADD_ROCKET' }) const removeRocket = () => ({ type: 'REMOVE_ROCKET' }) const toggleNightMode = () => ({ type: 'TOGGLE_NIGHT_MODE' })
class RocketStation extends React.Component { render() { const { rockets, addRocket, removeRocket, toggleNightMode, nightMode } = this.props return ( <div className="App"> <Scene nightMode={nightMode}> {rockets.map(id => ( <Rocket key={id} /> ))} </Scene> <div className="actions"> <Button variant={'success'} onClick={addRocket} disabled={rockets.length === 4}> Add rocket </Button> <Button variant={'danger'} onClick={removeRocket} disabled={rockets.length === 1}> Remove rocket </Button> <Button data-testid="LightModeBtn" variant={'navy'} onClick={toggleNightMode}> {`Night mode: ${nightMode ? 'OFF' : 'ON'}`} </Button> </div> </div> ) } } const mapStateToProps = state => ({ rockets: state.rockets, nightMode: state.nightMode }) const mapDispatchToProps = { addRocket, removeRocket, toggleNightMode } const Main = connect( mapStateToProps, mapDispatchToProps )(RocketStation)
export const initialState = { rockets: [1], nightMode: false } const store = createStore(reducer, initialState) function App() { return ( <Provider store={store}> <Main /> </Provider> ) }
class Rocket extends React.Component { constructor(props) { super(props) this.state = { color: getRandomRGB() } this.changeColor = this.changeColor.bind(this) } changeColor() { this.setState({ color: getRandomRGB() }) } render() { return ( <RocketIcon data-testid="Rocket" onClick={this.changeColor} style={{ fill: this.state.color }} /> ) } }
But after implementation like that, some of our tests may fail. So we will make a few adjustments.
We’re getting this dirty error:
Could not find “store” in the context of “Connect(RocketStation)”. Either wrap the root component in a <Provider>, or pass a custom React context provider to <Provider> and the corresponding React context consumer to Connect(RocketStation) in connect options.
And that’s because we are using redux and we need to include redux <Provider> to our tests as well. To do this we will override render method from @testing-library/react
import { render as rtlRender } from '@testing-library/react' import { createStore } from 'redux' import { Provider } from 'react-redux' function render( ui, { state = initialState, store = createStore(reducer, state) } = {} ) { return { ...rtlRender(<Provider store={store}>{ui}</Provider>), store } } test('renders', () => { render(<Main />) })
import * as utils from "../utils"; it('changes rocket color on click', () => { utils.getRandomRGB = jest.fn().mockReturnValueOnce('red') const { getByTestId } = render(<Main />) const rocket = getByTestId('Rocket') expect(rocket).toHaveStyle('fill: red') utils.getRandomRGB = jest.fn().mockReturnValueOnce('yellow') fireEvent.click(rocket) expect(rocket).toHaveStyle('fill: yellow') })
Now the coolest part. We will rewrite our app from React Class Components to function ones and instead of redux for the global state, we will use Context API. After that our tests should pass the tests without changing them AT ALL. Sounds great? Let’s do it.
Let’s start with declaring the context and it’s provider – <AppProvider>:
const MainContext = React.createContext() export function AppProvider({ children }) { const [state, dispatch] = useReducer(reducer, initialState) return ( <MainContext.Provider value={{ rockets: state.rockets, nightMode: state.nightMode, addRocket: () => dispatch({ type: 'ADD_ROCKET' }), removeRocket: () => dispatch({ type: 'REMOVE_ROCKET' }), toggleNightMode: () => dispatch({ type: 'TOGGLE_NIGHT_MODE' }) }}> {children} </MainContext.Provider> ) }
function RocketStation() { const { rockets, addRocket, removeRocket, toggleNightMode, nightMode } = useContext(MainContext) return ( <div className="App"> <Scene nightMode={nightMode}> {rockets.map(id => ( <Rocket key={id} /> ))} </Scene> <div className="actions"> <Button variant={'success'} onClick={addRocket} disabled={rockets.length === 4}> Add rocket </Button> <Button variant={'danger'} onClick={removeRocket} disabled={rockets.length === 1}> Remove rocket </Button> <Button data-testid="LightModeBtn" variant={'navy'} onClick={toggleNightMode}> {`Night mode: ${nightMode ? 'OFF' : 'ON'}`} </Button> </div> </div> ) }
function Rocket() { const [color, changeColor] = useState(getRandomRGB()) return ( <RocketIcon data-testid="Rocket" onClick={() => { console.log('click') changeColor(getRandomRGB()) }} style={{ fill: color }} /> ) }
function render(ui) { return { ...rtlRender(<AppProvider>{ui}</AppProvider>) }; }
I think I don’t even have to write much because as you’ve already seen, not focusing on implementation details is a huge benefit for your unit tests. You don’t have to struggle with testing how an app works under the hood but how it works for clients. The process of writing tests gets nicer and event TDD approach, which I recommend you to try, is easier too.
As long as the app still provides the same experience for users, you can even completely rewrite the way of implementation. It lets you refactor the codebase without any risk of the broken product.
App with Redux Class Components approach – Link to Condesandbox!
App with Hooks and Context API – Link to Codesandbox!