diff --git a/frontend/tests/e2e/support/api.ts b/frontend/tests/e2e/support/api.ts index 282ca15..2f15b9f 100644 --- a/frontend/tests/e2e/support/api.ts +++ b/frontend/tests/e2e/support/api.ts @@ -1,5 +1,12 @@ import { APIRequestContext, expect } from '@playwright/test'; +// Re-exported so specs get it from './fixtures' with everything else, rather +// than reaching into support/ directly — fixtures.ts does `export * from +// './support/api'`. It lives in its own module because that one imports +// nothing, which is what lets the Vitest unit suite cover it without loading +// @playwright/test. See findOrFail.ts. +export { findOrFail } from './findOrFail'; + /** * Where the app under test is served. * diff --git a/frontend/tests/e2e/support/findOrFail.ts b/frontend/tests/e2e/support/findOrFail.ts new file mode 100644 index 0000000..d971086 --- /dev/null +++ b/frontend/tests/e2e/support/findOrFail.ts @@ -0,0 +1,38 @@ +/** + * Picks one row out of a collection, failing loudly when it is not there. + * + * The pattern this replaces is `collection.find(...)` dereferenced straight + * away — `customers.find(c => c.email === x).id`. When the row is missing the + * test dies with "Cannot read properties of undefined" pointing at a line of + * test plumbing, which says nothing about what was expected. The suite runs + * fullyParallel against one shared database, so a lookup over a collection + * other specs also write to can miss for reasons that have nothing to do with + * the behaviour under test (#241). + * + * Deliberately imports nothing. It lives apart from `api.ts` so the Vitest + * unit suite can cover it without pulling `@playwright/test` — and therefore a + * browser harness — into a run configured for `environment: 'node'`. + * + * Throws rather than returning null: every caller wants the row, and an + * Error at the point of the miss beats a null threaded through three more + * lines before something else fails. + */ +export function findOrFail( + items: T[], + predicate: (item: T) => boolean, + description: string +): T { + const found = items.find(predicate); + + if (found === undefined) { + // The count matters as much as the description: "0 rows" means the + // fixture never landed, while "37 rows" means it landed and the predicate + // is wrong. Those are different bugs and the message should tell them + // apart without a re-run. + throw new Error( + `expected to find ${description}, but none of the ${items.length} rows matched` + ); + } + + return found; +} diff --git a/frontend/tests/unit/findOrFail.test.ts b/frontend/tests/unit/findOrFail.test.ts new file mode 100644 index 0000000..1f9551d --- /dev/null +++ b/frontend/tests/unit/findOrFail.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { findOrFail } from '../e2e/support/findOrFail'; + +interface Row { + id: number; + name: string; +} + +const rows: Row[] = [ + { id: 1, name: 'first' }, + { id: 2, name: 'second' } +]; + +describe('findOrFail', () => { + it('returns the matching row', () => { + expect(findOrFail(rows, (r) => r.name === 'second', 'the second row')).toEqual({ + id: 2, + name: 'second' + }); + }); + + it('returns the first match when several qualify', () => { + expect(findOrFail(rows, () => true, 'anything').id).toBe(1); + }); + + // The whole point. Dereferencing a missed `.find()` gives "Cannot read + // properties of undefined" pointing at test plumbing; this says what was + // wanted and how many rows were searched. + it('throws naming what it looked for', () => { + expect(() => findOrFail(rows, (r) => r.name === 'absent', 'the absent row')).toThrow( + /the absent row/ + ); + }); + + it('reports how many rows it searched', () => { + expect(() => findOrFail(rows, () => false, 'nothing')).toThrow(/2 rows/); + }); + + it('says so when the collection was empty, which reads differently', () => { + expect(() => findOrFail([], () => true, 'anything')).toThrow(/0 rows/); + }); +});