I'm using the testing-library to write my tests. I'm writing integration tests that load the component and then trying to walk through the UI in the test to mimic what a user might do and then testing the results of these steps. In my test output, I get the following warning when both tests run but do not get the following warning when only one test is run. All tests that run pass successfully.
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)
The following is my integration test written in jest. if I comment out either one of the tests, the warning goes away but if they both run then I get the warning.
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: '[email protected]', password: 'asdf' });
});
// fill out and submit form with valid credentials
userEvent.type(emailInput, '[email protected]');
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: '[email protected]', password: 'asdf' });
});
// fill out and submit form with invalid credentials
userEvent.type(emailInput, '[email protected]');
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();
});
});
The following is the component:
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);
The following is the AuthProvider component that wraps in the renderApp() function. It takes advantadge of the React useContext hook to manage the state of a users authentication for the app:
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>
)
});
It seems as though the first test mounts the component under test but that the second test is somehow trying to reference the first mounted component rather than the newly mounted component but I can't seem to figure out exactly what's happening here to correct these warnings. Any help would be greatly appreciated!
the AccountHandler isn't a singleton() the getInstance method name needs to be refactored to reflect this. So a new instance of AccountHandler is being created every time this is called. But, the register function adds an observer to an array that is iterated and each observer is called in that array when the authentication state changes. I wasn't clearing when new observers were added and thus the tests were calling both the old and unmounted observers as well as the new ones. By simply clearing that array, the issue was resolved. Here's the corrected code that has fixed the issue: