DEV Community

loading...

Things I got stuck with when unit testing REST APIs using Jest and React Testing Library.

Marie Otaki
Front-end developer
・9 min read

Alt Text

The reason why I started to write test code

I’m a React lover and have been creating a lot of web apps using React. However, I’ve never written test cases for them before. Usually, I start with a tutorial when I learn something new. Then, I create my app based on the knowledge that I gained from the tutorial. Either way, I didn’t have to write testing. When it comes to creating some apps with tutorials, testing is out of their scope most of the time. What about the time I create apps by myself?
Honestly, I thought that’s fine as long as the app is working. Yeah… that might be okay, but I can make it better!

Especially when it comes to production-level application, it must work safely. If I cause system failure on the production, the effect would be enormous. It’s enough reason to start learning test, isn’t it? That is why I started writing tests.

How is the project I write test cases for like?

The latest project I created by myself was a YouTube clone app. This is a simple React app working almost the same as YouTube. You can search for videos that you want to watch by keywords, and play them on it. Though I created it following a tutorial, there was no instruction about testing as usual. So, I decided to write testing for this app.

I’m using Jest and React Testing Library this time to write unit testing. Please note that I’ll skip the explanation about what they are in detail this time. If you’d like to know them more in detail, I recommend reading this article.

You can play around with this app here, by the way.😀

What kind of tests do I write?

Since the YouTube clone app fetches data from the YouTube API and passes them to each React component I decided to check if it executes as expected.

Here is my GitHub repo. If you feel something missing in my explanation, it might help.

I've taken out the part of the code that fetches data from the API. When hitting each endpoint by a GET method, the YouTube API returns a response as requested. I'm going to check if fetching data from the API (mock API) and being displayed correctly in React DOM.

import axios from 'axios';

const KEY = process.env.REACT_APP_YOUTUBE_API_KEY;

const youtube = axios.create({
  baseURL: 'https://www.googleapis.com/youtube/v3',
});

axios.defaults.withCredentials = true;

const params = {
  part: 'snippet',
  maxResults: '40',
  key: KEY,
  regionCode: 'CA',
  type: 'video',
};

export const fetchPopularData = async () => {
  return await youtube.get('/videos', {
    params: {
      ...params,
      chart: 'mostPopular',
    },
  });
};

export const fetchSelectedData = async (id) => {
  return await youtube.get('/videos', {
    params: {
      ...params,
      id,
    },
  });
};

export const fetchRelatedData = async (id) => {
  return await youtube.get('/search', {
    params: {
      ...params,
      relatedToVideoId: id,
    },
  });
};

export const fetchSearchData = async (query) => {
  return await youtube.get('/search', {
    params: {
      ...params,
      q: query,
    },
  });
};

Enter fullscreen mode Exit fullscreen mode

Preaparetion for the testing for APIs

Before diving into tests, you have to create a server that acts like the real API. That means you have to make the API returns data when its endpoint is hit as the YouTube API does so. How would you do that? Let's take a look at the example.

To create a server, I use Mock Service Worker. Their documentation is well organized and very easy to understand. I recommend looking over it. I'll move forward on the premise that you already know MSW this time.

const popularItems = [
  {
    id: '0',
    snippet: {
      thumbnails: {
        default: {
          url: 'https://dummyimage1/default.jpg',
          width: 120,
          height: 90,
        },
      },
      title: 'title1',
    },
  },
  {
    id: '1',
    snippet: {
      thumbnails: {
        default: {
          url: 'https://dummyimage2/default.jpg',
          width: 120,
          height: 90,
        },
      },
      title: 'title2',
    },
  },
  {
    id: '2',
    snippet: {
      thumbnails: {
        default: {
          url: 'https://dummyimage3/default.jpg',
          width: 120,
          height: 90,
        },
      },
      title: 'title3',
    },
  },
];
const server = setupServer(
  rest.get('https://www.googleapis.com/youtube/v3/videos', (req, res, ctx) => {
    return res(ctx.status(200), ctx.json({ items: popularItems }));
  })
);

beforeAll(() => {
  server.listen();
});

afterEach(() => {
  server.resetHandlers();
  cleanup();
});

afterAll(() => {
  server.close();
});
Enter fullscreen mode Exit fullscreen mode

The core part is the code below. When you hit the endpoint('https://www.googleapis.com/youtube/v3/videos') this server returns 200 status(success status that indicates that the request has succeeded) and JSON data, which has items property and value called popularItems.

const server = setupServer(
  rest.get('https://www.googleapis.com/youtube/v3/videos', (req, res, ctx) => {
    return res(ctx.status(200), ctx.json({ items: popularItems }));
  })
);
Enter fullscreen mode Exit fullscreen mode

I'll explain the other code briefly. 
Before starting testing, you should listen to the server with beforeAll().

beforeAll(() => {
  server.listen();
});
Enter fullscreen mode Exit fullscreen mode

You can reset any request handlers that you may add during the tests by using afterEach(), so they don't affect other tests.

afterEach(() => {
  server.resetHandlers();
  cleanup();
});
Enter fullscreen mode Exit fullscreen mode

You can clean up after the tests are finished using afterAll().

afterAll(() => {
  server.close();
});
Enter fullscreen mode Exit fullscreen mode

Let's write test cases!

Here is the code of test cases. Let's take a closer look at the code.

describe('Mocking API', () => {
  it('[Fetch success] Should fetch data correctly', async () => {
    render(
      <StoreProvider>
        <Router>
          <Top />
        </Router>
      </StoreProvider>
    );

    //check if the first object in popularItems is displayed correctly.
    expect(await screen.findByText('title1')).toBeInTheDocument();
    expect(screen.getByAltText('title1')).toBeTruthy();
    expect(screen.getByAltText('title1')).toHaveAttribute(
      'src',
      'https://dummyimage1/default.jpg'
    );

    //check if the second object in popularItems is displayed correctly.
    expect(await screen.findByText('title2')).toBeInTheDocument();
    expect(screen.getByAltText('title2')).toBeTruthy();
    expect(screen.getByAltText('title2')).toHaveAttribute(
      'src',
      'https://dummyimage2/default.jpg'
    );

    //check if the third object in popularItems is displayed correctly.
    expect(await screen.findByText('title3')).toBeInTheDocument();
    expect(screen.getByAltText('title3')).toBeTruthy();
    expect(screen.getByAltText('title3')).toHaveAttribute(
      'src',
      'https://dummyimage3/default.jpg'
    );
  });
});

Enter fullscreen mode Exit fullscreen mode

