How should I test React Hook "useEffect" making an api call with Typescript?
Asked Answered
E

3

36

I'm writing some jest-enzyme tests for a simple React app using Typescript and the new React hooks.

However, I can't seem to properly simulate the api call being made inside the useEffect hook.

useEffect makes the api call and updates the useState state "data" with "setData".

The object "data" is then mapped into a table to its corresponding table cells.

This seems like it should be easy to tackle with a mocked api response and an enzyme mount, but I keep getting errors telling me to use act() for component updates.

I tried using act() many ways but to no avail. I've tried replacing axios with fetch and using enzyme shallow and the react-test-library's render, but nothing seems to work.

The component:

import axios from 'axios'
import React, { useEffect, useState } from 'react';

interface ISUB {
  id: number;
  mediaType: {
    digital: boolean;
    print: boolean;
  };
  monthlyPayment: {
    digital: boolean;
    print: boolean;
  };
  singleIssue: {
    digital: boolean;
    print: boolean;
  };
  subscription: {
    digital: boolean;
    print: boolean;
  };
  title: string;
}

interface IDATA extends Array<ISUB> {}

const initData: IDATA = [];

const SalesPlanTable = () => {
  const [data, setData] = useState(initData);
  useEffect(() => {
    axios
      .get(`/path/to/api`)
      .then(res => {
        setData(res.data.results);
      })
      .catch(error => console.log(error));
  }, []);

  const renderTableRows = () => {
    return data.map((i: ISUB, k: number) => (
      <tr key={k}>
        <td>{i.id}</td>
        <td>
          {i.title}
        </td>
        <td>
          {i.subscription.print}
          {i.mediaType.digital}
        </td>
        <td>
          {i.monthlyPayment.print}
          {i.monthlyPayment.digital}
        </td>
        <td>
          {i.singleIssue.print}
          {i.singleIssue.digital}
        </td>
        <td>
          <button>Submit</button>
        </td>
      </tr>
    ));
  };

  return (
    <table>
      <thead>
        <tr>
          <th>ID</th>
          <th>Name</th>
          <th>MediaType</th>
          <th>MonthlyPayment</th>
          <th>SingleIssue</th>
          <th/>
        </tr>
      </thead>
      <tbody'>{renderTableRows()}</tbody>
    </table>
  );
};

export default SalesPlanTable;

The test:

const response = {
  data: {
    results: [
      {
        id: 249,
        mediaType: {
          digital: true,
          print: true
        },
        monthlyPayment: {
          digital: true,
          print: true
        },
        singleIssue: {
          digital: true,
          print: true
        },
        subscription: {
          digital: true,
          print: true
        },
        title: 'ELLE'
      }
    ]
  }
};

//after describe

it('should render a proper table data', () => {
    const mock = new MockAdapter(axios);
    mock.onGet('/path/to/api').reply(200, response.data);
    act(() => {
      component = mount(<SalesPlanTable />);
    })
    console.log(component.debug())
  });

I expect it to log the html of the table with the table body section rendered, I tried some async and different ways to mock axios but I keep either getting just the table headers or the message: An update to SalesPlanTable inside a test was not wrapped in act(...). I looked for many hours for a resolution but can't find anything that works so I decided to muster up some courage and ask here.

Eel answered 28/3, 2019 at 0:48 Comment(2)
so I decided to muster up some courage and ask here ...we're not THAT scary, are we? :) (great question, btw)Murmurous
Haha I asked something on here a while back but I was way less experienced with javascript and didn't thoroughly search on here before asking so it got taken down immediately :(Eel
M
25

There are two issues at play here


Asynchronous call to setData

setData gets called in a Promise callback.

As soon as a Promise resolves, any callbacks waiting for it get queued in the PromiseJobs queue. Any pending jobs in the PromiseJobs queue run after the current message has completed and before the next one begins.

In this case the currently running message is your test so your test completes before the Promise callback has a chance to run and setData isn't called until after your test completes.

You can fix this by using something like setImmediate to delay your assertions until after the callbacks in PromiseJobs have a chance to run.

Looks like you'll also need to call component.update() to re-render the component with the new state. (I'm guessing this is because the state change happens outside of an act since there isn't any way to wrap that callback code in an act.)

All together, the working test looks like this:

it('should render a proper table data', done => {
  const mock = new MockAdapter(axios);
  mock.onGet('/path/to/api').reply(200, response.data);
  const component = mount(<SalesPlanTable />);
  setImmediate(() => {
    component.update();
    console.log(component.debug());
    done();
  });
});

Warning: An update to ... was not wrapped in act(...)

The warning is triggered by state updates to the component that happen outside of an act.

State changes caused by asynchronous calls to setData triggered by a useEffect function will always happen outside of an act.

Here is an extremely simple test that demonstrates this behavior:

import React, { useState, useEffect } from 'react';
import { mount } from 'enzyme';

const SimpleComponent = () => {
  const [data, setData] = useState('initial');

  useEffect(() => {
    setImmediate(() => setData('updated'));
  }, []);

  return (<div>{data}</div>);
};

test('SimpleComponent', done => {
  const wrapper = mount(<SimpleComponent/>);
  setImmediate(done);
});

As I was searching for more info I stumbled on enzyme issue #2073 opened just 10 hours ago talking about this same behavior.

I added the above test in a comment to help the enzyme devs address the issue.

