DEV Community

Neil Ross
Neil Ross

Posted on • Originally published at neilross.dev

Test-Driven Development Tutorial with React Testing Library, Jest & GraphQL

We’re going to build a dumb joke book app, with Test-Driven Development (TDD). I’m going to use a Snowpack toolchain with help from my React Snowpack QuickStart. If you want to follow along, the tooling doesn’t matter, so feel free to use Create React App if you prefer. If you want the finished tutorial code, it is available to clone from this Github Repo

Before we start you should be aware that this is a tutorial to demonstrate TDD methodology in a React application, not to teach you how to build a joke fetching app. The technology choices used in this article are unsuitable for a small content marketing app. It would be a performance blunder to load the React framework unless already required for a critical path elsewhere within your platform. A more appropriate choice for a content marketing app would be vanilla js, alpine-js or Svelte. Please also be aware that apollo-client is also a chunky dependency, and again if you’re working on a platform that can be warranted, but if you want a lightweight graphQL client consider graphql-request

Start your Tooling

Open the vscode terminal and split-screen it:
open-splitscreen-1

In one terminal start snow pack by running

npm start
Enter fullscreen mode Exit fullscreen mode

and in the other terminal start jest in --watch mode by running

npm run jest
Enter fullscreen mode Exit fullscreen mode

Create your first test

We’re going to create a tests folder and add a new file called App.test.tsx. First, we’ll need to import the basic dependencies we need to test React components

import * as React from 'react';
import App from '../App'
import { render} from '@testing-library/react';
Enter fullscreen mode Exit fullscreen mode

Our first test will be to make sure that our app has a heading. It’s a basic accessibility & seo requirement.

