Last active
March 3, 2024 20:05
-
-
Save pedro-mass/71f7b2a16e70349d45a8224ea221f36f to your computer and use it in GitHub Desktop.
Test Helpers
This file contains hidden or 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
// @ts-check | |
// ref: https://github.com/pedro-mass/learn/blob/b07f49b56802646e4e05137a0ff77219508c29c8/javascript/code-katas/src/test-helpers.js | |
import React from "react"; | |
import partial from "lodash/partial"; | |
import { Provider } from "react-redux"; | |
import { createStore } from "./redux/store"; | |
import { arrayify } from "./utils/array"; | |
export { createStore }; | |
/** | |
* Gives provider for the redux store. | |
* | |
* Ideally we can migrate to better practices, but we're not there yet: | |
* https://redux.js.org/usage/writing-tests#setting-up-a-reusable-test-render-function | |
* | |
* @param {import("react").PropsWithChildren<{ | |
* store: ReturnType<import("./redux/store").createStore>; | |
* }>} props | |
*/ | |
export function StoreProvider({ children, store }) { | |
store = store ?? createStore(); | |
// @ts-expect-error - doesn't think <Provider /> is valid JSX | |
return <Provider store={store}>{children}</Provider>; | |
} | |
/** @param {{ name?: string; input?: any; arrange?: () => { input?: any } }} testCase */ | |
export const getTestName = (testCase) => { | |
if (testCase.name) { | |
return testCase.name; | |
} | |
const input = testCase.input ?? testCase.arrange?.().input; | |
return "input: " + JSON.stringify(input)?.slice(0, 150); | |
}; | |
/** | |
* Sets up a describe block, of the function name, with the testCases set up to | |
* support .only() & .skip() | |
* | |
* Usefully primarily for successful cases. | |
* | |
* Do normal flow for error cases | |
* | |
* @template {any[]} FnArgTypes | |
* @template FnReturnType | |
* @param {TestModifiers & { | |
* fnUnderTest: (...args: [...FnArgTypes]) => FnReturnType; | |
* assertWithSnapshot?: boolean; | |
* testCases: TestCase<FnArgTypes, FnReturnType>[]; | |
* } & { name?: string }} input | |
*/ | |
export const runTestCases = ({ | |
testCases, | |
fnUnderTest, | |
name, | |
assertWithSnapshot, | |
...config | |
}) => { | |
getDescribeFn(config)?.each(testCases)( | |
`${name ?? fnUnderTest.name}()`, | |
(testCase) => { | |
const name = getTestName(/** @type {TestCase} */ (testCase)); | |
const test = getItFn(testCase); | |
const arranged = testCase.arrange?.(); | |
const input = /** @type {FnArgTypes} */ ( | |
arrayify(testCase.input ?? arranged?.input) | |
); | |
const expected = /** @type {FnReturnType} */ ( | |
testCase.expected ?? arranged?.expected | |
); | |
const assertStep = testCase.assert ?? arranged?.assert; | |
test(name, () => { | |
if (assertWithSnapshot) { | |
return expect(fnUnderTest(...input)).toMatchSnapshot(); | |
} | |
if (assertStep) { | |
return assertStep({ | |
actual: fnUnderTest(...input), | |
expected, | |
}); | |
} | |
expect(fnUnderTest(...input)).toEqual(expected); | |
}); | |
} | |
); | |
}; | |
/** | |
* @template [FnType=typeof it] Default is `typeof it` | |
* @param {FnType} fn | |
* @param {TestModifiers} [config] | |
* @returns {FnType} | |
*/ | |
const getTestFn = (fn, config = {}) => { | |
const command = config?.only ? "only" : config?.skip ? "skip" : undefined; | |
return command ? fn[command] : fn; | |
}; | |
const getDescribeFn = partial(getTestFn, describe); | |
const getItFn = partial(getTestFn, it); | |
/** | |
* Filters down to .only(), and removes .skip() | |
* | |
* @param {(TestModifiers & import("immer/dist/internal").AnyObject)[]} cases | |
*/ | |
export const getTestCases = (cases = []) => { | |
const filteredCases = cases.filter((c) => c.only); | |
/** @param {typeof cases} _cases */ | |
const removeSkipped = (_cases) => _cases.filter((c) => c.skip !== true); | |
return filteredCases?.length > 0 | |
? removeSkipped(filteredCases) | |
: removeSkipped(cases); | |
}; | |
/** @param {TestCase} testCase */ | |
export function assertWithSnapshot(testCase) { | |
return { | |
...testCase, | |
assert: ({ actual }) => { | |
expect(actual).toMatchSnapshot(); | |
}, | |
}; | |
} | |
export function createMockEnv() { | |
const OLD_ENV = process.env; | |
const init = () => { | |
jest.resetModules(); // Most important - it clears the cache | |
process.env = { ...OLD_ENV }; // Make a copy | |
}; | |
const reset = () => { | |
process.env = OLD_ENV; // Restore old environment | |
}; | |
return { OLD_ENV, init, reset, beforeEach: init, afterEach: reset }; | |
} | |
// types: | |
/** @typedef {{ only?: boolean; skip?: boolean }} TestModifiers */ | |
/** | |
* @template [T=any] Default is `any` | |
* @typedef {Record<string | number | symbol, T>} AnyObject | |
*/ | |
/** | |
* @template [TestCaseInputs=any[]] Default is `any[]` | |
* @template [TestCaseExpected=any] Default is `any` | |
* @typedef {TestModifiers & { | |
* name?: string; | |
* expected?: TestCaseExpected; | |
* input?: TestCaseInputs; | |
* arrange?: () => { | |
* input?: TestCaseInputs; | |
* expected?: TestCaseExpected; | |
* assert?: (input: { | |
* actual: TestCaseExpected; | |
* expected: TestCaseExpected; | |
* testCase?: TestCase<TestCaseInputs, TestCaseExpected>; | |
* }) => void; | |
* }; | |
* assert?: (input: { | |
* actual: TestCaseExpected; | |
* expected: TestCaseExpected; | |
* testCase?: TestCase<TestCaseInputs, TestCaseExpected>; | |
* }) => void; | |
* }} TestCase | |
*/ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment