Home Writing

til / hemnet frontend testing workshop

This is a workshop in Frontend Testing that I held at Hemnet. The idea was to do it like a Mob programming session. That way, each participant got to get a feel for the code instead of having to sit and listen to me talk about it.

The focus of the workshop was for the participants to get better knowledge in testing some of the more uncommon/advanced testing paths.

The code is available at believer/frontend-testing-workshop and split into three branches: 1-context, 2-async and 3-hooks. Each branch also has a sibling with the completed state for that scenario. The completed branch names are the same but with -complete added at the end, e.g. 1-context-complete.

We’re using React, Jest, Testing Library, and React Query.

Sections #

Context Async Custom-hooks

Context #

We’ll start off by testing React’s context. The full starting code is available in the testing repo. The relevant code are these three files where we have set up a tiny application with a <Text> component that gets a text from the context and displays it to the user.

 1// AppContext.js
 2import React from 'react'
 4// Create a React context and set the default text value to an empty string
 5export const AppContext = React.createContext({
 6  text: '',
 9// Create a custom hook to make it easier to access the context
10export const useApp = () => React.useContext(AppContext)
 1// App.js
 2import { AppContext, useApp } from './AppContext'
 4export const Text = () => {
 5  const { text } = useApp()
 7  return <div>{text}</div>
10export default function App({ text }) {
11  return (
12    <AppContext.Provider value={{ text }}>
13      <Text />
14    </AppContext.Provider>
15  )
1// App.test.js
2import App, { Text } from './App'
3import { screen, render } from '@testing-library/react'
5// All the tests we'll create prepared as TODOs
6test.todo('renders app')
7test.todo('Text using a mocked useApp hook')
8test.todo('Text by importing the context')

Let’s start with the first test, that the app renders correctly.

 1// App.test.js
 3test('renders app', () => {
 4  // Render the App component and pass a text prop
 5  // The text prop is added to the context
 6  render(<App text="Frontend testing is fun!" />)
 8  // Assert that the document contains a text with the value
 9  // that we passed to the context. The toBeInTheDocument assertion comes
10  // from @testing-library/jest-dom
11  expect(screen.getByText(/frontend testing is fun/i)).toBeInTheDocument()

Next, we want to try render the <Text> component in isolation and here’s where we’ll start seeing some issues. If we just try to render the component, render(<Text />), and use the same assertion as above we’ll get an error that the text can’t be found. This happens because the <Text> component is no longer wrapped in a React context and it get’s the default value for text which we defined in when creating the context using React.createContext.

To get around this we’ll need some way of getting the data to the component. Our first attempt will be to mock the response of the custom hook, useApp, that we’ve defined for our context.

 1// App.test.js
 2// This import will be a mocked version as defined by jest.mock below
 3import { useApp } from './AppContext'
 5// This is a mock that automatically determines what the file contains
 6// and provides mocked functions for each exported value
 9// Mock the response of the useApp hook before each test runs
10beforeEach(() => {
11  useApp.mockReturnValue({
12    text: 'Frontend testing is fun!',
13  })
16test('Text using a mocked useApp hook', () => {
17  // Render the Text component
18  render(<Text />)
20  // Since the useApp hook is now mocked, we'll get a passing text
21  expect(screen.getByText(/frontend testing is fun/i)).toBeInTheDocument()

This works fine. However, if we were to remove the text prop from our first test and only use render(<App />) that test would still pass! That is because we’ve effectively mocked the entire context for all tests, which is not really want we want.

Let’s try it another way. This time by adding the context inside our test.

 1// App.test.js
 2// Import the AppContext, note that this is the real version and
 3// not a mocked version
 4import { AppContext } from './AppContext'
 6test('Text by importing the context', () => {
 7  // Wrap our Text component in the AppContext.Provider and
 8  // provide it with the value we want displayed in the Text component
 9  render(
10    <AppContext.Provider value={{ text: 'Frontend testing is fun' }}>
11      <Text />
12    </AppContext.Provider>
13  )
15  expect(screen.getByText(/frontend testing is fun/i)).toBeInTheDocument()

Now this is much better. Now we’re not messing with the first test and are instead asserting that the Text component works using the correct context. This is a trivial example, but for bigger contexts that are used across multiple files this would be a great solution for testing in isolation but still maintaining the integration testing aspect.

The final code for our tests looks like this and it’s available in the repo on the branch 1-context-complete

 1import App, { Text } from './App'
 2import { screen, render } from '@testing-library/react'
 3import { AppContext } from './AppContext'
 5test('renders app', () => {
 6  render(<App text="Frontend testing is fun!" />)
 8  expect(screen.getByText(/frontend testing is fun/i)).toBeInTheDocument()
11test('Text by importing the context', () => {
12  render(
13    <AppContext.Provider value={{ text: 'Frontend testing is fun' }}>
14      <Text />
15    </AppContext.Provider>
16  )
18  expect(screen.getByText(/frontend testing is fun/i)).toBeInTheDocument()

Async #

For our second testing scenario we are going to test an asynchronous hook. For this we’ll use react-query’s useQuery hook and fetch a character from the Star Wars API. The code is on the branch 2-async. This is what we’re starting out with

 1// App.js
 2import { useQuery, QueryClient, QueryClientProvider } from 'react-query'
 4// Create a client for making queries
 5const queryClient = new QueryClient()
 7// Call the Star Wars API and return the JSON data
 8// This can be any function, as long as it returns a promise
 9const fetchLuke = async () => {
10  const response = await fetch('https://swapi.dev/api/people/1/')
11  return response.json()
14const Luke = () => {
15  // Set up the useQuery hook with a unique key, 'luke', which is used
16  // for caching and pass our fetching function
17  const { isLoading, data } = useQuery('luke', fetchLuke)
19  // Loading state
20  if (isLoading) {
21    return <div>Loading...</div>
22  }
24  // Display the name of the character
25  return <div>{data.name}</div>
28export default function App() {
29  return (
30    // Set up the provider with the client we created
31    <QueryClientProvider client={queryClient}>
32      <Luke />
33    </QueryClientProvider>
34  )
1// App.test.js
2import App from './App'
3import { screen, render } from '@testing-library/react'
5test.todo('renders loading state')
6test.todo('renders data')

First we’ll test that the loading state renders correctly. We don’t need to do anything special for this case.

1// App.test.js
2test('renders loading state', () => {
3  // Render the App component
4  render(<App />)
6  // Assert 
7  expect(screen.getByText(/loading.../i)).toBeInTheDocument()

Next, we’ll want to make sure that the app actually display our character, Luke Skywalker. To make the test pass we only need to add async/await and use a findBy* query.

 1// Make the test asynchronous by adding async to the callback
 2test('renders data', async () => {
 3  render(<App />)
 5  // Using await and a findBy* query the assertion will wait until the
 6  // document contains the text we're looking for. If it takes too long
 7  // the test will timeout.
 8  expect(await screen.findByText(/luke skywalker/i)).toBeInTheDocument()
10  // This assertion checks that we're no longer rendering the loading state.
11  // It uses queryBy* since a getBy* or findBy* would throw errors if they
12  // can't find the element
13  expect(screen.queryByText(/loading.../)).not.toBeInTheDocument()

However, this would call the real API which is not ideal. The response could change, the service could be down or slow to respond. By adding a beforeEach with a mocked response we can ensure that our test won’t be flaky.

 1beforeEach(() => {
 2  global.fetch = jest.fn().mockResolvedValue({
 3    json: jest.fn().mockResolvedValue({
 4      // Use a name we know won't be returned from the API to ensure
 5      // that we're calling our mock. Be sure to update the assertion
 6      // as well. Kudos to a colleague for pointing this out!
 7      name: 'Mocked Skywalker',
 8    }),
 9  })

The final code is available on the branch 2-async-complete.

Custom hooks #

For our third and final scenario we’ll test a custom React hook. The hook we’re testing is trivial, but we’ll add some features using TDD as we go along. The code is available on the branch 3-hooks and the two files we’ll use are

 1// useCustomHook.js
 2import React from 'react'
 4export const useCustomHook = () => {
 5  const [state] = React.useState('Initial')
 7  return state
11// useCustomHook.test.js
12import { act, renderHook } from '@testing-library/react-hooks'
13import { useCustomHook } from './useCustomHook'
15test.todo('custom hook return state')
16test.todo('custom hook with custom initial value')
17test.todo('custom hook with updater')

The first test is pretty straightforward

 1// useCustomHook.test.js
 3test('custom hook return state', () => {
 4  // We use the renderHook utility to wrap our custom hook. This will return
 5  // an object with the current value of the hook and as well as any errors
 6  const { result } = renderHook(() => useCustomHook())
 8  // result.current is the current value that is returned
 9  expect(result.current).toEqual('Initial')

The criteria has changed and we now need to be able to pass in the initial value of the hook. This is where we’ll start using TDD. Let’s add a new test that tests this criteria and update the code for our hook.

 1// useCustomHook.test.js
 3test('custom hook with custom initial value', () => {
 4  // Pass in an initial value to the hook
 5  const { result } = renderHook(() => useCustomHook('newInitial'))
 7  // Assert that the hook takes our passed value
 8  expect(result.current).toEqual('newInitial')
11// Once we've confirmed that the test is indeed failing we can
12// make the necessary updates that will make it pass
14// useCustomHook.js
15// Add the ability to pass in a value, but set the default value – which
16// is used if no value is passed – to what we had before 'Initial'.
17// This will make sure that our first test doesn't break
18export const useCustomHook = (initial = 'Initial') => {
19  const [state] = React.useState(initial)
21  return state

Awesome, we’ve fulfilled the new demands for the custom hook. Unfortunately, the conditions changed again while we were fixing the last case. Now we also need to be able to update the value from outside the hook. For this we’ll return the setter part of useState so that the consumer can update the internal value. Again we’ll do this using TDD.

 1// useCustomHook.test.js
 3// We now want to return two values from our hook, the current value and
 4// a function to update the value with. Let's use the same style as useState
 5// uses, an array with two values: [value, updateFunction]
 7test('custom hook return state', () => {
 8  const { result } = renderHook(() => useCustomHook())
10  // The current value will now be the first item in an array
11  expect(result.current[0]).toEqual('Initial')
14test('custom hook with custom initial value', () => {
15  const { result } = renderHook(() => useCustomHook('newInitial'))
17  // The current value will now be the first item in an array
18  expect(result.current[0]).toEqual('newInitial')
21test('custom hook with updater', () => {
22  const { result } = renderHook(() => useCustomHook())
24  // The act utility is used to make the test run closer to how
25  // React actually calls it in the browser. The test passes without
26  // the act, but we would see an error in the test runner
27  act(() => {
28    // Call the second item of the returned array with our updated value
29    result.current[1]('newInitial')
30  })
32  // Assert that our value is the updated one
33  expect(result.current[0]).toEqual('newInitial')
36// Finally once are tests are updated, we can rebuild the hook to make
37// all tests pass
39// useCustomHook.js
40// Since we're now returning exactly the same as what useState returns
41// [state, setState], we can simply return the useState hook.
42export const useCustomHook = (initial = 'Initial') => React.useState(initial)

This was the complete code of our final scenario and the competed code is available in 3-hooks-complete branch.

  • Loading next post...
  • Loading previous post...