test(e2e): design, plan, and the find-or-fail helper (#241) #247

Merged
bermudalamb merged 3 commits from fix/241-e2e-isolation into main 2026-08-31 14:44:14 -05:00
3 changed files with 87 additions and 0 deletions
Showing only changes of commit 64324609e1 - Show all commits
+7
View File
@@ -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.
*
+38
View File
@@ -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<T>(
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;
}
+42
View File
@@ -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/);
});
});