Search code examples
reactjstypescriptreact-hooksreact-testing-libraryreact-context

React testing library context test - use the context value before render() is called


I have a ModalProvider that has an internal useState which controls whether the modal is shown or not.

I don't want to pass a value into the provider. The code works in the application but I am confused about how to write a spec for it since I can't use the context value before the context provider is rendered in the DOM.

Is this possible without passing a value into ModalProvider?

I realize I can pass an optional initialValue into the Provider but I wonder if there is a way to avoid this.

ModalContext

import React, { ReactNode, createContext, useContext, useState } from "react";

type ProviderProps = {
    children: ReactNode;
    value: {
        showModal: boolean;
        setShowModal: (val: boolean) => void;
    };
};

export const ModalContext = createContext<ProviderProps["value"] | undefined>(
    undefined,
);

export const useModalContext = () => {
    const context = useContext(ModalContext);

    if (!context) {
        throw new Error("use `useModalContext` only inside `ModalProvider`");
    }

    return context;
};

export type { ProviderProps as ModalProviderProps };

export const ModalProvider = ({ children }: Omit<ProviderProps, "value">) => {
    const [showModal, setShowModal] = useState(false);

    return (
        <ModalContext.Provider
            value={{
                showModal,
                setShowModal,
            }}
        >
            {children}
        </ModalContext.Provider>
    );
};

ModalSpec

const queryClient = new QueryClient();

const Wrapper = ({ children }: { children: ReactNode }) => (
    <ModalProvider>
        <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </ModalProvider>
);
describe("Modal test", () => {
    it("renders", () => {
        const { setShowModal } = useModalContext(); <-- error here since Wrapper is rendered after it.
        render(
            <>
                <button
                    onClick={() =>
                        setShowModal(true)
                    }
                >
                    Show modal
                </button>
                <Modal />
            </>,
            {wrapper: Wrapper}
        );

        const button = screen.getByText("Add");
        fireEvent.click(button);

        const heading = screen.getByText(/Show modal/i);
        expect(heading).toBeInTheDocument();
    });
});

Solution

  • Rule 1. React hooks can only be called inside React function component, so we need to create a dummy React function Component and use the React hook inside it for testing.

    Rule 2. React context can only be accessed from the descendant components of the context provider component.

    So, use the context value before the context provider is rendered in the DOM is not possible.

    E.g.

    ModalContext.tsx:

    import React, { ReactNode, createContext, useContext, useState } from 'react';
    
    type ProviderProps = {
        children: ReactNode;
        value: {
            showModal: boolean;
            setShowModal: (val: boolean) => void;
        };
    };
    
    export const ModalContext = createContext<ProviderProps['value'] | undefined>(undefined);
    
    export const useModalContext = () => {
        const context = useContext(ModalContext);
        if (!context) throw new Error('use `useModalContext` only inside `ModalProvider`');
        return context;
    };
    
    export type { ProviderProps as ModalProviderProps };
    
    export const ModalProvider = ({ children }: Omit<ProviderProps, 'value'>) => {
        const [showModal, setShowModal] = useState(false);
        return <ModalContext.Provider value={{ showModal, setShowModal }}>{children}</ModalContext.Provider>;
    };
    

    ModalContext.test.tsx:

    import { act, render } from '@testing-library/react';
    import React from 'react';
    import { ModalProvider, useModalContext } from './ModalContext';
    
    describe('76758727', () => {
        test('should pass', () => {
            let ctx;
            const Dummy = () => {
                ctx = useModalContext();
                return null;
            };
    
            render(<Dummy />, { wrapper: ({ children }) => <ModalProvider>{children}</ModalProvider> });
            expect(ctx.showModal).toBeFalse();
            act(() => {
                ctx.setShowModal(true);
            });
            expect(ctx.showModal).toBeTrue();
        });
    });
    

    Test result:

     PASS  stackoverflow/76758727/ModalContext.test.tsx (9.137 s)
      76758727
        ✓ should pass (12 ms)
    
    ------------------|---------|----------|---------|---------|-------------------
    File              | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
    ------------------|---------|----------|---------|---------|-------------------
    All files         |   91.67 |       50 |     100 |     100 |                   
     ModalContext.tsx |   91.67 |       50 |     100 |     100 | 15                
    ------------------|---------|----------|---------|---------|-------------------
    Test Suites: 1 passed, 1 total
    Tests:       1 passed, 1 total
    Snapshots:   0 total
    Time:        9.448 s, estimated 10 s
    

    package versions:

    "@testing-library/react": "^11.2.7",
    "jest": "^26.6.3",
    "react": "^16.14.0",
    "react-dom": "^16.14.0",