test: fail a test whose code caught and logged a missing mock export

A module mock that leaves out an export the code under test reaches throws
at the call, and where that code catches and logs (a best-effort tail, a
fail-soft read) the test went on green while the path it meant to cover
never ran. The Apple Health import tests mocked the rollup module without
its fold window, so the import tail's rollup fold and cache invalidation
never ran in any of them; four route test files mocked the logging context
or the rollups without what the measurement tail reaches.

Both setup files now watch the console and fail the test in which such a
line was logged. The ECG import test keeps the real rollup module and
stubs only the fold, and the four unit mocks gain what they lacked.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 23:15:43 +02:00
1 parent 2844088251
commit b09a6d2df8
9 files changed
+107 -4

No files matched your search

@@ -0,0 +1,27 @@
/**
* The setup files fail a test that logged a caught "missing mock export"
* error (`tests/support/swallowed-mock-errors.ts`). Pinned in both
* directions, so the check cannot be a no-op.
*/
import { describe, expect, it } from "vitest";
import { watchSwallowedMockErrors } from "../../tests/support/swallowed-mock-errors";
describe("the swallowed mock-export check", () => {
it("fails on a caught missing-export error that was only logged", () => {
const check = watchSwallowedMockErrors();
console.warn(
"[import] Rollup recompute failed",
new Error(
'[vitest] No "ROLLUP_FOLD_WINDOW_MS" export is defined on the "@/lib/rollups/measurement-rollups" mock. Did you forget to return it from "vi.mock"?',
),
);
expect(check).toThrow(/ROLLUP_FOLD_WINDOW_MS/);
});
it("lets every other log line through", () => {
const check = watchSwallowedMockErrors();
console.warn("[import] Rollup recompute failed", new Error("db down"));
expect(check).not.toThrow();
});
});
+2 -1
View File
@@ -29,7 +29,8 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined), auditLog: vi.fn().mockResolvedValue(undefined),
})); }));
vi.mock("@/lib/logging/context", () => ({ vi.mock("@/lib/logging/context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/logging/context")>()),
annotate: vi.fn(), annotate: vi.fn(),
})); }));
@@ -50,7 +50,8 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined), auditLog: vi.fn().mockResolvedValue(undefined),
})); }));
vi.mock("@/lib/logging/context", () => ({ vi.mock("@/lib/logging/context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/logging/context")>()),
annotate: vi.fn(), annotate: vi.fn(),
})); }));
@@ -52,7 +52,10 @@ vi.mock("@/lib/db", () => {
}); });
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); vi.mock("@/lib/logging/context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/logging/context")>()),
annotate: vi.fn(),
}));
vi.mock("@/lib/logging/fire-and-forget", () => ({ vi.mock("@/lib/logging/fire-and-forget", () => ({
fireAndForget: (promise: Promise<unknown>) => void promise.catch(() => {}), fireAndForget: (promise: Promise<unknown>) => void promise.catch(() => {}),
})); }));
@@ -83,6 +86,7 @@ vi.mock("@/lib/illness/safety-floor-check", () => ({
})); }));
vi.mock("@/lib/rollups/measurement-rollups", () => ({ vi.mock("@/lib/rollups/measurement-rollups", () => ({
recomputeBucketsForMeasurement: vi.fn(async () => {}), recomputeBucketsForMeasurement: vi.fn(async () => {}),
recomputeBucketsForKeys: vi.fn(async () => {}),
collapseToTypeDayKeys: vi.fn( collapseToTypeDayKeys: vi.fn(
(rows: Array<{ type: string; measuredAt: Date }>) => rows, (rows: Array<{ type: string; measuredAt: Date }>) => rows,
), ),
@@ -41,6 +41,7 @@ vi.mock("@/lib/rollups/measurement-rollups", async () => {
return { return {
collapseToTypeDayKeys: actual.collapseToTypeDayKeys, collapseToTypeDayKeys: actual.collapseToTypeDayKeys,
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined), recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
recomputeBucketsForKeys: vi.fn().mockResolvedValue(undefined),
}; };
}); });
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
@@ -31,7 +31,13 @@ vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: vi.fn(() => null), getGlobalBoss: vi.fn(() => null),
})); }));
vi.mock("@/lib/rollups/measurement-rollups", () => ({ // The real module with only the fold stubbed, so the import's tail (its fold
// window, the cache invalidation, the status re-warm) runs as it does in
// production.
vi.mock("@/lib/rollups/measurement-rollups", async (importOriginal) => ({
...(await importOriginal<
typeof import("@/lib/rollups/measurement-rollups")
>()),
recomputeUserRollups: vi.fn(async () => {}), recomputeUserRollups: vi.fn(async () => {}),
})); }));
+5
View File
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, inject, vi } from "vitest"; import { afterEach, beforeEach, inject, vi } from "vitest";
import { quarantineEntryFor } from "../quarantine/quarantine"; import { quarantineEntryFor } from "../quarantine/quarantine";
import { watchSwallowedMockErrors } from "../support/swallowed-mock-errors";
import { prepareWorkerDatabase } from "./worker-database"; import { prepareWorkerDatabase } from "./worker-database";
// Each worker runs against its own clone of the migrated template (see // Each worker runs against its own clone of the migrated template (see
@@ -30,10 +31,14 @@ process.env.SESSION_SECRET = "integration-test-session-secret-32-bytes";
* the worker BEFORE any application module reads it at import time, and a * the worker BEFORE any application module reads it at import time, and a
* static import here would pull part of the application tree in first. * static import here would pull part of the application tree in first.
*/ */
/** A swallowed "missing mock export" error fails its test (see the module). */
const checkSwallowedMockErrors = watchSwallowedMockErrors();
afterEach(async () => { afterEach(async () => {
const { settleBackgroundTasks } = const { settleBackgroundTasks } =
await import("@/lib/logging/background-tasks"); await import("@/lib/logging/background-tasks");
await settleBackgroundTasks(); await settleBackgroundTasks();
checkSwallowedMockErrors();
}); });
/** The flaky-test quarantine, as in the unit setup (`vitest.setup.ts`). */ /** The flaky-test quarantine, as in the unit setup (`vitest.setup.ts`). */
+52
View File
@@ -0,0 +1,52 @@
/**
* A module mock that leaves out an export the code under test reaches throws
* `No "X" export is defined on the "Y" mock` at the call. Where the code
* catches and logs that (a best-effort tail, a fail-soft read), the test went
* on green while the code path it meant to cover never ran: the Apple Health
* import tests mocked the rollup module without its fold window, and the
* import's cache invalidation never ran in any of them.
*
* `watchSwallowedMockErrors` wraps the console once per worker and returns
* the check a setup file runs after each test: it throws, failing that test,
* when such a line was logged during it.
*/
const MISSING_MOCK_EXPORT = /No "[^"]+" export is defined on the "[^"]+" mock/;
function textOf(arg: unknown): string {
if (arg instanceof Error) return `${arg.message} ${String(arg.cause ?? "")}`;
return typeof arg === "string" ? arg : "";
}
const SEEN = Symbol.for("healthlog.tests.swallowedMockErrors");
export function watchSwallowedMockErrors(): () => void {
// A setup file runs once per test file, and a worker runs many files: the
// console is wrapped once per worker, and every file shares what it saw.
const slot = globalThis as unknown as { [SEEN]?: string[] };
let seen = slot[SEEN];
if (!seen) {
const lines: string[] = [];
seen = lines;
slot[SEEN] = lines;
for (const level of ["log", "info", "warn", "error"] as const) {
const original = console[level].bind(console);
console[level] = (...args: unknown[]) => {
for (const arg of args) {
const match = MISSING_MOCK_EXPORT.exec(textOf(arg));
if (match) lines.push(match[0]);
}
original(...args);
};
}
}
const lines = seen;
return () => {
if (lines.length === 0) return;
const found = [...new Set(lines)];
lines.length = 0;
throw new Error(
"a module mock lacks an export the code under test reached, and the " +
`error was caught and logged instead of failing: ${found.join("; ")}`,
);
};
}
+6
View File
@@ -21,6 +21,7 @@
import { afterEach, beforeEach, vi } from "vitest"; import { afterEach, beforeEach, vi } from "vitest";
import { settleBackgroundTasks } from "@/lib/logging/background-tasks"; import { settleBackgroundTasks } from "@/lib/logging/background-tasks";
import { quarantineEntryFor } from "./tests/quarantine/quarantine"; import { quarantineEntryFor } from "./tests/quarantine/quarantine";
import { watchSwallowedMockErrors } from "./tests/support/swallowed-mock-errors";
import { primeMessages } from "@/lib/i18n/load-locale"; import { primeMessages } from "@/lib/i18n/load-locale";
import enMessages from "./messages/en.json"; import enMessages from "./messages/en.json";
@@ -41,8 +42,13 @@ primeMessages("en", enMessages);
* console, which would have thrown away a real signal and left the * console, which would have thrown away a real signal and left the
* non-determinism in place. See `src/lib/logging/background-tasks.ts`. * non-determinism in place. See `src/lib/logging/background-tasks.ts`.
*/ */
const checkSwallowedMockErrors = watchSwallowedMockErrors();
afterEach(async () => { afterEach(async () => {
await settleBackgroundTasks(); await settleBackgroundTasks();
// A caught "missing mock export" error fails the test it happened in
// (`tests/support/swallowed-mock-errors.ts`).
checkSwallowedMockErrors();
}); });
/** /**