Search code examples
reactjstypescriptreact-testing-library

React Context with hooks not populating state in context


I have the following React 18 code:

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

This code calls an async init function and sets the internal config.

However, when I test code I get an error:

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

The internal config is {} (the default value). This is both in the provider and the consumer. I've tried following the error messages instructions in using suspense. Still no luck. Edit:

Here are the tests written in Vitest and 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.log("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>
    );
  });
});

Only should accept children into the provider passes.

Edit 2: I have decided to use the act callback. However, now the InternalConfig in context is still Object {}.

Here is my code:

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


Solution

  • The TestConsumer component in the third unit test case has an unintentional side-effect, calling setInternalConfig directly from the function component body. You should also remove all test assertions from the function component body for the same reason (unintentional side-effect). React state updates are processed asynchronously and the component can rerender just about any number of times by the React framework during reconciliation.

    Move setInternalConfig to be called from within a useEffect hook, and remove the other extraneous side-effects from the function component body. All test assertions should occur in the unit test function body.

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

    That said, it's a bit odd to create a test component to call the hook when what you are really trying to test is the context value, and you've lost all the test assertions. When using RTL you shouldn't be trying to unit test React component internal implementation details. With RTL you unit test code via its APIs, e.g. values passed to a function or props passed to a React component, and the rendered result. TestConsumer doesn't consume any props and renders null, so there's not much to actually test here.

    Perhaps you should try using the renderHook function and just test the context directly via the useContext hook.

    Example Test Implementation:

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