test('The document must have an heading', () => {
  const { getByRole} = render(<App />);
  expect(getByRole('heading')).toBeTruthy();
Enter fullscreen mode Exit fullscreen mode

SIDENOTE: We want the test to be as simple a statement of what the app is doing as possible. In Behaviour-driven development, we would use our Gherkin Scenario

The test fails! We have Red. Now the core of TDD is getting it to turn Green. We call this RED-GREEN-REFACTOR.

Now we add an h1 to our App.tsx

import * as React from 'react'
interface Props {}
export default function App({}: Props) {
  return (
    <div className="container">
      <h1>React Jk-Jk</h1>
    </div>
  )
}

Enter fullscreen mode Exit fullscreen mode

Screenshot-2021-04-20-at-07.52.50

The test passes! We have Green. ONWARDS to fail once more, for our next test, we know that we need a button.

test('When the app loads there is a button', () => {
    const { getByRole} = render(
    <App />,
  );
  expect(getByRole('button')).toBeTruthy()
})
Enter fullscreen mode Exit fullscreen mode

Wait, we’ve repeated the render method for our first test. We should share that between our tests. So our test file becomes:

const renderApp = () => render(<App />)
test('The document should have an heading', () => {
  const { getByRole } = renderApp()
  expect(getByRole('heading')).toBeTruthy()
})
test('The app has a button', () => {
  const { getByRole } = renderApp()
  expect(getByRole('button')).toBeTruthy()
})
Enter fullscreen mode Exit fullscreen mode

Adding a button makes us green, but we need our next test. Given a button, when the user clicks the button then a joke appears.

test('When the user clicks the button then a joke appears', () => {
  const testJoke = "What's brown and sticky? ... A stick"
  const { getByRole, getByText } = renderApp(testJoke)
  const button = getByRole('button')
  fireEvent.click(button)
  expect(getByText(testJoke)).toBeInTheDocument()
})
Enter fullscreen mode Exit fullscreen mode

You may think that to make this test pass we’d need to go and fetch data from the server, we’d need to work out how we were going to display it, but that’s not true. We’re going to make the test pass in the dumbest way possible

In App.tsx:

import * as React from 'react'
const { useState } = React
interface Props {
  joke?: string
}
export default function App({joke}: Props) {
  const [isClicked, setIsClicked] = useState(false)
  return (
    <div className="container">
      <h1>React Jk-Jk</h1>
      {isClicked && <p>{joke}</p>}
      <button onClick={()=> setIsClicked(true)}>Click me</button>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Notice we make the component accept a prop joke so it can receive the text, and we then use a useState to determine if the button has been clicked. That passes, but now we must refactor.

Let’s get some data

npm install @apollo/client graphql
Enter fullscreen mode Exit fullscreen mode

This testing data requires a short explanation of a technique that is the basis of most testing: Mocking. When we mock API data we are providing our component with data that will not change, so we can be sure that we are testing our component in isolation. Mocking with React Context means that we need to create a test wrapper. Thankfully apollo-client comes with its own mockedProvider that makes this easy.

import { MockedProvider } from '@apollo/client/testing'
const mocks = []
const renderApp = (joke?: string) => {
  return render(
    <MockedProvider mocks={mocks} addTypename={false}>
      <App joke={joke} />
    </MockedProvider>
  )
}
Enter fullscreen mode Exit fullscreen mode

In the next part, we need to generate our mock. I’m going to use the https://icanhazdadjoke.com/api as a data source, and the insomnia app to grab my mock.

A screenshot of the insomnia app, showing fetching a graphQL query

ASIDE: I’m using the graphQL endpoint for demo purposes, to get that to work locally would cause CORS issues. Now CORS issues are why we work with Backend Developers, professionally I’d slack a colleague to sort out the CORS policy, here I’m using the allow CORS chrome extension to enable CORS locally.

In insomnia we can construct a graphql query and hit the endpoint

query joke {
  joke {
    id
    joke
    permalink
  }
}
Enter fullscreen mode Exit fullscreen mode

The data returned in Insomnia can form the basis of the mock that we pass to mockedProvider. we give our query the name of GET_JOKE_QUERY.

const mocks = [
  {
    request: {
      query: GET_JOKE_QUERY,
    },
    result: {
      data: {
        joke: {
          __typename: 'Joke',
          id: 'sPfqWDlq4Ed',
          joke: '"Hey, dad, did you get a haircut?" "No, I got them all cut."',
          permalink: 'https://icanhazdadjoke.com/j/sPfqWDlq4Ed',
        },
      },
    },
  },
]
Enter fullscreen mode Exit fullscreen mode

The first state that we’ll test is loading so we’ll write the following test:

test('When fetching data the user is shown a loading message', () => {
  const { getByText } = renderApp()
  expect(getByText('Loading...')).toBeInTheDocument()
})
Enter fullscreen mode Exit fullscreen mode

Now we’re going to wire up our data plumbing with graphQL, first in index.tsx we set up apollo-client

import { ApolloClient, InMemoryCache } from '@apollo/client'
import { ApolloProvider } from '@apollo/client/react'
const uri = 'https://icanhazdadjoke.com/graphql'
const client = new ApolloClient({
  // link: authLink.concat(httpLink),
  uri,
  cache: new InMemoryCache(),
})
var mountNode = document.getElementById('app')
ReactDOM.render(
  <ApolloProvider client={client}>
    <App joke="What's brown and sticky? ... A stick" />
  </ApolloProvider>,
  mountNode
)
Enter fullscreen mode Exit fullscreen mode

Now back in our App.tsx we import useQuery and add our GET_JOKE_QUERY to the head of the file

import { useQuery, gql } from '@apollo/client'
export const GET_JOKE_QUERY = gql`
  query joke {
    joke {
      id
      joke
      permalink
    }
  }
Enter fullscreen mode Exit fullscreen mode

Then in the body of the component, we destructure loading from useQuery and add an early return with a loading message.

export default function App({ joke }: Props) {
  const [isClicked, setIsClicked] = useState(false)
  const { loading } = useQuery(GET_JOKE_QUERY)
  if (loading) return <p>Loading...</p>
  return (
    <div className="container">
      <h1>React Jk-Jk</h1>
      {isClicked && <p>{joke}</p>}
      <button onClick={() => setIsClicked(true)}>Click me</button>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode


Great 🙂 now our loading test passes, but now all of our other tests fail, we need to make our other tests asynchronous and introduce async-await. We can update our other tests to be:

test('The document should have an heading', async () => {
  const { getByRole, getByText } = renderApp()
  await waitForElementToBeRemoved(() => getByText(/Loading.../i))
  expect(getByRole('heading')).toBeTruthy()
})
test('The app has a button', async () => {
  const { getByRole, getByText } = renderApp()
  await waitForElementToBeRemoved(() => getByText(/Loading.../i))
  expect(getByRole('button')).toBeTruthy()
})
test('When the user clicks the button then a joke appears', async () => {
  const testJoke = "What's brown and sticky? ... A stick"
  const { getByRole, getByText } = renderApp(testJoke)
  await waitForElementToBeRemoved(() => getByText(/Loading.../i))
  const button = getByRole('button')
  fireEvent.click(button)
  expect(getByText(testJoke)).toBeInTheDocument()
})
Enter fullscreen mode Exit fullscreen mode

That’s good that all 4 tests are GREEN, and passing, but that’s 3 repetitions so we need to refactor that into a helper. I’m not necessarily a DRY (don’t repeat yourself) programmer – I prefer a WET approach (write everything twice so save on hasty abstractions). I’m going to do two things, I’m going to import the screen method from Testing Library, and then I’m going to consolidate those awaits into a helper function.

`import { render, screen, fireEvent, waitForElementToBeRemoved } from '@testing-library/react'
Enter fullscreen mode Exit fullscreen mode

Then the helper:

const doneLoading = (screen: { getByText: (arg0: RegExp) => any }) =>
  waitForElementToBeRemoved(() => screen.getByText(/Loading.../i))
Enter fullscreen mode Exit fullscreen mode

So it has the benefit of making our tests a bit more readable:

test('The document should have an heading', async () => {
  renderApp()
  await doneLoading(screen)
  expect(screen.getByRole('heading')).toBeTruthy()
})
test('The app has a button', async () => {
  renderApp()
  await doneLoading(screen)
  expect(screen.getByRole('button')).toBeTruthy()
})
test('When the user clicks the button then a joke appears', async () => {
  const testJoke = "What's brown and sticky? ... A stick"
  renderApp(testJoke)
  await doneLoading(screen)
  const button = screen.getByRole('button')
  fireEvent.click(button)
  expect(screen.getByText(testJoke)).toBeInTheDocument()
})
Enter fullscreen mode Exit fullscreen mode

Now we want to change the behaviour so that the app loads and then fetches data and then shows us a joke so we write:

test("When data is fetched a joke is displayed on the screen", async ()=> {
  renderApp()
  await doneLoading(screen)
  expect(screen.getByTestId('joke')).toBeInTheDocument()
})
Enter fullscreen mode Exit fullscreen mode

So the fastest way to make that green is to simply add a test-id to our App.tsx

return (
        data ? (
            <div className="container">
                <h1>React Jk-Jk</h1>
                <p data-testid="joke">{JSON.stringify(data)}</p>
                {isClicked && <p>{joke}</p>}
                <button onClick={() => setIsClicked(true)}>Click me</button>
            </div>
        ) : null
    )
}
Enter fullscreen mode Exit fullscreen mode

We need to refactor to get the behaviour we want. We’re going to need to actually display a joke.
So we’re going to create a small component to display a joke.

import * as React from 'react'
interface Joke {
  id: string
  joke: string
  permalink: string
}
export default function Joke(jokeData: Joke) {
  return (
    <div>
      <p>{jokeData.joke}</p>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Now we have a failing test we need to refactor our “When the user clicks the button then a joke appears” test. We’re going to change this to be “When the user clicks the button the app fetches a new joke”. We refactor our spec:

test("When the user clicks the button the app fetches a new joke", async () => {
    renderApp()
    await screen.findByTestId("joke")
    const button = screen.getByRole("button")
    fireEvent.click(button)
    await screen.findByTestId("joke")
    expect(mockJokes).toHaveBeenCalledTimes(2)
})
Enter fullscreen mode Exit fullscreen mode

You’ll notice that instead of awaiting our doneLoading function we are now awaiting a joke appearing on the screen, then clicking our button and then awaiting another joke. Our expect statement now introduces another key concept of testing, mocking. So let’s write our mock.

To make this test go green, we need to get some more results from our service and store them in our mock. Now we create an array of only the results

const jokes = [
  {
      data: {
          joke: {
              id: "39Etc2orc",
              joke:
                  "Why did the man run around his bed? Because he was trying to catch up on his sleep!",
              permalink: "https://icanhazdadjoke.com/j/39Etc2orc",
              __typename: "Joke",
          },
      },
  },
  {
      data: {
          joke: {
              __typename: "Joke",
              id: "sPfqWDlq4Ed",
              joke:
                  '"Hey, dad, did you get a haircut?" "No, I got them all cut."',
              permalink: "https://icanhazdadjoke.com/j/sPfqWDlq4Ed",
          },
      },
  },
  {
      data: {
          joke: {
              id: "wcxHJBl3gFd",
              joke:
                  "I am terrified of elevators. I\u2019m going to start taking steps to avoid them.",
              permalink: "https://icanhazdadjoke.com/j/wcxHJBl3gFd",
              __typename: "Joke",
          },
      },
  },
]
Enter fullscreen mode Exit fullscreen mode

Then we need to make the mockedProvider request different jokes:

const mocks = [
    {
        request: {
            query: GET_JOKE_QUERY,
        },
        result: () => mocks[0],
        newData: () => mocks[1],
    },
]
Enter fullscreen mode Exit fullscreen mode

We could test the screen.findByTestId("joke").content and then click the button and test that the content had changed, but we’re trying to test that the button has called the apollo client’s refetch method. We go a step further and create a jest function to return the data.

const mockJokes = jest
    .fn()
    .mockReturnValue(jokes[0])
    .mockReturnValueOnce(jokes[1])
    .mockReturnValueOnce(jokes[2])
const mocks = [
    {
        request: {
            query: GET_JOKE_QUERY,
        },
        result: () => mockJokes(),
        newData: () => mockJokes(),
    },
]
beforeEach(() => mockJokes.mockClear())
Enter fullscreen mode Exit fullscreen mode

The jest.fn() method is so important to the process of testing. It’s likely that if we’re struggling to test something, we need to take a step back and refocus on the way in which we’re mocking external dependencies. We’re using the mockReturnValue to set default data, then we’re making the function return a different data object from our array of mocks each time the function is called with mockReturnValueOnce. Importantly, because our expect is expect(mockJokes).toHaveBeenCalledTimes(2) we need to add jest’s beforeEach hook to reset the mock before each test, otherwise the mock will persist, and for each test in the App.test.tsx it would run, meaning that by the time it reached our test it could be called 4 times, and when another developer in the future inserted new test before it would break our test.
So now we’ve refactored our test all that remains is to update our component to make it green.
In our App.tsx we update useQuery to destructure the refetch method, and then we update our onClick function to call refetch().

export default function App() {
    const { loading, data, refetch } = useQuery(GET_JOKE_QUERY)
    if (loading) {
        return <p>Loading...</p>
    }
    return (
        <div className="container">
            <h1>React Jk-Jk</h1>
            {data && <Joke joke={data.joke.joke} id={data.joke.id} />}
            <button onClick={() => refetch()}>Click me</button>
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

And we’re done with the test-driven development. We’ve met the behaviour required. I intend to post another tutorial demonstrating how I would style the joke book app, because TDD may allow you to deploy on Fridays and sleep soundly, but nothing is production-ready until it looks good enough for users to want to use it. I’ll update this page with a link when I write that tutorial.

If you have been, thanks for following along. I welcome any comments or feedback on this article.

Acknowledgements

My thanks go to Brett Langdon the maintainer of icanhazdadjoke, this wouldn’t have been possible without an API. I take inspiration for this tutorial from this excellent article on TDD with Vue from Daniel Kuroski that helped me get into TDD in 2018. When I was working with Vue, I was immensely grateful for his comprehensive tutorial, my hope is that this tutorial can be as useful to a React Developer interested in getting started with TDD.

Top comments (0)