Last active
July 13, 2024 19:08
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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