paint-brush
How React Testing Library Can Improve Your Mental Health [Part 2]by@mac
774 reads
774 reads

How React Testing Library Can Improve Your Mental Health [Part 2]

by Mac WasilewskiDecember 23rd, 2020
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

The key is to find the action type to set to the current todo element. We want to call appReducer with an initialState object, and a second argument. This one should be an object with type = “todos/todoAdded” and a payload. And then make sure that the result contains all previous todo with the one we passed as payload. Let’s go to nextTodoId (todos.reduce(maxId, todo) and test for that in the assertion.

Companies Mentioned

Mention Thumbnail
Mention Thumbnail
featured image - How React Testing Library Can Improve Your Mental Health [Part 2]
Mac Wasilewski HackerNoon profile picture

You can find the first part here: Why testing?

Let’s continue writing our Redux tests. The file we are testing is:

const initialState = {
   todos: [
       { id: 1, text: 'Do boring stuff', completed: false, color: 'purple' },
       { id: 2, text: 'Write tests!', completed: false, color: 'blue' }
   ],
   filters: {
       status: 'All',
       colors: []
   }
}
function nextTodoId(todos) {
const maxId = todos.reduce((maxId, todo) => Math.max(todo.id, maxId), -1)
return maxId + 1
}
export default function appReducer(state = initialState, action) {
switch (action.type) {
case 'todos/todoAdded':
           {
return {
                   ...state,
                   todos: [
                       ...state.todos,
                       {
                           id: nextTodoId(state.todos),
                           text: action.payload,
                           completed: false
                       }
                   ]
               }
           }
case 'todos/todoToggled':
           {
return {
                   ...state,
                   todos: state.todos.map(todo => {
if (todo.id !== action.payload) {
return todo
                       }
return {
                           ...todo,
                           completed: !todo.completed
                       }
                   })
               }
           }
case 'filters/statusFilterChanged':
           {
return {
// Copy the whole state
                   ...state,
// Overwrite the filters value
                   filters: {
// copy the other filter fields
                       ...state.filters,
// And replace the status field with the new value
                       status: action.payload
                   }
               }
           }
default:
return state
   }
}

Like we said earlier testing redux reducers is simple. We need to make sure that the function updates the current state as we expect it to.
So in the below case:

        case 'todos/todoAdded':
            {
                return {
                    ...state,
                    todos: [
                        ...state.todos,
                        {
                            id: nextTodoId(state.todos),
                            text: action.payload,
                            completed: false
                        }
                    ]
                }
            }

We want to call appReducer with an initialState object, and a second argument. This one should be an object with type = “todos/todoAdded” and a payload which is whatever string we want. And then make sure that the result contains all previous todo with the one we passed as payload. Let’s have a look at the test:

    it('should add a todo if the action type equals to todos/todoAdded', () => {
        const appReducerCall = appReducer(
          initialState, 
          { 
            type: 'todos/todoAdded', 
            payload: 'Buy beer' 
          });
        const result = {
          ...initialState, 
          ...initialState.todos.push({ 
            id: 3, 
            text: 'Buy beer', 
            completed: false
          }) 
        };
        expect(appReducerCall).toEqual(result)
    })

And let’s have a quick look at our code coverage:

See? Now the todos/todoAdded has full coverage. Notice that our "private" (not exported) function nextTodoId has coverage as well. Check line 2.

Some people believe in testing private functions, I don’t. It is an internal implementation and all we care for here is that the id has been increased, and we test for that in the assertion.

If you look at the initial state it has two todos, we add a third one and the id should be 3. In the future, someone might rename this internal function but the test will still pass, and that’s what we want.


Let’s go to next todos/todoToggled. Have a look at it.

        case 'todos/todoToggled':
            {
                return {
                    ...state,
                    todos: state.todos.map(todo => {
                        if (todo.id !== action.payload) {
                            return todo
                        }

                        return {
                            ...todo,
                            completed: !todo.completed
                        }
                    })
                }
            }

The key is to find the action.payload. So what happens is that we loop through all todos array using the map Array function. Then if the current todo.id element equals to the payload it returns the current todo.

In summary, we call appReducer with type set to todos/todoToggled and the payload being an existing id. For this test we might want some initial state:


Have a look at the below test:

    it('should change the completed to opposite value when calling todos/todoToggled with existing id', () => {
        const appReducerCall = appReducer(
            initialState, 
            { type: 'todos/todoToggled', payload: 1 }
        );
        const result = {
            ...initialState,
            ...initialState.todos[0].completed = true
        };
        expect(appReducerCall).toEqual(result)
    })

And the code coverage:

And we’ve got the last case to test! Let’s give it a go-to get our first full 100%!

This time we call filters/statusFilterChanged.

The only thing that happens is that we override the filter’s status property by passing any payload. Simple! Let’s pass the correct type and then the payload and assert that the new state has our new passed state.

    it('should change the filter status of the current state using the payload', () => {
        const appReducerCall = appReducer(
            initialState, 
            { type: 'filters/statusFilterChanged', payload: 'status' }
        );
        const result = {
            ...initialState,
            filters: {
                status: 'status',
                colors: []
            }
        };
        expect(appReducerCall).toEqual(result)
    })

Now, look at that! Full 100% coverage of our reducer.js code!

We are done here, let's move to components.

@testing-library/react

Now finally let’s start using react-testing-library!

To start please run

npm i --save-dev @testing-library/react

This will give you access to the render and the selectors which the library provides.

The problem the library is solving is according to the website:

You want to write maintainable tests for your React components. As a part of this goal, you want your tests to avoid including implementation details of your components and rather focus on making your tests give you the confidence for which they are intended. As part of this, you want your testbase to be maintainable in the long run so refactors of your components (changes to implementation but not functionality) don't break your tests and slow you and your team down.


It makes sense. It is the same philosophy we used to ‘test’ our private nextTodoId function in redux. We do not care about implementation details, we just want to be sure that our app works when we change something. This philosophy works great with BDD, so Behavior Driven Development.

In this approach to testing, we construct our tests according to the story requirements. For example: As a user, I want to disable the button. Our tests should not care what functions we call, we want to make sure that the button gets disabled. So we can change dispatched actions, state, some props but as long as the user is able to disable the button we are happy.

Let’s move on! This is the component we will start with:

import React from 'react';
import { Provider } from 'react-redux';
import store from './store/store'
import EntryComponent from './EntryComponent'

function App() {

  return (
    <Provider store={store}>
      <EntryComponent />
    </Provider>
  );
}

export default App;

As we can see this is a basic entry component with a Provider. There is not much to be tested here.

But we can use this example to introduce the

render 
method.

It is important to understand that when we test our components, we do not test them as they appear in the browser. Jest uses a browser to render our components, but as these are unit tests we only render the currently tested component.

So, in this case, we are going to render the whole app what will cause a lot of components to have low coverage

Let’s create an App.test.js file in the same directory as the App.js exist. And now we need to import several elements.

import React from 'react';
import { render } from '@testing-library/react';
import App from './App';
describe(‘App.js’, () => {
})

Pretty straightforward! The test is simple.

    it('should render the App component', () => {
        const { container } = render( < App / > );
        expect(container.firstChild).toBeTruthy();
    })


Let’s have a look at the above. The

render
method returns several constants, one of them is the container which is the result of the render.

We can access several properties of the

container 
and one of them is the firstChild which we use to assert that it is truthy. Obviously, it is.

What is interesting is that now we rendered more components which are children to the App.js. Let’s have a look at the coverage.

See? We have full App.js coverage, as there is not much to test but we’ve hit some other components.

Let’s open the EntryComponent.js and have a look at it.

import logo from './logo.svg';
import React, { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import './App.css';
import Todo from './Todo'

const selectTodos = state => state.todos;

function EntryComponent() {
  const todos = useSelector(selectTodos);
  const dispatch = useDispatch();
  const [todo, setTodo] = useState('');
  const addTodo = () => {
    setTodo('')
    dispatch({type: 'todos/todoAdded', payload: todo});
  }
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <div style={{display: 'flex'}}>
          <input placeholder='Add another todo' onChange={event => setTodo(event.target.value)} value={todo}></input>
          <button onClick={addTodo} disabled={todo ? false : true}>+</button>
        </div>
        <ul>
          {
            todos.map((item) => {
              return (<Todo key={item.text} todo={item.text}/>)
            })
          }
        </ul>
      </header>
    </div>
  )
}

export default EntryComponent

This component is more complex. We have an add todo button, a placeholder and we even display all the todos added. We will be able to use a lot more react-testing-library features. Hooray!

The setup is all the same as for the App.js but we need to import the correct component. Create a properly named file and let’s copy the test from App.js. Remember to update the imports!

    it('should render the EntryComponent component', () => {
        const { container } = render( < EntryComponent / >);
        expect(container.firstChild).toBeTruthy();
    })

Let’s run and it fails!

What just happened?

The error we see is

could not find react-redux context value; please ensure the component is wrapped in a <Provider>

Do you remember when I mentioned that the render method only renders the imported component?

The problem here is that we try to test redux, but there is no store initialized, no Provider, etc.

The fix is pretty simple, we have to create a store and wrap, just for tests, the EntryComponent in the provider.

Have a look at how this is done (it is very well explained here: https://redux.js.org/recipes/writing-tests#connected-components).

So we need to wrap each test in a Provider passing some state. We could each time create a store, then manually wrap the component but we will follow best practices and the what is suggested above.

Create a test-utils.js file and add the below code to it:

// test-utils.js
import React from 'react'
import { render as rtlRender } from '@testing-library/react'
import { createStore } from 'redux'
import { Provider } from 'react-redux'
// Import your own reducer
import reducer from './store/reducer'

function render(
  ui,
  {
    initialState,
    store = createStore(reducer, initialState),
    ...renderOptions
  } = {}
) {
  function Wrapper({ children }) {
    return <Provider store={store}>{children}</Provider>
  }
  return rtlRender(ui, { wrapper: Wrapper, ...renderOptions })
}

// re-export everything
export * from '@testing-library/react'
// override render method
export { render }

Note that we override the default render method with our own which accepts as first argument the tested component and as the second an initialState and wraps our component with the Provider with the state.

In the end, we export all off react-testing-library methods.

Let’s fix our test now and see how it works!

First of all, we do no longer import the render from

’@testing-library/react’
but now from the
‘test-utils.js’
we created.

Copy the same initialState we used in the reducer.js.

And now the passing test looks like this:

    it('should render the EntryComponent component', () => {
        const { container } = render( < EntryComponent / > , { initialState });
        expect(container.firstChild).toBeTruthy();
    })

And passes!

Note the code coverage, let’s have a look!

So it seems like we have to add a todo to get the coverage!

In React testing library we do not call the internal method directly - you remember the philosophy? We just do what the user would.

So we have to fill an input field and then press the Add button and check if the newly added todo is present.

In order to find elements the render method returns even more helpers, like

getByText
,
getByTestId
. We can use those to find DOM elements.

Check out the code below.

    it('should add a new todo to the list when a new todo is not empty and after pressing the button', () => {
        const { getByText, getByPlaceholderText } = render( 
          < EntryComponent / > ,
          { initialState }
        );
        fireEvent.change(
          getByPlaceholderText('Add another todo'),
          { target: { value: '23' } }
        )
        fireEvent.click(getByText('+'))
        expect(getByText('23')).toBeInTheDocument();
    })

The Add button has got a ‘+’ text so we can use

getByText 
to locate it, and the input field has got a placeholder so we use
getByPlaceholderText
.

In order to fill the field and press a button we use the fireEvent method using fireEvent.change for the input and fireEvent.click for the button respectively.

We change the field first and fill it in with a string ‘23’ then we press the button. In the final line, we assert again using getByText that the ‘23’ is in the Document! And our tests pass.

Just remember that

toBeInTheDocument 
is not part of the testing library and you need to install and set up ‘jest-dom’.

Let’s check out our coverage again.

A lot better now! And a 100%!

But notice one thing, the button has a disabled state if the todo value is equal to false. Hmm. Should we test it? The code coverage is 100%.

You will find out in the next part alongside snapshot testing examples and why NOT to rely on snapshots. We will learn what are spies and how we leverage them in our testing.

But to get to the next part you need to like react to this post.

See the image above? Find those icons above or below the article, on the right hand-side of each paragraph and click one if you like this article, or if you think it is rubbish... Don't forget to share :)

If I get 10 reactions you will get the next part!