#reactjs #jestjs #react-testing-library
#reactjs #jestjs #react-testing-library
Вопрос:
Я использую библиотеку тестирования для написания своих тестов. Я пишу интеграционные тесты, которые загружают компонент, а затем пытаюсь пройти через пользовательский интерфейс в тесте, чтобы имитировать то, что может сделать пользователь, а затем тестирую результаты этих шагов. В моем тестовом выводе я получаю следующее предупреждение, когда выполняются оба теста, но не получаю следующее предупреждение, когда выполняется только один тест. Все выполняемые тесты проходят успешно.
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);
Ниже приведен компонент AuthProvider, который оборачивается в функцию renderApp() . Он использует перехватчик React useContext для управления состоянием аутентификации пользователей для приложения:
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>
)
});
Кажется, что первый тест монтирует тестируемый компонент, но второй тест каким-то образом пытается ссылаться на первый смонтированный компонент, а не на вновь смонтированный компонент, но я не могу точно понять, что здесь происходит, чтобы исправить эти предупреждения. Любая помощь будет с благодарностью!
Ответ №1:
AccountHandler не является синглтоном () имя метода 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();
}
Комментарии:
1. Неплохо. Это, однако, заставляет вас иметь только одного зарегистрированного наблюдателя в любое время. И если это так, то нет необходимости, чтобы это был массив. Вы вполне могли бы это сделать
this.observers = observer
.
Ответ №2:
Похоже, что ваш AccountHandler
одноэлементный, и вы подписываетесь на его изменения.
Это означает, что после того, как вы размонтируете первый компонент и смонтируете второй экземпляр, первый все еще зарегистрирован там, и любые обновления для AccountHandler
него будут запускать обработчик, который также вызывает setCurrentUser
и setPending
первого компонента.
Вам нужно отказаться от подписки, когда компонент размонтирован.
что-то вроде этого
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);
}
}, [])
Комментарии:
1. Габриэле, спасибо за ваши мысли здесь, вы указали мне правильное направление. Смотрите ответ ниже.