I'll explain a bit about the keywords used in this code.

  • describe: explains what kind of test it is. You can write test cases in the function passed as the second argument.
  • it: describes the test itself. It takes as parameters the name of the test and a function that hold the tests.
  • render:the method used to render a given component(in this case, is the target I'd like to test)
  • expect: the condition that the test needs to pass.

For example, the code below means like that…

  1. I expect 'title1' to exist in the document
  2. I expect 'title1' to exit as an alt attribute (I'd like to check if img tag where alt ='title1' exists)
  3. I expect 'title1' to exist as an alt attribute (I'd like to check if img tag where alt ='title1' exists) has src attribute 'https://dummyimage1/default.jpg'
//check if the first object in popularItems is displayed correctly.
    expect(await screen.findByText('title1')).toBeInTheDocument();
    expect(screen.getByAltText('title1')).toBeTruthy();
    expect(screen.getByAltText('title1')).toHaveAttribute(
      'src',
      'https://dummyimage1/default.jpg'
    );
Enter fullscreen mode Exit fullscreen mode

What are the problems I had and how to solve them?

Problem1: How would you access the global state?

Now that I introduced my final code first, you might not imagine how much I struggled until I finished this project. However, I had several problems when coding, so let me introduce them.

The first point that I got stuck on was to access globalState. When rendering the component to be tested, usually you write code like this.

 render(<Top />);
Enter fullscreen mode Exit fullscreen mode

I went the same way at first. However, as soon as I run the test, I came across the error.

Error: Uncaught [Error: Invariant failed:
You should not use <Link> outside a <Router>
Enter fullscreen mode Exit fullscreen mode

Okay, that is because I used inside the Top component, but I didn't wrap them with . Then, I modified it like so.

render(
   <Router>
     <Top />
   </Router>
);
Enter fullscreen mode Exit fullscreen mode

This time, it seems to fix the error, but it still didn't pass the test.

Unable to find an element with the text: title1. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.
Enter fullscreen mode Exit fullscreen mode

Why a thing like this happened? Because the YouTube clone app is using React context API and state managed by globalState. Let's take a look at App.js and index.js the upper layer of that.

//src/App.js
function App() {
  return (
    <Router>
      <Switch>
        <Route exact path="/" component={Top} />
        <Route exact path="/search" component={Search} />
        <Route exact path="/watch" component={Watch} />
      </Switch>
    </Router>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode
//src/index.js
ReactDOM.render(
  <React.StrictMode>
    <StoreProvider>
      <App />
    </StoreProvider>
  </React.StrictMode>,
  document.getElementById('root')
);
Enter fullscreen mode Exit fullscreen mode

In the App.js every component is wrapped with , while in index.js App component is wrapped with which manages the global state. It didn't pass the test because I didn't wrap the Top component with both and . Eventually, the correct code is like this.

render(
      <StoreProvider>
        <Router>
          <Top />
        </Router>
      </StoreProvider>
    );
Enter fullscreen mode Exit fullscreen mode

Now, you should run the test correctly! 👏

Problem2: What if the endpoint needs a certain query?

Let's take a look at another component to be tested.

import React, { useEffect, useContext } from 'react';
import Layout from '../components/Layout/Layout';
import VideoGrid from '../components/VideoGrid/VideoGrid';
import VideoGridItem from '../components/VideoGridItem/VideoGridItem';
import { useLocation } from 'react-router-dom';
import { fetchSearchData } from '../apis';
import { Store } from '../store/index';

const Search = () => {
  const { globalState, setGlobalState } = useContext(Store);
  const location = useLocation();

  useEffect(() => {
    const setSearchResult = async () => {
      const searchParams = new URLSearchParams(location.search);
      const query = searchParams.get('query');
      if (query) {
        await fetchSearchData(query).then((res) => {
          setGlobalState({
            type: 'SET_SEARCHED',
            payload: { searched: res.data.items },
          });
        });
      }
    };
    setSearchResult();
  }, [setGlobalState, location.search]);

  return (
    <Layout>
      <VideoGrid>
        {globalState.searched ? (
          globalState.searched.map((search) => {
            return (
              <VideoGridItem
                id={search.id.videoId}
                key={search.id.videoId}
                src={search.snippet.thumbnails.medium.url}
                title={search.snippet.title}
              />
            );
          })
        ) : (
          <span>no data</span>
        )}
      </VideoGrid>
    </Layout>
  );
};

export default Search;

Enter fullscreen mode Exit fullscreen mode

It's almost the same structure as the component that I mentioned earlier, but you need a query to fetch data from API in this case. So, how would you do the same thing in the test?

If you are using React Router (Most of the React projects are using it, I guess.), you can use createMemoryHistory.

createMemoryHistory(options)
createMemoryHistory creates an in-memory history object that does not interact with the browser URL. This is useful when you need to customize the history used for server-side rendering, as well as for automated testing.

As in this description, it's the best fit for automated testing! So, it's time to write testing!

const searchedItems = [
  {
    id: {
      videoId: 'serched00',
    },
    snippet: {
      thumbnails: {
        medium: {
          url: 'https://dummyimage1/mqdefault.jpg',
          width: 320,
          height: 180,
        },
      },
      title: 'title1',
    },
  }
//omission
];

const server = setupServer(
  rest.get(
    'https://www.googleapis.com/youtube/v3/search?query=dummy',
    (req, res, ctx) => res(ctx.status(200), ctx.json({ items: searchedItems }))
  )
);

beforeAll(() => {
  server.listen();
});

afterEach(() => {
  server.resetHandlers();
  cleanup();
});

afterAll(() => {
  server.close();
});

describe('Mocking API', () => {
  it('[Fetch success] Should fetch data correctly', async () => {
    const history = createMemoryHistory();
    history.push('/search?query=dummy');

    render(
      <StoreProvider>
        <Router history={history}>
          <Search />
        </Router>
      </StoreProvider>
    );

    //check if the first object in popularItems is displayed correctly.
    expect(await screen.findByText('title1')).toBeInTheDocument();
    expect(screen.getByAltText('title1')).toBeTruthy();
    expect(screen.getByAltText('title1')).toHaveAttribute(
      'src',
      'https://dummyimage1/mqdefault.jpg'
    );
  });
});

Enter fullscreen mode Exit fullscreen mode

In this case, it acts like that you are in the path '/search' with query 'dummy'.

//src/pages/Search.test.js
const history = createMemoryHistory();
    history.push('/search?query=dummy');

    render(
      <StoreProvider>
        <Router history={history}>
          <Search />
        </Router>
      </StoreProvider>
    );
Enter fullscreen mode Exit fullscreen mode

This is how you can get the query in the Search component.

//src/pages/Search.js
 useEffect(() => {
    const setSearchResult = async () => {
      const searchParams = new URLSearchParams(location.search);
      const query = searchParams.get('query');
      if (query) {
        await fetchSearchData(query).then((res) => {
          setGlobalState({
            type: 'SET_SEARCHED',
            payload: { searched: res.data.items },
          });
        });
      }
    };
    setSearchResult();
  }, [setGlobalState, location.search]);

Enter fullscreen mode Exit fullscreen mode

Here are more example using createMemoryHistory().
https://testing-library.com/docs/example-react-router/

To learn history a little bit more, this article might help.
https://medium.com/@pshrmn/a-little-bit-of-history-f245306f48dd

Problem3: Didn't pass the tests due to the structure of the dummy data.

I failed to test many times due to the structure of the dummy data, so make sure the data structure is the same as the real data!

Problem4: Didn't pass the tests because I didn't wrap the tests with async.

When you write test cases for APIs, you have to use async because it takes a while until completing to fetch data from it. Don't forget to use it in your test cases.


When you write test cases for the first time, you may face errors a lot like me. I hope this article helps! If you have any questions or suggestions, please let me know! Thank you so much for reading! 😀

I'm open to discussing new opportunities in web development!🔥
Also, I'm working on #100DaysOfCode on Twitter right now. Check it out if you like!

Twitter: @marie_otaki
Note: This article first appeared on my Medium blog.

Discussion (0)

Forem Open with the Forem app