【问题标题】:How can I prevent a React state update on an unmounted component in my integration testing?如何在集成测试中防止未安装组件的 React 状态更新?
【发布时间】:2020-09-30 19:01:51
【问题描述】:

我正在使用测试库来编写我的测试。我正在编写加载组件的集成测试,然后尝试在测试中遍历 UI 以模拟用户可能执行的操作,然后测试这些步骤的结果。在我的测试输出中,当两个测试都运行时我收到以下警告,但只运行一个测试时没有收到以下警告。所有运行的测试都成功通过。

  console.error node_modules/react-dom/cjs/react-dom.development.js:88
Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.
    in Unknown (at Login.integration.test.js:12)

以下是我开玩笑写的集成测试。如果我注释掉任何一个测试,警告就会消失,但如果它们都运行,那么我会收到警告。

import React from 'react';
import { render, screen, waitForElementToBeRemoved, waitFor } from '@testing-library/react';
import userEvent from "@testing-library/user-event";
import { login } from '../../../common/Constants';
import "@testing-library/jest-dom/extend-expect";
import { MemoryRouter } from 'react-router-dom';
import App from '../../root/App';
import { AuthProvider } from '../../../middleware/Auth/Auth';

function renderApp() {
  render(
    <AuthProvider>
      <MemoryRouter>
        <App />
      </MemoryRouter>
    </AuthProvider>
  );

  //Click the Login Menu Item
  const loginMenuItem = screen.getByRole('link', { name: /Login/i });
  userEvent.click(loginMenuItem);

  //It does not display a login failure alert
  const loginFailedAlert = screen.queryByRole('alert', { text: /Login Failed./i });
  expect(loginFailedAlert).not.toBeInTheDocument();

  const emailInput = screen.getByPlaceholderText(login.EMAIL);
  const passwordInput = screen.getByPlaceholderText(login.PASSWORD);
  const buttonInput = screen.getByRole('button', { text: /Submit/i });

  expect(emailInput).toBeInTheDocument();
  expect(passwordInput).toBeInTheDocument();
  expect(buttonInput).toBeInTheDocument();

  return { emailInput, passwordInput, buttonInput }
}

describe('<Login /> Integration tests:', () => {

  test('Successful Login', async () => {
    const { emailInput, passwordInput, buttonInput } = renderApp();

    Storage.prototype.getItem = jest.fn(() => {
      return JSON.stringify({ email: 'asdf@asdf.com', password: 'asdf' });
    });

    // fill out and submit form with valid credentials
    userEvent.type(emailInput, 'asdf@asdf.com');
    userEvent.type(passwordInput, 'asdf');
    userEvent.click(buttonInput);

    //It does not display a login failure alert
    const noLoginFailedAlert = screen.queryByRole('alert', { text: /Login Failed./i });
    expect(noLoginFailedAlert).not.toBeInTheDocument();

    // It hides form elements
    await waitForElementToBeRemoved(() => screen.getByPlaceholderText(login.EMAIL));
    expect(emailInput).not.toBeInTheDocument();
    expect(passwordInput).not.toBeInTheDocument();
    expect(buttonInput).not.toBeInTheDocument();
  });


  test('Failed Login - invalid password', async () => {
    const { emailInput, passwordInput, buttonInput } = renderApp();

    Storage.prototype.getItem = jest.fn(() => {
      return JSON.stringify({ email: 'brad@asdf.com', password: 'asdf' });
    });

    // fill out and submit form with invalid credentials
    userEvent.type(emailInput, 'brad@asdf.com');
    userEvent.type(passwordInput, 'invalidpw');
    userEvent.click(buttonInput);

    //It displays a login failure alert
    await waitFor(() => expect(screen.getByRole('alert', { text: /Login Failed./i })).toBeInTheDocument())

    // It still displays login form elements
    expect(emailInput).toBeInTheDocument();
    expect(passwordInput).toBeInTheDocument();
    expect(buttonInput).toBeInTheDocument();
  });
});

以下是组件:

import React, { useContext } from 'react';
import { Route, Switch, withRouter } from 'react-router-dom';
import Layout from '../../hoc/Layout/Layout';
import { paths } from '../../common/Constants';
import LandingPage from '../pages/landingPage/LandingPage';
import Dashboard from '../pages/dashboard/Dashboard';
import AddJob from '../pages/addJob/AddJob';
import Register from '../pages/register/Register';
import Login from '../pages/login/Login';
import NotFound from '../pages/notFound/NotFound';
import PrivateRoute from '../../middleware/Auth/PrivateRoute';
import { AuthContext } from '../../middleware/Auth/Auth';

