Skip to content

Instantly share code, notes, and snippets.

@isocroft
Last active July 13, 2024 19:08
Show Gist options
  • Save isocroft/ee52bd8cabb317826c250c638c0c47e0 to your computer and use it in GitHub Desktop.
Save isocroft/ee52bd8cabb317826c250c638c0c47e0 to your computer and use it in GitHub Desktop.
Helper functions for react-testing-library to make writing tests a lot easier and faster while maintaining quality feedback
import React, { useMemo } from "react";
import { Provider as ReactReduxProvider } from "react-redux";
import { useForm, FormProvider, UseFormProps } from "react-hook-form";
import { SessionContext } from 'next-auth/react';
import { IntlProvider as ReactIntlProvider } from "react-intl";
import { BrowserRouter, BrowserRouterProps, MemoryRouter, MemoryRouterProps, Router, RouterProps } from "react-router-dom";
import { History, LocationDescriptor, createBrowserHistory, createMemoryHistory } from "history";
import { render, RenderOptions, RenderResult } from "@testing-library/react";
import { renderHook, RenderHookResult, RenderHookOptions } from "@testing-library/react-hooks";
type Separate<F, O> = F extends O ? F : null;
type Writeonly<T> = Separate<keyof T, keyof Readonly<T>>;
/**
* Helps to verify if object reference type has zero enumerable and
* and configurable members.
*
* @param {Object} objectValue
*
* @returns {Boolean}
*/
function isEmpty<T>(objectValue: T): boolean {
if(!objectValue || typeof objectValue !== "object") {
return true;
}
for(const prop in objectValue) {
if(Object.prototype.hasOwnProperty.call(objectValue, prop)) {
return false;
}
}
return JSON.stringify(objectValue) === JSON.stringify({});
};
/**
* Helps as an assertion signature for ensureing that readonly members
* are not overwritten by assignment on the `globalThis: Window` object
*
* @param {String} property
* @throws {Error}
*
* @returns {void}
*/
function assertReadonlyGlobalsNotMutable(property) {
const readOnlyGlobalObjects = [
'origin',
'history',
'clientInformation',
'caches',
'closed',
'crypto',
'fetch'
]
if (readOnlyGlobalObjects.includes(property)) {
throw new Error(
`Cannot override sensitive readonly global object: "${property}"`
)
}
}
/**
* Helps setup initial route for React Router v5 router for a test
*
* @param {import('history').History} history
* @param {{ path: String, title?: String, state?: Object }} initialRoute
*
* @returns {void}
*/
export function setupInitialRoute(
history: import('history').History,
initialRoute = { path: '/', title: '', state: undefined }
) {
if (initialRoute) {
const isHistoryStateEmpty = !initialRoute.state || isEmpty(initialRoute.state)
if (history.location === null) {
window.history.pushState(
isHistoryStateEmpty ? null : initialRoute.state,
initialRoute.title || '',
initialRoute.path
)
} else {
if (isHistoryStateEmpty) {
history.push(initialRoute.path)
} else {
history.push(initialRoute.path, initialRoute.state)
}
}
}
};
/**
* A custom wrapper to provision a i18n locale for a real consumer component
* or a real custom hook
*
* @param {String} locale
*
* @returns {[String, (props: { children: React.ReactNode }) => JSX.Element]}
*
* @see https://testing-library.com/docs/example-react-intl/
*/
export function getWrapperWithLocaleFormat (locale = 'pt') {
return [locale, ({ children }: { children: React.ReactNode }) => {
return (
<ReactIntlProvider locale={locale}>
{ children }
</ReactIntlProvider>
);
}];
}
/**
* A custom wrapper to provision a next-auth/react for a real consumer component
* or a real custom hook
*
* @param {Object} sessionInfo
*
* @returns {[Object, (props: { children: React.ReactNode }) => JSX.Element]}
*
*/
export function getWrapperWithNextAuthv4Session ({
sessionInfo: {
data: Record<string, unknown>,
status: 'loading' | 'unauthenticated' | 'authenticated'
}
}) {
const memoizedSessionInfo = useMemo(() => {
const defaultSessionInfo = { data: undefined, status: "unauthenticated" };
return sessionInfo ? sessionInfo : defaultSessionInfo;
}, [sessionInfo.data]);
return [memoizedSessionInfo, ({ children }: { children: React.ReactNode }) => (
<SessionContext.Provider value={memoizedSessionInfo}>
{children}
</SessionContext.Provider>
)];
}
/**
* A custom wrapper to provision a redux store for a real consumer component
* or a real custom hook
*
* @param {import('react-redux').Store} reduxStore
*
* @returns {[import('react-redux').Store, (props: { children: React.ReactNode }) => JSX.Element]}
*
* @see https://testing-library.com/docs/example-react-redux/
*/
export function getWrapperWithReduxStore (reduxStore: unknown) {
return [reduxStore, ({ children }: { children: React.ReactNode }) => {
return (
<ReactReduxProvider store={reduxStore}>
{ children }
</ReactReduxProvider>
);
}];
}
/**
* A custom wrapper to provision a react-hook-form for a real custom component
*
* @param {import('react-hook-form').UseFormProps} formOptions
*
* @returns {import('react').Provider}
*
* @see: https://kpwags.com/posts/2023/10/03/unit-testing-and-react-hook-form/
*/
export const getWrapperWithReactHookForm = (
{ defaultValues = {} }: Partial<UseFormProps>
): React.Provider<{ children?: React.ReactNode }> => {
const reactHookFormWrapper = ({ children }: { children: React.ReactNode }) => {
const methods = useForm({ defaultValues });
return (
<FormProvider {...methods}>{children}</FormProvider>
);
}
return reactHookFormWrapper;
};
/**
* A custom render for react-hook-form input control consumer component
*
* @param {import('react').ReactElement} InputComponent
* @param {import('@testing-library/react').RenderOptions} renderOptions
*
* @returns {import('@testing-library/react').RenderResult}
*
* @see: https://kpwags.com/posts/2023/10/03/unit-testing-and-react-hook-form/
*/
export function renderInputControlForReactHookForm <Q extends {}>(
InputComponent: React.ReactElement,
renderOptions?: RenderOptions<Q>
) {
return render(InputComponent, Object.assign(renderOptions || {}, {
wrapper: getWrapperWithReactHookForm({})
});
};
/**
* A custom wrapper to provision a router for a real consumer component
* or a real hook
*
* This is use when testing an actual consumer component or hook making
* use of the router or it's hooks: `useHistory()`, `useNavigate()` or
* `useLocation()`
*
* @param {import('react').ComponentClass} Router
* @param {Boolean} chooseMemoryRouter
*
* @returns {[import('history').History, (optionalProps: { getUserConfirmation?: Function, initialEntries?: import('history').LocationDescriptor[], basename?: String, keyLength?: Number }) => ((props: { children: React.ReactNode }) => JSX.Element)]}
*
* @see https://testing-library.com/docs/example-react-router/
*/
export function getWrapperWithRouter<H = unknown> (
Router: React.ComponentClass<BrowserRouterProps | MemoryRouterProps & RouterProps>,
chooseMemoryRouter: boolean,
): [History<H>, (optionalProps: { getUserConfirmation?: Function, initialEntries?: LocationDescriptor[], basename?: string, keyLength?: number }) => (props: { children: React.ReactNode }) => JSX.Element ] {
let history: History<H>;
if (chooseMemoryRouter) {
history = createMemoryHistory<H>();
} else {
history = createBrowserHistory<H>();
}
return [history, ({
getUserConfirmation,
initialEntries,
basename,
keyLength
} = {}) => ({ children }: { children: React.ReactNode }) => (
<Router
history={history}
getUserConfirmation={getUserConfirmation ? getUserConfirmation : undefined}
keyLength={keyLength ? keyLength : undefined}
initialEntries={initialEntries ? initialEntries : undefined}
basename={basename ? basename : undefined}
>
{ children }
</Router>
)
];
};
/**
* A custom render to setup a router for a real consumer component.
* It also creates initial route with state.
*
* This is used when testing an actual consumer component making use
* of the router
*
* @param {Boolean} chooseMemoryRouter
* @param {{ path: String, title?: String, state?: Object }} initialRoute
*
* @returns {[import('history').History, (optionalProps: { getUserConfirmation?: Function, initialEntries?: Array, basename?: String, keyLength?: Number }) => ((props: { children: React.ReactNode }) => JSX.Element)]}
*/
export const setInitialRouteAndReturnRouterProvider = (
chooseMemoryRouter = false,
initialRoute = { path: '/', title: '', state: undefined },
) => {
const [$history, getRouterWrapperProvider] = getWrapperWithRouter(
chooseMemoryRouter ? MemoryRouter : Router,
chooseMemoryRouter || false,
);
setupInitialRoute($history, initialRoute)
return [
$history,
getRouterWrapperProvider
]
};
/**
* A custom render to setup a router for a real consumer component.
* It also creates initial route with state as it renders the real
* consumer component.
*
* This is used when testing an actual consumer component making use
* of the router
*
* @param {React.ReactElement} Component
* @param {{ chooseMemoryRouter?: Boolean, initialEntries?: import().LocationDescriptor[], initialRoute?: { path: string, title: string, state?: Record<string, unknown> | null }, getUserConfirmation?: Function }} routingOptions
* @param {import('@testing-library/react').RenderOptions} renderOptions
*
* @returns {[import('history').History, import('@testing-library/react').RenderResult]}
*/
export function setInitialRouteAndRenderComponentWithRouter <Q extends {}, H = unknown>(
Component: React.ReactElement,
routingOptions:{
initialRoute?: { path: string, title: string, state?: H },
chooseMemoryRouter?: boolean,
getUserConfirmation?: ((message: string, callback: (ok: boolean) => void) => void) | undefined,
initialEntries?: LocationDescriptor[] | undefined,
} = {
initialRoute: { path: '/', title: '', state: undefined },
chooseMemoryRouter: false
},
renderOptions?: RenderOptions<Q>
): [ History<H>, RenderResult<Q> ] {
const [ $history, getRouterWrapperProvider ] = getWrapperWithRouter<H>(
routingOptions.chooseMemoryRouter
? MemoryRouter
: BrowserRouter,
routingOptions.chooseMemoryRouter || false
);
setupInitialRoute($history, routingOptions.initialRoute);
return [$history, render(
Component,
Object.assign(
renderOptions || {},
{
wrapper: getRouterWrapperProvider({
getUserConfirmation: routingOptions.getUserConfirmation,
initialEntries: routingOptions.initialEntries
})
}
)
)];
}
/**
* A custom render to setup a router for a real consumer hook.
* It also creates initial route with state as it renders the real
* consumer hook.
*
* This is used when testing an actual consumer hook making use of
* the router
*
* @param {Function} Hook
* @param {Object} routingOptions
* @param {import('@testing-library/react').RenderHookOptions} renderOptions
*
* @returns {[import('history').History, import('@testing-library/react').RenderHookResult]}
*/
export function setInitialRouteAndRenderHookWithRouter <Q extends {}, H = unknown>(
Hook: (props: Q) => unknown,
routingOptions:{
initialRoute?: { path: string, title: string, state?: H },
chooseMemoryRouter?: boolean,
getUserConfirmation?: ((message: string, callback: (ok: boolean) => void) => void) | undefined,
initialEntries?: LocationDescriptor[] | undefined,
} = {
initialRoute: { path: '/', title: '', state: undefined },
chooseMemoryRouter: false
},
renderOptions?: RenderHookOptions<Q>
): [ History<H>, RenderHookResult<Q, unknown> ] {
const [ $history, getRouterWrapperProvider ] = getWrapperWithRouter<H>(
routingOptions.chooseMemoryRouter
? MemoryRouter
: BrowserRouter,
routingOptions.chooseMemoryRouter || false,
);
setupInitialRoute($history, routingOptions.initialRoute);
return [$history, renderHook(
Hook,
Object.assign(
renderOptions || {},
{
wrapper: getRouterWrapperProvider({
getUserConfirmation: routingOptions.getUserConfirmation,
initialEntries: routingOptions.initialEntries
})
}
)
)];
}
/**
* A custom render to setup providers with a real consumer component.
* Extends regular render options with `providerProps` to allow
* injecting different scenarios to test with.
*
* This is used when testing a actual consumer component making use
* of the provider
*
* @param {import('react').Provider} Provider
*
* @returns {((import('react').ReactElement, { providerProps: any }, import('@testing-library/react').RenderOptions) => import('@testing-library/react').RenderResult)}
*
* @see https://testing-library.com/docs/react-testing-library/setup#custom-render
*/
export function getCustomRendererFor<Q extends {}, T> (Provider: React.Provider<T>) {
return (
WrappedComponent: React.ReactElement,
{ providerProps }: { providerProps: T },
renderOptions?: RenderOptions<Q>) => {
return render(
<Provider value={providerProps}>{WrappedComponent}</Provider>,
renderOptions
);
};
}
/**
* A custom render to setup provider with a test context consumer component.
* Extends regular render options with a custom props to allow inpecting
* state changes.
*
* This is used to render a provider while testing it directly.
*
* @param {import('react').Provider} Provider
* @param {{ children: import('react').ReactNode }} props
* @param {import('@testing-library/react').RenderOptions} renderOptions
*
* @returns {import('@testing-library/react').RenderResult}
*/
export function renderProvider <P extends Record<string, unknown>, Q extends {}>(
Provider: ((props: P & { children?: React.ReactNode }) => JSX.Element | null),
props: P & { children?: React.ReactNode },
renderOptions?: RenderOptions<Q>
) {
const [children, ...restProps] = props;
return render(
<Provider {...restProps}>{children ? children : null}</Provider>,
renderOptions
)
}
/**
* A helper utility for replacing native object and BOM APIs in web browsers
* with a fake implementation replica so as to make testing a lot easier.
*
* @param {String | keyof Window} property
* @param {*} value
*
* @returns {void}
*/
export const provisionFakeWebPageWindowObject = (property: keyof Window, value: unknown) => {
const { [property]: originalProperty } = window;
beforeAll(() => {
assertReadonlyGlobalsNotMutable(property);
delete window[property];
Object.defineProperty(window, property, {
configurable: true,
writable: true,
value,
});
});
afterAll(() => {
if (Boolean(originalProperty)) {
//@ts-ignore
window[property as Writeonly<Window>] = originalProperty;
}
});
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment