React Context с хуками, не заполняющими состояние в контексте

У меня есть следующий код 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);
    });
  });
});

(1) С какой «ошибкой выше» вам нужна помощь? (2) Проблема, похоже, в компоненте TestConsumer, который вы не включили в пост. Пожалуйста, отредактируйте , включив полное сообщение об ошибке, любую сопутствующую трассировку стека и полный минимальный воспроизводимый пример соответствующего кода, с которым вы работаете.

Drew Reese 04.08.2024 01:35

@DrewReese Я обновил пост, чтобы ответить на ваши вопросы.

Ethan 04.08.2024 01:41
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Навигация по приложениям React: Исчерпывающее руководство по React Router
Навигация по приложениям React: Исчерпывающее руководство по React Router
React Router стала незаменимой библиотекой для создания одностраничных приложений с навигацией в React. В этой статье блога мы подробно рассмотрим...
Массив зависимостей в React
Массив зависимостей в React
Все о массиве Dependency и его связи с useEffect.
1
2
65
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Компонент 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 04.08.2024 03:22

@Ethan Извините, expect(internalConfig?.theme.colors.primary).toBe("red"); в теле компонента все равно будет считаться непреднамеренным побочным эффектом. Проблема в том, что во время начального цикла рендеринга internalConfig по-прежнему является исходным пустым объектом, например. {}, поэтому internalConfig.theme не определено, что само по себе не является проблемой, пока вы не попытаетесь прочитать internalConfig.theme.colors и, очевидно, не возникнет ошибка. Я обновил свой ответ: вам также следует удалить этот непреднамеренный побочный эффект из компонента.

Drew Reese 04.08.2024 04:12

Все тесты пройдены 🎉 И последний вопрос. Как я могу с помощью такого подхода узнать, что я действительно установил свое состояние, а не просто ничего не достиг.

Ethan 04.08.2024 05:36

@Итан Отличный вопрос. act — это помощник по тестированию, позволяющий применять ожидающие обновления React перед тем, как делать утверждения. Вы ставите в очередь обновление состояния, поэтому оно обрабатывает обновление состояния и последующий «повторный рендеринг». Утверждение до и после обновления состояния должно предоставлять доказательства того, что обновление произошло.

Drew Reese 04.08.2024 05:38

Хорошо, я думаю, что лучше всего использовать второй метод. Однако ценность InternalConfig по-прежнему {}. Я добавил свой новый код в сообщение.

Ethan 05.08.2024 01:08

Наконец-то с вашей помощью все заработало. Спасибо за хорошо написанный ответ!

Ethan 05.08.2024 01:54

@Итан Круто, рад слышать, что у тебя все заработало. Здоровья и удачи!

Drew Reese 05.08.2024 02:10

Другие вопросы по теме