Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
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}