У меня есть следующий код React 18:
import { Dispatch, ReactNode, SetStateAction, Suspense, createContext, useEffect, useState } from "react";
import { InternalConfig } from "../types/config";
import config from "../src/config";
import React from "react";
export const EtechUIContext = createContext<[InternalConfig, Dispatch<SetStateAction<InternalConfig>>] | []>([]);
export default function EtechUIProvider({
children,
configPath,
options
}: {
children: ReactNode;
configPath: string;
options?: {
filter: string;
};
}) {
const [internalConfig, setInternalConfig] = useState<InternalConfig>({} as InternalConfig);
const [calledTimes, setCalledTimes] = useState(0);
useEffect(() => {
const init = async (configPath: string, options?: { filter: string }) => {
try {
const newConfig = await config().init(configPath, options);
setInternalConfig(newConfig);
setCalledTimes(0);
} catch (err: any) {
setCalledTimes((prev) => prev++);
if (calledTimes >= 3) {
throw new Error("Failed to initialize eTech UI. This is likely a bug, please report it :)");
}
}
};
if (Object.keys(internalConfig).length === 0) {
init(configPath, options);
}
}, [internalConfig, calledTimes]);
return (
<Suspense fallback = {<p>Loading...</p>}>
<EtechUIContext.Provider value = {[internalConfig, setInternalConfig]}>{children}.</EtechUIContext.Provider>
</Suspense>
);
}
Этот код вызывает функцию асинхронной инициализации и устанавливает внутреннюю конфигурацию.
Однако, когда я тестирую код, я получаю сообщение об ошибке:
The above error occurred in the <TestConsumer> component:
at TestConsumer (/Users/ekrich/git/etech-ui/packages/etech-ui-utils/contexts/tests/EtechUI.spec.tsx:17:58)
at Suspense
at EtechUIProvider (/Users/ekrich/git/etech-ui/packages/etech-ui-utils/contexts/EtechUI.tsx:10:3)
Consider adding an error boundary to your tree to customize error handling behavior.
Visit https://reactjs.org/link/error-boundaries to learn more about error boundaries.
Warning: Cannot update a component (`EtechUIProvider`) while rendering a different component (`TestConsumer`). To locate the bad setState() call inside `TestConsumer`, follow the stack trace as described in https://reactjs.org/link/setstate-in-render
at TestConsumer (/Users/ekrich/git/etech-ui/packages/etech-ui-utils/contexts/tests/EtechUI.spec.tsx:26:73)
at Suspense
at EtechUIProvider (/Users/ekrich/git/etech-ui/packages/etech-ui-utils/contexts/EtechUI.tsx:10:3)
Note no above error was actually provided
Внутренняя конфигурация — {}
(значение по умолчанию). Это есть и у поставщика, и у потребителя. Я пробовал следовать инструкциям по сообщениям об ошибках при использовании приостановки. Все еще не повезло.
Редактировать:
Вот тесты, написанные на Vitest и React-Testing-Library.
import EtechUiProvider, { EtechUIContext } from "../EtechUI";
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import React, { useContext } from "react";
describe("EtechUIContext", () => {
it("should accept children into the provider", () => {
render(
<EtechUiProvider configPath = "./etech.config.ts" options = {{ filter: "etech-ui-utils-tests" }}>
Testing
</EtechUiProvider>
);
});
it("should contain an InternalConfig in context", () => {
function TestConsumer() {
expect(useContext(EtechUIContext)[0]?.configName).toBe("etech-ui-utils-tests");
return null;
}
render(
<EtechUiProvider configPath = "./etech.config.ts" options = {{ filter: "etech-ui-utils-tests" }}>
<TestConsumer />
</EtechUiProvider>
);
});
it("should have a setInternalConfig function that updates the InternalConfig", () => {
function TestConsumer() {
const [internalConfig, setInternalConfig] = useContext(EtechUIContext);
console.info("internal", internalConfig);
expect(setInternalConfig).toBeDefined();
// @ts-ignore
// Ignore error: cannot invoke null object.
// The above expect statement ensures that setInternalConfig is defined
setInternalConfig({
name: "etech-ui-utils",
configName: "etech-ui-utils-tests",
uiDir: "packages/etech-ui-utils/ui",
theme: {
colors: {
primary: "red",
secondary: "purple",
success: "green",
warning: "orange",
error: "red"
},
colorMode: "browser"
}
});
expect(internalConfig?.theme.colors.primary).toBe("red");
return null;
}
render(
<EtechUiProvider configPath = "./etech.config.ts" options = {{ filter: "etech-ui-utils-tests" }}>
<TestConsumer />
</EtechUiProvider>
);
});
});
Проходит только should accept children into the provider
.
Редактировать 2:
Я решил использовать обратный вызов act
. Однако теперь InternalConfig в контексте все еще Object {}
.
Вот мой код:
import EtechUiProvider, { EtechUIContext } from "../EtechUI";
import { describe, it, expect } from "vitest";
import { act, render, renderHook } from "@testing-library/react";
import React, { Context, useContext, useEffect } from "react";
const usePopulated = (context: Context<any[]>, expected: string) => {
const [internalConfig] = useContext(context);
if (internalConfig.configName === expected) {
return true;
}
return false;
};
describe("EtechUIContext", () => {
it("should accept children into the provider", () => {
render(
<EtechUiProvider configPath = "./etech.config.ts" options = {{ filter: "etech-ui-utils-tests" }}>
Testing
</EtechUiProvider>
);
});
it("should have a setInternalConfig function that updates the InternalConfig", async () => {
function TestConsumer() {
const [internalConfig, setInternalConfig] = useContext(EtechUIContext);
useEffect(() => {
if (setInternalConfig) {
// @ts-ignore
setInternalConfig({
name: "etech-ui-utils",
configName: "etech-ui-utils-tests",
uiDir: "packages/etech-ui-utils/ui",
theme: {
colors: {
primary: "red",
secondary: "purple",
success: "green",
warning: "orange",
error: "red"
},
colorMode: "browser"
}
});
}
}, []);
return null;
}
act(() => {
const { result } = renderHook(() => usePopulated(EtechUIContext, "etech-ui-utils-tests"), {
wrapper: ({ children }) => (
<EtechUiProvider configPath = "./etech.config.ts" options = {{ filter: "etech-ui-utils-tests" }}>
{children}
</EtechUiProvider>
)
});
expect(result.current).toBe(true);
});
});
});
@DrewReese Я обновил пост, чтобы ответить на ваши вопросы.
Компонент TestConsumer
в третьем примере модульного теста имеет непреднамеренный побочный эффект, вызывая setInternalConfig
непосредственно из тела функционального компонента. По той же причине (непреднамеренный побочный эффект) вам также следует удалить все тестовые утверждения из тела функционального компонента. Обновления состояния React обрабатываются асинхронно, и компонент может перерисовываться практически любое количество раз с помощью платформы React во время согласования.
Переместите setInternalConfig
, чтобы он вызывался изнутри useEffect
-хука, и удалите другие посторонние побочные эффекты из тела функционального компонента. Все тестовые утверждения должны находиться в теле функции модульного теста.
it("should have a setInternalConfig function that updates the InternalConfig", () => {
function TestConsumer() {
const [internalConfig, setInternalConfig] = useContext(EtechUIContext);
useEffect(() => {
if (setInternalConfig) {
setInternalConfig({
name: "etech-ui-utils",
configName: "etech-ui-utils-tests",
uiDir: "packages/etech-ui-utils/ui",
theme: {
colors: {
primary: "red",
secondary: "purple",
success: "green",
warning: "orange",
error: "red"
},
colorMode: "browser"
}
});
}
}, []);
return null;
}
render(
<EtechUiProvider
configPath = "./etech.config.ts"
options = {{ filter: "etech-ui-utils-tests" }}
>
<TestConsumer />
</EtechUiProvider>
);
});
Тем не менее, немного странно создавать тестовый компонент для вызова перехватчика, когда на самом деле вы пытаетесь проверить значение контекста, и вы потеряли все тестовые утверждения. При использовании RTL вам не следует пытаться выполнить модульное тестирование деталей внутренней реализации компонента React. С помощью RTL вы выполняете модульное тестирование кода через его API, например. значения, переданные в функцию, или реквизиты, переданные в компонент React, и визуализированный результат. TestConsumer
не использует никаких реквизитов и отображает null, поэтому здесь особо тестировать особо нечего.
Возможно, вам стоит попробовать использовать функцию renderHook и просто протестировать контекст напрямую через крючок useContext
.
Пример реализации теста:
export const useEtechUiProvider = () => useContext(EtechUiProvider);
import EtechUiProvider, { EtechUIContext } from "../EtechUI";
import { describe, it, expect } from "vitest";
import { act, renderHook } from "@testing-library/react";
import React, { useContext } from "react";
...
const ProvidersWrapper = ({ children }) => (
<EtechUiProvider
configPath = "./etech.config.ts"
options = {{ filter: "etech-ui-utils-tests" }}
>
{children}
</EtechUiProvider>
);
it("should have a setInternalConfig function that updates the InternalConfig", () => {
const { result } = renderHook(useEtechUiProvider, {
wrapper: ProvidersWrapper, // <-- renders hook within context provider
});
expect(result.current.internalConfig).toEqual({}); // assert initial state
expect(result.current.setInternalConfig).toBeDefined(); // assert callback exists
// Action to call function and effect state update
act(() => {
result.current.setInternalConfig({
name: "etech-ui-utils",
configName: "etech-ui-utils-tests",
uiDir: "packages/etech-ui-utils/ui",
theme: {
colors: {
primary: "red",
secondary: "purple",
success: "green",
warning: "orange",
error: "red"
},
colorMode: "browser"
}
});
});
expect(result.current.internalConfig?.theme.colors.primary).toBe("red");
});
В настоящее время первый подход лучше всего подходит для моих нужд. Однако мой тест по-прежнему завершается ошибкой: Cannot read properties of undefined (reading 'colors')
. Все остальные ошибки присутствуют.
@Ethan Извините, expect(internalConfig?.theme.colors.primary).toBe("red");
в теле компонента все равно будет считаться непреднамеренным побочным эффектом. Проблема в том, что во время начального цикла рендеринга internalConfig
по-прежнему является исходным пустым объектом, например. {}
, поэтому internalConfig.theme
не определено, что само по себе не является проблемой, пока вы не попытаетесь прочитать internalConfig.theme.colors
и, очевидно, не возникнет ошибка. Я обновил свой ответ: вам также следует удалить этот непреднамеренный побочный эффект из компонента.
Все тесты пройдены 🎉 И последний вопрос. Как я могу с помощью такого подхода узнать, что я действительно установил свое состояние, а не просто ничего не достиг.
@Итан Отличный вопрос. act — это помощник по тестированию, позволяющий применять ожидающие обновления React перед тем, как делать утверждения. Вы ставите в очередь обновление состояния, поэтому оно обрабатывает обновление состояния и последующий «повторный рендеринг». Утверждение до и после обновления состояния должно предоставлять доказательства того, что обновление произошло.
Хорошо, я думаю, что лучше всего использовать второй метод. Однако ценность InternalConfig по-прежнему {}
. Я добавил свой новый код в сообщение.
Наконец-то с вашей помощью все заработало. Спасибо за хорошо написанный ответ!
@Итан Круто, рад слышать, что у тебя все заработало. Здоровья и удачи!
(1) С какой «ошибкой выше» вам нужна помощь? (2) Проблема, похоже, в компоненте
TestConsumer
, который вы не включили в пост. Пожалуйста, отредактируйте , включив полное сообщение об ошибке, любую сопутствующую трассировку стека и полный минимальный воспроизводимый пример соответствующего кода, с которым вы работаете.