test: shared React hook harness for the jsdom-less tests (#409) #434
2 changed files with 551 additions and 0 deletions
286
src/test/hookHarness.test.ts
Normal file
286
src/test/hookHarness.test.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
// hookHarness — each simulated semantic proved by its own test (#409).
|
||||
//
|
||||
// The tests that rely on the harness can only be as true as it is. Here it is
|
||||
// driven directly: a harness of its own, a fake React built over the real one,
|
||||
// and small components written for the test.
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import * as actualReact from "react";
|
||||
import { createHookHarness, fakeReact, hookHarness, type HookHarness } from "./hookHarness";
|
||||
|
||||
let harness: HookHarness;
|
||||
let R: typeof actualReact;
|
||||
|
||||
beforeEach(() => {
|
||||
harness = createHookHarness();
|
||||
R = fakeReact(actualReact, harness);
|
||||
});
|
||||
|
||||
describe("hookHarness — the module itself", () => {
|
||||
it("imports React only as a type: a value import would load the mock it builds", () => {
|
||||
// The code only: the header comment shows the pattern of use.
|
||||
const source = readFileSync(new URL("./hookHarness.ts", import.meta.url), "utf8")
|
||||
.split("\n")
|
||||
.filter((line) => !line.trimStart().startsWith("//"))
|
||||
.join("\n");
|
||||
const reactImports = source.split("\n").filter((line) => /from\s+["']react["']/.test(line));
|
||||
expect(reactImports.length).toBeGreaterThan(0);
|
||||
for (const line of reactImports) expect(line).toMatch(/^import type /);
|
||||
expect(source).not.toMatch(/require\(["']react["']\)/);
|
||||
expect(source).not.toMatch(/import\(["']react["']\)/);
|
||||
});
|
||||
|
||||
it("shares one harness through its accessor", () => {
|
||||
expect(hookHarness()).toBe(hookHarness());
|
||||
});
|
||||
|
||||
it("leaves the rest of React real", () => {
|
||||
expect(R.isValidElement).toBe(actualReact.isValidElement);
|
||||
expect(R.Fragment).toBe(actualReact.Fragment);
|
||||
expect(R.createElement).toBe(actualReact.createElement);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hookHarness — useState", () => {
|
||||
it("calls the initializer once, at the first render", () => {
|
||||
const init = vi.fn(() => 1);
|
||||
const Counter = () => R.useState(init)[0];
|
||||
expect(harness.render(Counter)).toBe(1);
|
||||
expect(harness.render(Counter)).toBe(1);
|
||||
expect(init).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps a stable setter, applies functional updates, and does not re-render", () => {
|
||||
let renders = 0;
|
||||
const Counter = () => {
|
||||
renders++;
|
||||
return R.useState(0);
|
||||
};
|
||||
const [, set] = harness.render(Counter);
|
||||
set(5);
|
||||
set((n: number) => n + 1);
|
||||
expect(renders).toBe(1);
|
||||
const [value, again] = harness.render(Counter);
|
||||
expect(value).toBe(6);
|
||||
expect(again).toBe(set);
|
||||
});
|
||||
|
||||
it("keeps each slot apart, by call order", () => {
|
||||
const Pair = () => [R.useState("a")[0], R.useState("b")[0]];
|
||||
expect(harness.render(Pair)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("reads a slot the test preset before the first render", () => {
|
||||
harness.slots[0] = "preset";
|
||||
expect(harness.render(() => R.useState("init")[0])).toBe("preset");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hookHarness — useReducer", () => {
|
||||
const reducer = (state: number, action: { by: number }) => state + action.by;
|
||||
|
||||
it("applies the reducer, keeps a stable dispatch, and records every action in the spy", () => {
|
||||
const Sum = () => R.useReducer(reducer, 10);
|
||||
const [state, dispatch] = harness.render(Sum);
|
||||
expect(state).toBe(10);
|
||||
dispatch({ by: 2 });
|
||||
dispatch({ by: 3 });
|
||||
const [next, again] = harness.render(Sum);
|
||||
expect(next).toBe(15);
|
||||
expect(again).toBe(dispatch);
|
||||
expect(harness.dispatch.mock.calls.map((c) => c[0])).toEqual([{ by: 2 }, { by: 3 }]);
|
||||
});
|
||||
|
||||
it("calls the init function once", () => {
|
||||
const init = vi.fn((n: number) => n * 2);
|
||||
const Sum = () => R.useReducer(reducer, 4, init)[0];
|
||||
expect(harness.render(Sum)).toBe(8);
|
||||
expect(harness.render(Sum)).toBe(8);
|
||||
expect(init).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hookHarness — useRef", () => {
|
||||
it("keeps one object from render to render", () => {
|
||||
const Ref = () => R.useRef(0);
|
||||
const first = harness.render(Ref);
|
||||
first.current = 7;
|
||||
const second = harness.render(Ref);
|
||||
expect(second).toBe(first);
|
||||
expect(second.current).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hookHarness — useMemo and useCallback", () => {
|
||||
it("useMemo keeps its value while the dependencies are equal, recomputes when one changes", () => {
|
||||
const compute = vi.fn((n: number) => ({ n }));
|
||||
let dep = 1;
|
||||
const Memo = () => R.useMemo(() => compute(dep), [dep]);
|
||||
const first = harness.render(Memo);
|
||||
expect(harness.render(Memo)).toBe(first);
|
||||
expect(compute).toHaveBeenCalledTimes(1);
|
||||
dep = 2;
|
||||
const second = harness.render(Memo);
|
||||
expect(second).not.toBe(first);
|
||||
expect(second).toEqual({ n: 2 });
|
||||
expect(compute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("compares the dependencies with Object.is", () => {
|
||||
let dep: unknown = NaN;
|
||||
const compute = vi.fn(() => ({}));
|
||||
const Memo = () => R.useMemo(compute, [dep]);
|
||||
harness.render(Memo);
|
||||
harness.render(Memo);
|
||||
expect(compute).toHaveBeenCalledTimes(1);
|
||||
dep = { same: true };
|
||||
harness.render(Memo);
|
||||
dep = { same: true };
|
||||
harness.render(Memo);
|
||||
expect(compute).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("useMemo without dependencies recomputes at every render", () => {
|
||||
const compute = vi.fn(() => ({}));
|
||||
const Memo = () => R.useMemo(compute, undefined as unknown as []);
|
||||
harness.render(Memo);
|
||||
harness.render(Memo);
|
||||
expect(compute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("useCallback keeps the first function while the dependencies are equal", () => {
|
||||
let dep = "a";
|
||||
const Callback = () => R.useCallback(() => dep, [dep]);
|
||||
const first = harness.render(Callback);
|
||||
expect(harness.render(Callback)).toBe(first);
|
||||
dep = "b";
|
||||
const second = harness.render(Callback);
|
||||
expect(second).not.toBe(first);
|
||||
expect(second()).toBe("b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hookHarness — useEffect", () => {
|
||||
const log: string[] = [];
|
||||
let dep = 1;
|
||||
const Effects = () => {
|
||||
const d = dep;
|
||||
R.useEffect(() => {
|
||||
log.push(`run ${d}`);
|
||||
return () => {
|
||||
log.push(`cleanup ${d}`);
|
||||
};
|
||||
}, [d]);
|
||||
R.useEffect(() => {
|
||||
log.push("once");
|
||||
}, []);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
log.length = 0;
|
||||
dep = 1;
|
||||
});
|
||||
|
||||
it("captures the effects during the render, without running them", () => {
|
||||
harness.render(Effects);
|
||||
expect(log).toEqual([]);
|
||||
});
|
||||
|
||||
it("commit runs them all at the first commit, then only those whose dependencies changed", () => {
|
||||
harness.render(Effects);
|
||||
harness.commit();
|
||||
expect(log).toEqual(["run 1", "once"]);
|
||||
|
||||
harness.render(Effects);
|
||||
harness.commit();
|
||||
expect(log).toEqual(["run 1", "once"]);
|
||||
|
||||
dep = 2;
|
||||
harness.render(Effects);
|
||||
harness.commit();
|
||||
// The cleanup of the previous run, with its own closure, before the new run.
|
||||
expect(log).toEqual(["run 1", "once", "cleanup 1", "run 2"]);
|
||||
});
|
||||
|
||||
it("commits a render once", () => {
|
||||
const fn = vi.fn();
|
||||
const Always = () => R.useEffect(fn);
|
||||
harness.render(Always);
|
||||
harness.commit();
|
||||
harness.commit();
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
harness.render(Always);
|
||||
harness.commit();
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("runs every cleanup before any effect of the same commit", () => {
|
||||
const order: string[] = [];
|
||||
let n = 0;
|
||||
const Two = () => {
|
||||
const k = n;
|
||||
R.useEffect(() => {
|
||||
order.push(`a${k}`);
|
||||
return () => {
|
||||
order.push(`~a${k}`);
|
||||
};
|
||||
}, [k]);
|
||||
R.useEffect(() => {
|
||||
order.push(`b${k}`);
|
||||
return () => {
|
||||
order.push(`~b${k}`);
|
||||
};
|
||||
}, [k]);
|
||||
};
|
||||
harness.render(Two);
|
||||
harness.commit();
|
||||
n = 1;
|
||||
harness.render(Two);
|
||||
harness.commit();
|
||||
expect(order).toEqual(["a0", "b0", "~a0", "~b0", "a1", "b1"]);
|
||||
});
|
||||
|
||||
it("replayEffects plays StrictMode's double run: every cleanup, then every effect again", () => {
|
||||
harness.render(Effects);
|
||||
harness.commit();
|
||||
harness.replayEffects();
|
||||
expect(log).toEqual(["run 1", "once", "cleanup 1", "run 1", "once"]);
|
||||
});
|
||||
|
||||
it("unmount runs every cleanup, once", () => {
|
||||
harness.render(Effects);
|
||||
harness.commit();
|
||||
harness.unmount();
|
||||
harness.unmount();
|
||||
expect(log).toEqual(["run 1", "once", "cleanup 1"]);
|
||||
});
|
||||
|
||||
it("clear starts a fresh mount", () => {
|
||||
harness.render(Effects);
|
||||
harness.commit();
|
||||
harness.dispatch("x");
|
||||
harness.clear();
|
||||
expect(harness.slots).toEqual([]);
|
||||
expect(harness.dispatch).not.toHaveBeenCalled();
|
||||
harness.render(Effects);
|
||||
harness.commit();
|
||||
expect(log).toEqual(["run 1", "once", "run 1", "once"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hookHarness — hooks it does not simulate", () => {
|
||||
it.each(["useContext", "useLayoutEffect", "useSyncExternalStore", "useId", "useTransition", "use"])(
|
||||
"%s throws instead of reaching the real React",
|
||||
(name) => {
|
||||
const hook = (R as unknown as Record<string, () => unknown>)[name];
|
||||
expect(hook).toBeTypeOf("function");
|
||||
expect(() => hook()).toThrow(`hookHarness: ${name} is not simulated`);
|
||||
},
|
||||
);
|
||||
|
||||
it("on the default export too", () => {
|
||||
const fake = R as unknown as { default: Record<string, (...args: unknown[]) => unknown> };
|
||||
expect(() => fake.default.useContext()).toThrow("hookHarness: useContext is not simulated");
|
||||
expect(fake.default.useState).toBe((R as unknown as Record<string, unknown>).useState);
|
||||
});
|
||||
});
|
||||
265
src/test/hookHarness.ts
Normal file
265
src/test/hookHarness.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
// hookHarness — React's hooks with their semantics, for tests without jsdom (#409).
|
||||
//
|
||||
// The repository has no jsdom: tests call hooks and components as plain
|
||||
// functions, under `vi.mock("react")`. A fake hook that drops React's
|
||||
// semantics (an initializer called at every render, a memo recomputed every
|
||||
// time) leaves one-line mutations green. This module is the one harness those
|
||||
// tests share, so the semantics are written, and proved, once
|
||||
// (hookHarness.test.ts).
|
||||
//
|
||||
// USE — one pattern, at the head of the test module, before the imports of
|
||||
// the code under test (the factory of `vi.mock` is hoisted, hence the dynamic
|
||||
// import inside it):
|
||||
//
|
||||
// vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
|
||||
// import { hookHarness } from "../test/hookHarness";
|
||||
// const harness = hookHarness();
|
||||
//
|
||||
// then, per test: `harness.clear()` in `beforeEach` (a fresh mount),
|
||||
// `harness.render(() => Component(props))` for one render of the mounted
|
||||
// component, `harness.commit()` to run its effects. Every test module gets its
|
||||
// own copy of this module, so the singleton is shared by the mock and the test
|
||||
// of one file, never across files. Values the test changes between renders
|
||||
// (a pathname, a context value) live in a `vi.hoisted` object of the test.
|
||||
//
|
||||
// SEMANTICS
|
||||
// - Slots by call order, as React: `render()` resets the cursor, so a
|
||||
// component must call its hooks in the same order at every render. A test
|
||||
// may preset the slot of a `useState` or `useReducer` (its value) before the
|
||||
// first render.
|
||||
// - `useState`: initializer called once (at the first render), stable setter,
|
||||
// functional updates applied to the latest value. A setter does NOT
|
||||
// re-render: the test renders again when it wants to read the new state.
|
||||
// - `useReducer`: the reducer IS applied (the slot holds the new state for the
|
||||
// next render), its `dispatch` is stable, and every action is also recorded
|
||||
// by `harness.dispatch`, a `vi.fn()` spy shared by all the reducers.
|
||||
// - `useRef`: one object per slot, kept from render to render.
|
||||
// - `useMemo` / `useCallback`: recomputed only when a dependency changed
|
||||
// (`Object.is`, as React); without a dependency array, at every render.
|
||||
// - `useEffect`: captured during the render, never run by it. `commit()` runs
|
||||
// the effects of the last render whose dependencies changed since their last
|
||||
// run (all of them at the first commit, every time without an array): first
|
||||
// the cleanups of those effects, then the effects, in call order, as React.
|
||||
// A render is committed once. `replayEffects()` plays StrictMode's double
|
||||
// run at mount (every cleanup, then every effect again); `unmount()` runs
|
||||
// every cleanup.
|
||||
// - Any other hook of React (`useContext`, `useLayoutEffect`,
|
||||
// `useSyncExternalStore`, `useId`, `use`…) THROWS: it would otherwise reach
|
||||
// the real React outside a render ("Invalid hook call"), and a test would go
|
||||
// red by accident rather than on its assertion. Mock the module that calls
|
||||
// it, or cover the hook here with its own test.
|
||||
// - Everything else (`isValidElement`, `Fragment`, `createElement`…) is the
|
||||
// real React, on the named exports and on the default export.
|
||||
//
|
||||
// NOT SIMULATED: re-rendering (on a setter, a dispatch, a context change),
|
||||
// children (a component in the returned tree is an element, never called),
|
||||
// the DOM, concurrent features, the bail-out of a setter called with the same
|
||||
// value, the render-phase update loop React runs for a dispatch during render
|
||||
// (the next `render()` reads it).
|
||||
//
|
||||
// React is imported here only as a type: a value import, from inside the
|
||||
// factory that builds the mock of "react", would load that very mock.
|
||||
import type * as React from "react";
|
||||
import { vi, type Mock } from "vitest";
|
||||
|
||||
type Deps = readonly unknown[] | undefined;
|
||||
type EffectFn = () => void | (() => void);
|
||||
|
||||
const EFFECT = Symbol("hookHarness.effect");
|
||||
|
||||
interface EffectSlot {
|
||||
[EFFECT]: true;
|
||||
/** Dependencies at the last run, `null` before the first one. */
|
||||
ran: Deps | null;
|
||||
cleanup: (() => void) | undefined;
|
||||
}
|
||||
|
||||
interface PendingEffect {
|
||||
index: number;
|
||||
fn: EffectFn;
|
||||
deps: Deps;
|
||||
}
|
||||
|
||||
export interface HookHarness {
|
||||
/** The slots of the mounted component, by call order; a test may preset a state slot before the first render. */
|
||||
slots: unknown[];
|
||||
/** Every action dispatched to any `useReducer` of the component, in order. */
|
||||
dispatch: Mock<(action: unknown) => void>;
|
||||
/** One render of the mounted component: resets the cursor and the captured effects, then calls `fn`. */
|
||||
render<T>(fn: () => T): T;
|
||||
/** Starts a render by hand (cursor at 0, no captured effect), for a test that calls the component itself. */
|
||||
reset(): void;
|
||||
/** Runs the effects of the last render whose dependencies changed, cleanups first; once per render. */
|
||||
commit(): void;
|
||||
/** StrictMode at mount: every cleanup of the last render's effects, then every effect again. */
|
||||
replayEffects(): void;
|
||||
/** Runs every cleanup, as React does when the component goes away. */
|
||||
unmount(): void;
|
||||
/** A fresh mount: no slot, no effect, no recorded action. */
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
type Internal = HookHarness & {
|
||||
writers: Array<((arg: unknown) => void) | undefined>;
|
||||
next(): number;
|
||||
capture(effect: PendingEffect): void;
|
||||
};
|
||||
|
||||
function depsChanged(previous: Deps | null, next: Deps): boolean {
|
||||
if (previous === null || previous === undefined || next === undefined) return true;
|
||||
if (previous.length !== next.length) return true;
|
||||
return next.some((dep, k) => !Object.is(dep, previous[k]));
|
||||
}
|
||||
|
||||
const isEffectSlot = (slot: unknown): slot is EffectSlot =>
|
||||
typeof slot === "object" && slot !== null && EFFECT in slot;
|
||||
|
||||
/** A harness of its own, for the harness's tests; the mock uses the shared one (`hookHarness()`). */
|
||||
export function createHookHarness(): HookHarness {
|
||||
let cursor = 0;
|
||||
let pending: PendingEffect[] = [];
|
||||
/** A render is committed once, as React does. */
|
||||
let committed = false;
|
||||
|
||||
const run = (effects: PendingEffect[]) => {
|
||||
for (const { index } of effects) {
|
||||
const slot = harness.slots[index] as EffectSlot;
|
||||
const cleanup = slot.cleanup;
|
||||
slot.cleanup = undefined;
|
||||
cleanup?.();
|
||||
}
|
||||
for (const { index, fn, deps } of effects) {
|
||||
const slot = harness.slots[index] as EffectSlot;
|
||||
slot.ran = deps;
|
||||
const cleanup = fn();
|
||||
slot.cleanup = typeof cleanup === "function" ? cleanup : undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const harness: Internal = {
|
||||
slots: [],
|
||||
writers: [],
|
||||
dispatch: vi.fn(),
|
||||
render(fn) {
|
||||
harness.reset();
|
||||
return fn();
|
||||
},
|
||||
reset() {
|
||||
cursor = 0;
|
||||
pending = [];
|
||||
committed = false;
|
||||
},
|
||||
commit() {
|
||||
if (committed) return;
|
||||
committed = true;
|
||||
run(pending.filter(({ index, deps }) => depsChanged((harness.slots[index] as EffectSlot).ran, deps)));
|
||||
},
|
||||
replayEffects() {
|
||||
run(pending);
|
||||
},
|
||||
unmount() {
|
||||
for (const slot of harness.slots) {
|
||||
if (!isEffectSlot(slot)) continue;
|
||||
const cleanup = slot.cleanup;
|
||||
slot.cleanup = undefined;
|
||||
cleanup?.();
|
||||
}
|
||||
},
|
||||
clear() {
|
||||
harness.slots = [];
|
||||
harness.writers = [];
|
||||
harness.dispatch.mockReset();
|
||||
harness.reset();
|
||||
},
|
||||
next: () => cursor++,
|
||||
capture: (effect) => {
|
||||
pending.push(effect);
|
||||
},
|
||||
};
|
||||
return harness;
|
||||
}
|
||||
|
||||
const shared = createHookHarness();
|
||||
|
||||
/** The harness the mock of "react" writes to, shared with the test of the same module. */
|
||||
export function hookHarness(): HookHarness {
|
||||
return shared;
|
||||
}
|
||||
|
||||
const COVERED = new Set(["useState", "useReducer", "useRef", "useMemo", "useCallback", "useEffect"]);
|
||||
|
||||
/**
|
||||
* The module that replaces "react": the real one, with the covered hooks
|
||||
* backed by `harness` (the shared one by default) and every other hook
|
||||
* throwing.
|
||||
*/
|
||||
export function fakeReact(actual: typeof React, harness: HookHarness = shared): typeof React {
|
||||
const h = harness as Internal;
|
||||
|
||||
/** Index of this hook's slot, filled by `init` at the first render only. */
|
||||
const slot = (init: () => unknown): number => {
|
||||
const index = h.next();
|
||||
if (!(index in h.slots)) h.slots[index] = init();
|
||||
return index;
|
||||
};
|
||||
|
||||
const memo = (compute: () => unknown, deps: Deps): unknown => {
|
||||
const index = slot(() => ({ deps: null as Deps | null, value: undefined as unknown }));
|
||||
const entry = h.slots[index] as { deps: Deps | null; value: unknown };
|
||||
if (depsChanged(entry.deps, deps)) {
|
||||
entry.value = compute();
|
||||
entry.deps = deps;
|
||||
}
|
||||
return entry.value;
|
||||
};
|
||||
|
||||
/** Stable setter or dispatch of a state slot, created at its first use. */
|
||||
const writer = (index: number, make: () => (arg: unknown) => void) => (h.writers[index] ??= make());
|
||||
|
||||
const covered = {
|
||||
useState: (init: unknown) => {
|
||||
const index = slot(() => (typeof init === "function" ? (init as () => unknown)() : init));
|
||||
const set = writer(index, () => (next: unknown) => {
|
||||
h.slots[index] =
|
||||
typeof next === "function" ? (next as (previous: unknown) => unknown)(h.slots[index]) : next;
|
||||
});
|
||||
return [h.slots[index], set];
|
||||
},
|
||||
useReducer: (
|
||||
reducer: (state: unknown, action: unknown) => unknown,
|
||||
initialArg: unknown,
|
||||
init?: (arg: unknown) => unknown,
|
||||
) => {
|
||||
const index = slot(() => (init ? init(initialArg) : initialArg));
|
||||
const dispatch = writer(index, () => (action: unknown) => {
|
||||
h.dispatch(action);
|
||||
h.slots[index] = reducer(h.slots[index], action);
|
||||
});
|
||||
return [h.slots[index], dispatch];
|
||||
},
|
||||
useRef: (init: unknown) => h.slots[slot(() => ({ current: init }))],
|
||||
useMemo: (compute: () => unknown, deps: Deps) => memo(compute, deps),
|
||||
useCallback: (fn: unknown, deps: Deps) => memo(() => fn, deps),
|
||||
useEffect: (fn: EffectFn, deps?: Deps) => {
|
||||
const index = slot((): EffectSlot => ({ [EFFECT]: true, ran: null, cleanup: undefined }));
|
||||
h.capture({ index, fn, deps });
|
||||
},
|
||||
};
|
||||
|
||||
const uncovered: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(actual)) {
|
||||
if ((key === "use" || /^use[A-Z]/.test(key)) && !COVERED.has(key)) {
|
||||
uncovered[key] = () => {
|
||||
throw new Error(
|
||||
`hookHarness: ${key} is not simulated; mock the module that calls it, or cover it in src/test/hookHarness.ts`,
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const fake: Record<string, unknown> = { ...actual, ...uncovered, ...covered };
|
||||
// `import React from "react"` then `React.useState`: the default export too.
|
||||
const original = (actual as { default?: unknown }).default;
|
||||
if (original && typeof original === "object") fake.default = { ...original, ...uncovered, ...covered };
|
||||
return fake as unknown as typeof React;
|
||||
}
|
||||
Loading…
Reference in a new issue