6

I have a bunch of API calls that I would like to unit test. As far as I know, unit testing API calls doesn't involve actually making those API calls. As far as I know you would simulate responses of those API calls and then test on the DOM changes however I'm currently struggling to do this. I have the following code:

App.js

function App() {

  const [text, setText] = useState("");

  function getApiData() {
        fetch('/api')
        .then(res => res.json())
        .then((result) => {
          console.log(JSON.stringify(result));
          setText(result); 
        })
      }

  return (
    <div className="App">
      {/* <button data-testid="modalButton" onClick={() => modalAlter(true)}>Show modal</button> */}
      <button data-testid="apiCall" onClick={() => getApiData()}>Make API call</button>
      <p data-testid="ptag">{text}</p>
    </div>
  );
}

export default App;

App.test.js

it('expect api call to change ptag', async () => {
  const fakeUserResponse = {'data': 'response'};
  var {getByTestId} = render(<App />)
  var apiFunc = jest.spyOn(global, 'getApiData').mockImplementationOnce(() => {
    return Promise.resolve({
      json: () => Promise.resolve(fakeUserResponse)
    })
  })


  fireEvent.click(getByTestId("apiCall"))
  const text = await getByTestId("ptag")
  expect(text).toHaveTextContent(fakeUserResponse['data'])
})

I'm trying to mock the result of getApiData() here and then test a DOM change (the p tag changes to the result). The above code gives me the error:

Cannot spy the getApiData property because it is not a function; undefined given instead

How do I access that class function?

EDIT:

I've adapted the code but I'm still having a bit of trouble:

App.js

function App() {

  const [text, setText] = useState("");

  async function getApiData() {
        let result = await API.apiCall()
        console.log("in react side " + result)
        setText(result['data'])
      }

  return (
    <div className="App">
      {/* <button data-testid="modalButton" onClick={() => modalAlter(true)}>Show modal</button> */}
      <button data-testid="apiCall" onClick={() => getApiData()}>Make API call</button>
      <p data-testid="ptag">{text}</p>
    </div>
  );
}

export default App;

apiController.js

export const API = {
    apiCall() {
        return fetch('/api')
        .then(res => res.json())
    }
}

Server.js

const express = require('express')
const app = express()
const https = require('https')
const port = 5000

app.get('/api', (request, res) => {
    res.json("response")
})

app.listen(port, () => console.log(`Example app listening at http://localhost:${port}`))

App.test.js

import React from 'react';
import { render, shallow, fireEvent } from '@testing-library/react';
import App from './App';
import {API} from './apiController'
//import shallow from 'enzyme'

it('api call returns a string', async () => {
  const fakeUserResponse = {'data': 'response'};
  var apiFunc = jest.spyOn(API, 'apiCall').mockImplementationOnce(() => {
    return Promise.resolve({
      json: () => Promise.resolve(fakeUserResponse)
    })
  })
  var {getByTestId, findByTestId} = render(<App />)
  fireEvent.click(getByTestId("apiCall"))
  expect(await findByTestId("ptag")).toHaveTextContent('response');
})

The error I'm getting is

expect(element).toHaveTextContent()

   Expected element to have text content:
     response
   Received:

     14 |   var {getByTestId, findByTestId} = render(<App />)
     15 |   fireEvent.click(getByTestId("apiCall"))
   > 16 |   expect(await findByTestId("ptag")).toHaveTextContent('response');
        |                                      ^
     17 | })
     18 | 
     19 | // it('api call returns a string', async () => {

Reusable unit test (hopefully):

    it('api call returns a string', async () => {
      const test1 = {'data': 'response'};
       const test2 = {'data': 'wrong'}

      var apiFunc = (response) => jest.spyOn(API, 'apiCall').mockImplementation(() => {
        console.log("the response " + JSON.stringify(response))
        return Promise.resolve(response)
        })

      var {getByTestId, findByTestId} = render(<App />)

      let a = await apiFunc(test1);
      fireEvent.click(getByTestId("apiCall"))
      expect(await findByTestId("ptag")).toHaveTextContent('response');
      let b = await apiFunc(test2);
      fireEvent.click(getByTestId("apiCall"))
      expect(await findByTestId("ptag")).toHaveTextContent('wrong');

    })

2 Answers2

11

Don't mock the API library. It's better the stub the server responses instead. If you write a bunch of tests that mock out the API call, you're binding the implementation of your app to your tests. Say you don't want to use fetch() but want to use something like isomorphic-unfetch for a SSR app? Switching over an entire test suite of mocks will be really painful.

Instead, use a server stubbing library like nock or msw. Think of these libraries as JSDOM but for your server. This way you're binding your test suite to the backend rather than the implementation library. Let's rewrite your example to show you what I mean:

import React from 'react';
import nock from 'nock';
import { render, fireEvent, screen } from '@testing-library/react';

import App from './App';

it('displays user data', async () => {
  const scope = nock('https://yoursite.com')
    .get('/api')
    .once()
    .reply(200, {
      data: 'response',
    });

  render(<App />)
  fireEvent.click(screen.getByTestId("apiCall"))
  expect(await screen.findByTestId("ptag")).toHaveTextContent('response');
})

Check out the blog post I wrote for a deeper dive on the subject, Testing components that make API calls.

tony g
  • 334
  • 2
  • 8
8

You cannot access getApiData because it's a private function inside other function (a closure) and it's not exposed to the global scope. That means global variable does not have property getApiData, and you are getting undefined given instead.

To do this you need to export somehow this function, I would suggest by moving it to different file, but the same should be fine as well. Here's a simple example:

export const API = {
  getData() {
    return fetch('/api').then(res => res.json())
  }
}

Somewhere in your component:

API.getData().then(result => setText(result))

And in test:

var apiFunc = jest.spyOn(API, 'getData').mockImplementationOnce(() => {
    return Promise.resolve({
      json: () => Promise.resolve(fakeUserResponse)
    })
  })

There are other ways to achieve that, but maybe this one would be enough.

And I think there would be one more problem. You are using const text = await getByTestId("ptag"), but getBy* functions from react-testing-library are not asynchronous (they do not return a promise you can wait to resolve), so your test will fail, as you wouldn't wait for a mock request to finish. Instead, try findBy* version of this function that you can await on and make sure promise is resolved.

jjanczyk
  • 481
  • 3
  • 7
  • Thanks for the help. I've edited the example accordingly however I'm having a bit of trouble. The ptag doesn't seem to receive any textual output. –  May 03 '20 at 18:04
  • 1
    Your `spy` is incorrect - you are mocking `json()` method, but it'snot this level (with this mock you are removing a call to `json()` entirely). Please use this version instead: `jest.spyOn(API, "apiCall").mockImplementation(() => Promise.resolve(fakeUserResponse));` – jjanczyk May 03 '20 at 19:35
  • 1
    Thanks a lot for the help, you've helped me out massively. Do you know what a good way to try this out with multiple fakeUserResponse test cases would be? –  May 03 '20 at 20:42
  • No problem, glad I could help :) – jjanczyk May 03 '20 at 20:43
  • Sorry to pester you again but do you know what a good way to try this out with multiple fakeUserResponse test cases would be? –  May 03 '20 at 20:47
  • You should be able to chain `mockImplementationOnce`: https://jestjs.io/docs/en/mock-function-api#mockfnmockimplementationoncefn. Or use `mockImplementation` and add some conditions inside handler to return different value with following calls – jjanczyk May 03 '20 at 21:21