Estoy usando la biblioteca de pruebas para escribir mis pruebas. Estoy escribiendo pruebas de integración que cargan el componente y luego trato de recorrer la interfaz de usuario en la prueba para imitar lo que podría hacer un usuario y luego probar los resultados de estos pasos. En el resultado de mi prueba, recibo la siguiente advertencia cuando se ejecutan ambas pruebas, pero no recibo la siguiente advertencia cuando solo se ejecuta una prueba. Todas las pruebas que se ejecutan pasan satisfactoriamente.

  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)

La siguiente es mi prueba de integración escrita en broma. si comento cualquiera de las pruebas, la advertencia desaparece, pero si ambas se ejecutan, aparece la advertencia.

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();
  });
});

El siguiente es el componente:

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);

El siguiente es el componente AuthProvider que se ajusta a la función renderApp (). Aprovecha el gancho React useContext para administrar el estado de autenticación de un usuario para la aplicación:

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>
  )
});

Parece que la primera prueba monta el componente bajo prueba, pero que la segunda prueba de alguna manera intenta hacer referencia al primer componente montado en lugar del componente recién montado, pero parece que no puedo entender exactamente qué está sucediendo aquí para corregir estas advertencias. ¡Cualquier ayuda será muy apreciada!

0
Brad Jones 30 sep. 2020 a las 22:01

2 respuestas

La mejor respuesta

AccountHandler no es un singleton (), el nombre del método getInstance necesita ser refactorizado para reflejar esto. Entonces, se crea una nueva instancia de AccountHandler cada vez que se llama. Sin embargo, la función de registro agrega un observador a una matriz que se itera y cada observador es llamado en esa matriz cuando cambia el estado de autenticación. No estaba aclarando cuándo se agregaron nuevos observadores y, por lo tanto, las pruebas estaban llamando tanto a los observadores antiguos como a los que no estaban montados, así como a los nuevos. Simplemente limpiando esa matriz, se resolvió el problema. Aquí está el código corregido que solucionó el problema:

  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();
  }
1
Brad Jones 8 oct. 2020 a las 13:01

Parece que tu AccountHandler es un singleton y estás suscrito a los cambios.

Esto significa que después de desmontar el primer componente y montar la segunda instancia, la primera todavía está registrada allí y cualquier actualización de AccountHandler activará el controlador que llama a setCurrentUser y setPending del primer componente también.

Debe darse de baja cuando se desmonta el componente.

Algo como esto

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);
  }
}, [])
0
Gabriele Petrioli 2 oct. 2020 a las 22:22