Testing and mocking render props or children-as-function in React and TypeScript
Mocking components in Jest tests is useful because if, say, Component A has been unit tested already, you donât need to test Component A again when it is being used as part of Component B.
The real-world example
For a more concrete example consider my recent experience with modals. Our work project has many modals with different contents and slightly different styles. But as they are all modals, they have a lot of shared functionality: they share the same styling for the backdrop and pop out element that appear on the top of the content stack; they have the same style and functionality on the close (X) button; clicking on the backdrop has the same functionality.
As such, I created a <BaseModal/>
to share these features easily. The BaseModal
has all of the features that I described above. Below is a simplified version of this component. We used the ReactContext API to drive the Modal state. When we want to close the modal, we pass undefined
to that ModalContext value function.
interface Props {
children: ReactNode;
}
function BaseModal ({children}: Props) {
const setModalContent = useContext(ModalContext)
<div className="baseModal">
<CloseIcon onClick={() => setModalContent(undefined)}/>
{children}
</div>
}
Then to create a new modal, we wrap the new modal component in <BaseModal/>
to get access to the correct the closing and styling functionality.
interface Props {
name: string
}
function WelcomeModal({ name }: Props) {
return (
<BaseModal>
<p>Hello {name} welcome to your new account!</p>
</BaseModal>
)
}
Testing the components with mocks
When testing BaseModal, Iâd test the following: check that the ModalContext is called with useContext; check that, when the CloseIcon is clicked that the function from useContext is called with undefined
; use a snapshot test to test that any children passed in are correctly and that any other DOM implementations such as classNames
and div
s are positioned correctly.
jest.mock("react", () => {
return { ...jest.requireActual("react"), useContext: jest.fn() }
})
const mockSetModal = jest.fn()
;(React.useContext as jest.Mock).mockReturnValue(mockSetModal)
describe("BaseModal Component", () => {
beforeEach(() => {
mockSetModal.mockClear()
})
it("matches the snapshot", () => {
const { getByText, container, queryByTestId } = render(
<BaseModal>
<div>My content</div>
</BaseModal>
)
expect(container).toMatchSnapshot()
})
it("when close is clicked, setModal is called with undefined", () => {
const { getByText, queryByTestId, container } = renderWithIntl(
<BaseModal showTopCloseButton={false}>
<div>My content</div>
</BaseModal>
)
fireEvent.click(queryByTestId("button.closeModal") as HTMLElement)
expect(mockSetModal).toHaveBeenCalledWith(undefined)
})
})
When it comes to testing modals which use BaseModal
as a wrapper (e.g. the example WelcomeModal
), we donât need to test the functionalities already given to it by BaseModal
. We should be test anything new that WelcomeModal
brings. In this case, it would be WelcomeModal
âs ability to render text depending on the name
prop.
Our tests should focus on whatâs new because we already have a test which tests everything that BaseModal
gives us. Testing any functionality of BaseModal
again would make the codebase less flexible; if we one day had to refactor BaseModal
, whether that be changing the positioning of the close button or changing the way we manage modal display state, we wonât have to change every single modal which relies on it.
So the test for WelcomeModal
would look something like
// mock using appropriate path to BaseModal
jest.mock("../BaseModal/BaseModal", () => {
return function MockModal({ children }: { children: ReactNode }) {
return <mock-base-modal>{children}</mock-base-modal>
}
})
describe("WelcomeModal Component", () => {
it("matches the snapshot", () => {
const { container } = render(<WelcomeModal name="jerry" />)
expect(container).toMatchSnapshot()
})
})
By mocking BaseModal
with jest.mock
, when the BaseModal is printed in the snapshot, it will look something like
<mock-base-modal>
<p>Hello jerry welcome to your new account!</p>
</mock-base-modal>
The <mock-base-modal/>
in our snapshot is a placeholder for the BaseModal
. If BaseModal
were to change, we would not have to update this snapshot because of the mock. Here, we only care that BaseModal is being used as part of the component, not about the behavior that BaseModal gives it, because this has already been tested.
Extending the mock even further
Later on, we wanted to add the ability for the internal content of the modal to close the modal itself. For example, if there was a cancel button in the modal, weâd want clicking the cancel button to close the modal as well. When I saw this, I immediately thought to pass down an onClose function through a render prop (aka children as function). BaseModal
was extended to look something like this:
interface Props {
children: ReactNode | ((handleClose: () => void) => ReactNode)
}
function BaseModal({ children }: Props) {
const setModalContent = useContext(ModalContext)
const closeModal = () => setModalContent(undefined)
return (
<div className="baseModal">
<CloseIcon onClick={closeModal} />
{typeof children === "function" ? children(closeModal) : children}
</div>
)
}
To test this new functionality, weâd want to make sure that when children is a function, the argument that is passed to it is equivalent to closeModal
, or even, it is equivalent to () => setModalContent(undefined)
. This is how I ended up doing:
it("when children is a function, it returns the children and passes", () => {
const mockChildren = jest.fn()
renderWithIntl(<BaseModal>{mockChildren}</BaseModal>)
expect(mockChildren).toHaveBeenCalled()
const mockChildrenParams = mockChildren.mock.calls[0]
expect(mockChildrenParams[0]).toEqual(expect.any(Function))
// verify that it has not been called yet, so we are clear that the following invoked function is really mockSetModal
expect(mockSetModal).not.toHaveBeenCalled()
// invoke the param passed to the children
mockChildrenParams[0]()
expect(mockSetModal).toHaveBeenCalledWith(undefined)
})
What struck me here is that we are isolating the BaseModal
component from the implementation of the children prop. We donât need to care how the children prop is implemented, what it returns or what calculations it might make. Rather, we only care about the fact that itâs a function type, itâs called, and that the parameter we pass through to it is a function that closes the Modal.
Continuing with the WelcomeModal
example, letâs say we add the aforementioned cancel and close button. How would we test this? Letâs try a more TDD approach this time, now that we have the outline of the tests already set up.
Firstly, weâd need to extend the BaseModal mock so that it can account for our new implementation of render children functions.
// mock using appropriate path to BaseModal
jest.mock("../BaseModal/BaseModal", () => {
return function MockModal({ children }: { children: ReactNode }) {
const mockModalOnClose = jest.fn()
return (
<mock-base-modal>
{typeof children === "function" ? children(mockModalOnClose) : children}
</mock-base-modal>
)
}
})
By changing our mock to this, the test will be able to render the children as function properly. However, I would still feel more comfortable if the functionality of the cancel button is tested: that it calls the parameter passed through to the children. For this, we already have mockModalOnClose
in our mock. So we can effectively probe and test it, weâd need to take it out of the scope of the mock factory and then weâd be able to use it in our assertions:
const mockModalOnClose = jest.fn()
jest.mock("../BaseModal/BaseModal", () => {
return function MockModal({ children }: { children: ReactNode }) {
return (
<mock-base-modal>
{typeof children === "function" ? children(mockModalOnClose) : children}
</mock-base-modal>
)
}
})
describe("WelcomeModal Component", () => {
it("matches the snapshot", () => {
const { container } = render(<WelcomeModal name="jerry" />)
expect(container).toMatchSnapshot() // snapshot should update with the new button
})
it("when the cancel button is clicked, mockCloseModal is called", () => {
const { getByText } = render(<WelcomeModal name="jerry" />)
fireEvent.click(getByText("Cancel"))
expect(mockModalOnClose).toHaveBeenCalled()
})
})
With these tests, the new feature would look something like:
interface Props {
name: string
}
function WelcomeModal({ name }: Props) {
return (
<BaseModal>
{closeModal => (
<>
<p>Hello {name} welcome to your new account!</p>
<button onClick={closeModal} type="button">
Cancel
</button>
</>
)}
</BaseModal>
)
}
Using and typing Manual Mocks in the __mocks__
directory
Because we are going to be using BaseModal
for many different kinds of modals, it would be useful to create a manual mock for the component. Doing this means that we do not have to repeat the implementation across multiple tests. All weâd need to do is call jest.mock('path/to/BaseModal')
in every test suite that we need to mock implementation.
In order that we can continue to probe that mockCloseModal
is called, we also need to make sure that the function is exported from the __mocks__/BaseModal.tsx
file. I also extended the mock a bit so we could tell when the MockModal is called with the close button or without the close button. (e.g. weâd want a close button for most modals, however we wouldnât want it for something like a cookies modal so that the user has to click âagree with termsâ to close the modal).
// __mocks__/BaseModal.tsx
import React from "react"
import { Props } from "../BaseModal" // Get Props interface so it behaves more akin to the component
export const mockModalOnClose = jest.fn()
function MockModal({ children, showTopCloseButton = true }: Props) {
const Component = showTopCloseButton ? "mock-modal-with-close" : "mock-modal"
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
return (
<Component>
{typeof children === "function" ? children(mockModalOnClose) : children}
</Component>
)
}
export default MockModal
However, when I first used the above implementation, I found that exporting mockModalOnClose
for testing was not as obvious as it seemed. Here I will detail the my attempts to get it working in TS, but scroll down to the bottom if you want the answer now.
I knew that when jest.mock(filepath)
is called with an accompanying file in the __mocks__
directory, Jest essentially diverts any from that file to use the mock file. So in the below example, even though the BaseModal
file doesnât have an exported function called mockModalOnClose
, the tests will still work because we are actually importing from '__mocks__/BaseModal'
, which we know has a mockModalOnClose function (assuming you are running the tests without a compilation step).
import { mockModalOnClose } from "../BaseModal/BaseModal"
jest.mock("../BaseModal/BaseModal")
// further down
it("when the cancel button is clicked, mockCloseModal is called", () => {
const { getByText } = render(<WelcomeModal name="jerry" />)
fireEvent.click(getByText("Cancel"))
expect(mockModalOnClose).toHaveBeenCalled()
})
However, with the above code, TypeScript will raise an error saying
Module '"../BaseModal/BaseModal"' has no exported member 'mockModalOnClose'.
This is correct because the file really doesnât have an exported member with that name. But because the tests are still working, even with the type error (that mockModalOnClose is really the function from __mocks__/BaseModal
), we know that TypeScript is missing something.
In order to tell TypeScript to trust that my implementation is correct, I ended up doing this:
import * as BaseModal from "../BaseModal/BaseModal"
import * as MockModal from "../BaseModal/__mocks__/BaseModal"
jest.mock("../BaseModal/BaseModal")
const { mockModalOnClose } = BaseModal as typeof MockModal
// further down, we can check that mockModalOnClose is called successfully
Using as
tells TypeScript that the compiler should treat the value as a certain type. Here, because TypeScript is unaware of the implications of jest.mock
, we need to manually say âTreat whatever we export from the BaseModal
file as if itâs the same type as exporting from MockModal
â.