Mirror of https://github.com/roostorg/coop github.com/roostorg/coop
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 557ff54b2b435e5f1e789c6a8a4e1bebf2d7deb6 241 lines 8.7 kB view raw
1/** 2 * Jest doesn't always do a good job surfacing thrown errors (or promise 3 * rejections), so we use this helper a lot to log them w/ the test results. 4 */ 5export function logErrorAndThrow(e: Error): never { 6 console.error(e); 7 throw e; 8} 9 10type Fixture<T extends Record<string, unknown>> = T & { 11 cleanup?(): void | Promise<void>; 12}; 13 14/** 15 * This function simplifies the process of defining one or more tests that: a) 16 * need some data or objects ("fixtures") to be setup before the test runs; and 17 * b) need to cleanup that data when the test is complete. To see how this 18 * function is useful, consider how you might setup/cleanup some fixtures 19 * without this function: 20 * 21 * First, imagine that you only want the fixtures for a single test. In that 22 * case, you might simply do: 23 * 24 * ```ts 25 * it("some test...", () => { 26 * // setup data/fixtures here ("arrange") 27 * 28 * // run test logic ("act" + "assert") 29 * 30 * // do cleanup. 31 * }) 32 * ``` 33 * 34 * But there are two problems: 35 * 36 * 1) if any of the test logic throws (i.e., if the test fails), then the 37 * cleanup code will never run, leaving the system in an undesirable state. 38 * How problematic this is depends on exactly what state is left 39 * un-cleaned-up, and how our tests are written -- i.e., if most of our 40 * tests are written to not be effected by state created for other tests, 41 * which is a prerequisite for running tests in parallel, then the dangling 42 * state wouldn't matter for those tests. However, our tests aren't 43 * currently written like that, and there are likely to always be at least 44 * some cases where we'll want serial execution between a set of tests. For 45 * those, reliably executing the cleanup logic is important. 46 * 47 * 2) there's no way to share the setup and cleanup logic across a set of tests, 48 * which is often convenient (e.g., every test in some suite may want to 49 * create an X at the beginning and delete it at the end). 50 * 51 * To solve these problems, jest et al introduce the `beforeEach()` and 52 * `afterEach()` methods for defining setup logic that'll run before/after all 53 * the tests in a give suite. The advantage is that `afterEach()` runs even if 54 * the test fails, saving the test code from being wrapped in a `try-finally`, 55 * and the same `beforeEach`/`afterEach` logic can apply to multiple tests by 56 * wrapping all those tests in the same `describe()` block (although the need 57 * for that extra wrapping can sometimes feel a bit artifical and hurt 58 * organization). 59 * 60 * However, with this `beforeEach`/`afterEach` pattern, a mutable, shared 61 * variable is needed for a test (or the cleanup code) to get access to a value 62 * created by `beforeEach`, like so: 63 * 64 * ```ts 65 * let fixtureValue: SomeType; 66 * beforeEach(async () => { 67 * fixtureValue = .... 68 * }) 69 * 70 * afterEach(async () => { 71 * await deleteFromDb(fixtureValue.id) 72 * }) 73 * 74 * test('...', () => { 75 * callSomething(fixtureValue); 76 * }) 77 * 78 * test('...', () => { 79 * callSomethingElse(fixtureValue); 80 * }) 81 * ```` 82 * 83 * The problem with this, fundamentally, is that it makes it impossible to ever 84 * run the tests in parallel, because each test is referring to (and could 85 * mutate) the shared `fixtureValue` variable. For that same reason, it also 86 * makes the tests harder to read/refactor, as it's less clear whether the order 87 * of the test matters (or whether a subsequent test will fail if a prior one is 88 * skipped). 89 * 90 * `makeTestWithFixture` offers the same value as `beforeEach`/`afterEach` -- 91 * i.e. reusable setup/teardown logic, that'll run even if tests fail -- but 92 * without the downsides of the tests referring to shared mutable variables. 93 * 94 * You use it like this: 95 * 96 * ```ts 97 * // NB: name this constant something more appropriate based on the fixtures 98 * // you're defining. 99 * const testWithTwoSpecificFixtures = makeTestWithFixture(async () => { 100 * // create new object that is a fixture. 101 * const [fixtureOne, fixtureTwo] = await Promise.all([ 102 * addSomethingToDb(), // for example. 103 * addSomethingToDb() 104 * ]; 105 * 106 * return { 107 * // the keys can be called whatever you want; one key per fixture value. 108 * fixtureOne, 109 * fixtureTwo, 110 * // this cleanup function has to be called cleanup. 111 * async cleanup() { 112 * await Promise.all([deleteFromDb(fixtureOne), deleteFromDb(fixtureTwo)]) 113 * } 114 * } 115 * }) 116 * ``` 117 * 118 * This returns a new function, which is saved into `testWithObject` that, when 119 * called, defines a jest test that has access to the fixture values (i.e., 120 * `fixtureOne` and `fixtureTwo`) as an argument to the function containing the 121 * test code. The fixture values are destructurable by name. Then, the cleanup 122 * code automatically runs after the test, regardless of whether the test fails. 123 * 124 * For example, to define two tests, each of which will get a fresh copy of 125 * `fixtureOne` and `fixtureTwo` (i.e., the fixture-creating-and-cleanup 126 * function passed to `makeTestWithFixture` will run for each test): 127 * 128 * ```ts 129 * // Define the test by calling testWithObject, and destructure the fixture 130 * // values by name. 131 * 132 * testWithTwoSpecificFixtures('...', ({ fixtureOne, fixtureTwo }) => { 133 * callSomething(fixtureOne); 134 * }) 135 * 136 * testWithTwoSpecificFixtures('...', ({ fixtureOne, fixtureTwo }) => { 137 * callSomethingElse(fixtureTwo); 138 * }) 139 * ``` 140 * 141 * @returns A function that registers/defines a test. This takes a name and a 142 * function containing the code for the test case. That function can receives 143 * the fixtures as an object and can destructure them by name. 144 */ 145export function makeTestWithFixture<T extends Record<string, unknown>>( 146 makeSetupTeardown: () => Promise<Fixture<T>> | Fixture<T>, 147) { 148 type JestItCall = ( 149 name: string, 150 fn: (vars: T) => void | Promise<void>, 151 timeout?: number | undefined, 152 ) => void; 153 154 const fn = _makeTestWithFixture(makeSetupTeardown) as JestItCall & { 155 only: JestItCall; 156 skip: JestItCall; 157 todo: JestItCall; 158 }; 159 // eslint-disable-next-line better-mutation/no-mutation 160 fn.only = _makeTestWithFixture(makeSetupTeardown, it.only); 161 // eslint-disable-next-line better-mutation/no-mutation 162 fn.skip = _makeTestWithFixture(makeSetupTeardown, it.skip); 163 // eslint-disable-next-line better-mutation/no-mutation 164 fn.todo = _makeTestWithFixture(makeSetupTeardown, it.todo); 165 return fn; 166} 167 168function _makeTestWithFixture<T extends Record<string, unknown>>( 169 makeSetupTeardown: () => Promise<Fixture<T>> | Fixture<T>, 170 jestFn = it, 171) { 172 return ( 173 name: string, 174 testFn: (vars: T) => void | Promise<void>, 175 timeout?: number, 176 ) => 177 jestFn( 178 name, 179 // The function here returns a promise _if and only if_ the original 180 // testFn returned a promise, to keep synchronous tests synchronous. 181 /* eslint-disable @typescript-eslint/promise-function-async */ 182 (() => { 183 return continueWith( 184 () => makeSetupTeardown(), 185 ({ cleanup, ...variables }) => { 186 return continueWith( 187 () => testFn(variables as T), 188 (testRes) => { 189 // if test succeeded, call cleanup, throw its error (if any), 190 // else return test result. 191 return continueWith( 192 () => cleanup?.(), 193 () => testRes, 194 (e) => { 195 throw e; 196 }, 197 ); 198 }, 199 (e) => { 200 // If test threw, call cleanup and then throw test's error 201 // (even if cleanup throws). 202 return continueWith( 203 () => cleanup?.(), 204 () => { 205 throw e; 206 }, 207 () => { 208 throw e; 209 }, 210 ); 211 }, 212 ); 213 }, 214 (e) => { 215 // If makeSetupTeardown threw, nothing we can do but pass that error along. 216 throw e; 217 }, 218 ); 219 }) as jest.ProvidesCallback, 220 /* eslint-enable @typescript-eslint/promise-function-async */ 221 timeout, 222 ); 223} 224 225function continueWith<T, U>( 226 getValue: () => T | Promise<T>, 227 then: (it: T) => U, 228 catcher: (e: unknown) => void, 229): Awaited<U> | Promise<Awaited<U>> | void | Promise<void> { 230 try { 231 const res = getValue(); 232 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 233 if (res && typeof res === 'object' && 'then' in res) { 234 return res.then(then, catcher) as Promise<Awaited<U>> | Promise<void>; 235 } else { 236 return then(res) as Awaited<U>; 237 } 238 } catch (e) { 239 catcher(e); 240 } 241}