Murmurous answered 28/3, 2019 at 3:12 Comment(4)
THANK YOU SO MUCH!! It still has the weird 'act()' warning like you said but the table actually renders the proper data now, which is all I need... Hats off to you, sir!Eel
Use jest.useFakeTimers and wrap jest.runAllImmediates in act. This way there is no more error: github.com/eps1lon/react-act-immediate/blob/…. I could not solve the original issue with this method though.Alienist
Test passes, but still getting Warning: An update to Post inside a test was not wrapped in act(...).Poulin
I was able to remove the warning by wrapping the setImmediate in an act like so: await act(() => new Promise<void>((resolve) => { setImmediate(() => { node.update(); resolve(); }); }))Gamut
B
5

Solution

It both works and gets rid of the test was not wrapped in act(...) warning.

const waitForComponentToPaint = async (wrapper) => {
   await act(async () => {
     await new Promise(resolve => setTimeout(resolve, 0));
     wrapper.update();
   });
};

Usage:

it('should do something', () => {
    const wrapper  = mount(<MyComponent ... />);
    await waitForComponentToPaint(wrapper);
    expect(wrapper).toBlah...
})

Thanks to...

This is a work-around suggested by edpark11 in the issue @Brian_Adams mentioned in his answer.

Original post: https://github.com/enzymejs/enzyme/issues/2073#issuecomment-565736674

I copied the post here with a few modifications for archiving sake.

Bremble answered 17/10, 2020 at 14:29 Comment(0)
G
0

It's generally a bad practice to mock the library used to make fetch requests. Say you want to replace axios with fetch or isomorphic-unfetch? You would have to completely replace all of the mocks in your test suite with a new one. It's better to bind your tests to the server contracts than the mocks.

Use a server stub library like msw or nock + React Testing Library (RTL). RTL has some great tools to kick over React's lifecycle on asynchronous executions.

Here's how I would re-write the test using the example you provided:

RTL + Nock

/* SalesPlanTable.jsx */

import axios from 'axios';
import React, { useEffect, useState } from 'react';

interface ISUB {
  id: number;
  mediaType: {
    digital: boolean;
    print: boolean;
  };
  monthlyPayment: {
    digital: boolean;
    print: boolean;
  };
  singleIssue: {
    digital: boolean;
    print: boolean;
  };
  subscription: {
    digital: boolean;
    print: boolean;
  };
  title: string;
}

interface IDATA extends Array<ISUB> {}

const initData: IDATA = [];

const SalesPlanTable = () => {
  const [data, setData] = useState(initData);
  const [status, setStatus] = useState('loading');

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await axios.get('/path/to/api');
        setData(response.data.results);
        setStatus('ready');
      } catch (error) {
        console.log(error);
        setStatus('error');
      }
    };

    fetchData();
  }, []);

  const renderTableRows = () => {
    return data.map((i: ISUB, k: number) => (
      <tr key={k}>
        <td>{i.id}</td>
        <td>{i.title}</td>
        <td>
          {i.subscription.print}
          {i.mediaType.digital}
        </td>
        <td>
          {i.monthlyPayment.print}
          {i.monthlyPayment.digital}
        </td>
        <td>
          {i.singleIssue.print}
          {i.singleIssue.digital}
        </td>
        <td>
          <button>Submit</button>
        </td>
      </tr>
    ));
  };

  if (status === 'loading') {
    return <div>Loading...</div>;
  }

  if (status === 'error') {
    return <div>Error occurred while fetching data.</div>;
  }

  return (
    <table>
      <thead>
        <tr>
          <th>ID</th>
          <th>Name</th>
          <th>MediaType</th>
          <th>MonthlyPayment</th>
          <th>SingleIssue</th>
          <th />
        </tr>
      </thead>
      <tbody>{renderTableRows()}</tbody>
    </table>
  );
};

export default SalesPlanTable;
/* SalesPlanTable.test.jsx */

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

import SalesPlanTable from './SalesPlanTable';

/**
 * @NOTE: This should probably go into a `__fixtures__` folder.
 */
const response = {
  data: {
    results: [
      {
        id: 249,
        mediaType: {
          digital: true,
          print: true,
        },
        monthlyPayment: {
          digital: true,
          print: true,
        },
        singleIssue: {
          digital: true,
          print: true,
        },
        subscription: {
          digital: true,
          print: true,
        },
        title: 'ELLE',
      },
    ],
  },
};

describe('<SalesPlanTable />', () => {
  it('displays the title', async () => {
    const scope = nock('http://localhost')
      .get('/path/to/api')
      .reply(200, response.data);

    render(<SalesPlanTable />);
  
    // Wait for the async task to kick over
    await waitFor(() => {
      expect(screen.getByText('Loading...')).not.toBeInTheDocument();
    });
  
    // Test the render
    expect(screen.getByText('ELLE')).toBeInTheDocument();
    expect(scope.isDone()).toBeTruthy();
  });
});

Enzyme + Nock

/* SalesPlanTable.jsx */

import React from 'react';
import { mount } from 'enzyme';
import nock from 'nock';

import SalesPlanTable from './SalesPlanTable';

const response = {
  data: {
    results: [
      {
        id: 249,
        mediaType: {
          digital: true,
          print: true,
        },
        monthlyPayment: {
          digital: true,
          print: true,
        },
        singleIssue: {
          digital: true,
          print: true,
        },
        subscription: {
          digital: true,
          print: true,
        },
        title: 'ELLE',
      },
    ],
  },
};

describe('<SalesPlanTable />', () => {
  it('displays the title', async () => {
    nock('http://localhost')
      .get('/path/to/api')
      .reply(200, response.data);

    const component = mount(<SalesPlanTable />);
    
    // Wait for API call to complete
    await new Promise((resolve) => setTimeout(resolve)); 
    component.update();

    expect(component.find('td').at(1).text()).toBe('ELLE');
    expect(scope.isDone()).toBeTruthy();
  });
});
Gudrun answered 15/6, 2023 at 19:35 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.