function App() {

  let authenticatedRoutes = (
    <Switch>
      <PrivateRoute path={'/dashboard'} exact component={Dashboard} />
      <PrivateRoute path={'/add'} exact component={AddJob} />
      <PrivateRoute path={'/'} exact component={Dashboard} />
      <Route render={(props) => (<NotFound {...props} />)} />
    </Switch>
  )

  let publicRoutes = (
    <Switch>
      <Route path='/' exact component={LandingPage} />
      <Route path={paths.LOGIN} exact component={Login} />
      <Route path={paths.REGISTER} exact component={Register} />
      <Route render={(props) => (<NotFound {...props} />)} />
    </Switch>
  )

  const { currentUser } = useContext(AuthContext);
  let routes = currentUser ? authenticatedRoutes : publicRoutes;

  return (
    <Layout>{routes}</Layout>
  );
}

export default withRouter(App);

以下是包装在 renderApp() 函数中的 AuthProvider 组件。它利用 React useContext hook 来管理应用程序的用户身份验证状态:

import React, { useEffect, useState } from 'react'
import { AccountHandler } from '../Account/AccountHandler';

export const AuthContext = React.createContext();

export const AuthProvider = React.memo(({ children }) => {
  const [currentUser, setCurrentUser] = useState(null);
  const [pending, setPending] = useState(true);

  useEffect(() => {
    if (pending) {
      AccountHandler.getInstance().registerAuthStateChangeObserver((user) => {
        setCurrentUser(user);
        setPending(false);
      })
    };
  })

  if (pending) {
    return <>Loading ... </>
  }
  return (
    <AuthContext.Provider value={{ currentUser }}>
      {children}
    </AuthContext.Provider>
  )
});

似乎第一个测试安装了被测组件,但第二个测试以某种方式试图引用第一个安装的组件而不是新安装的组件,但我似乎无法弄清楚这里到底发生了什么来纠正这些警告。任何帮助将不胜感激!

【问题讨论】:

    标签: reactjs jestjs react-testing-library


    【解决方案1】:

    AccountHandler 不是 singleton(),getInstance 方法名称需要重构以反映这一点。所以每次调用它时都会创建一个新的 AccountHandler 实例。但是, register 函数将一个观察者添加到一个迭代的数组中,并且当身份验证状态发生变化时,每个观察者都会在该数组中被调用。当添加新的观察者时,我并没有弄清楚,因此测试同时调用了旧的和未安装的观察者以及新的观察者。通过简单地清除该数组,问题就解决了。这是已解决问题的更正代码:

      private observers: Array<any> = [];
    
      /**
       * 
       * @param observer a function to call when the user authentication state changes
       * the value passed to this observer will either be the email address for the 
       * authenticated user or null for an unauthenticated user.
       */
      public registerAuthStateChangeObserver(observer: any): void {
        /**
         * NOTE:
         * * The observers array needs to be cleared so as to avoid the 
         * * situation where a reference to setState on an unmounted
         * * React component is called.  By clearing the observer we 
         * * ensure that all previous observers are garbage collected
         * * and only new observers are used.  This prevents memory
         * * leaks in the tests.
         */
        this.observers = [];
    
        this.observers.push(observer);
        this.initializeBackend();
      }

    【讨论】:

    • 不错。但是,这会迫使您在任何时候都只有一个注册的观察者。如果是这样,那么它就不需要是一个数组。你可以很好地做到this.observers = observer
    【解决方案2】:

    看起来您的 AccountHandler 是一个单例,您订阅了对它的更改。

    这意味着在您卸载第一个组件并安装第二个实例后,第一个组件仍然在那里注册,并且对 AccountHandler 的任何更新都会触发调用第一个组件的 setCurrentUsersetPending 的处理程序组件也是。

    组件卸载后需要取消订阅。

    类似的东西

    const handleUserChange = useCallback((user) => {
      setCurrentUser(user);
      setPending(false);
    }, []);
    
    useEffect(() => {
      if (pending) { 
        AccountHandler.getInstance().registerAuthStateChangeObserver(handleUserChange)
      };
    
      return () => {
        // here you need to unsubscribe
        AccountHandler.getInstance().unregisterAuthStateChangeObserver(handleUserChange);
      }
    }, [])

    【讨论】:

    • Gabriele,谢谢您的想法,您为我指明了正确的方向。请参阅下面的答案。
    猜你喜欢
    • 2021-11-05
    • 2020-08-27
    • 1970-01-01
    • 1970-01-01
    • 2014-04-23
    • 2019-01-15
    • 2023-02-11
    • 2021-07-01
    • 2020-11-21
    相关资源
    最近更新 更多