Skip to content

Commit ad8ba5f

Browse files
committed
fix(testing): top-level hook outside describe no longer inflates step count
A top-level `beforeAll`/`afterAll`/`beforeEach`/`afterEach` call creates a synthetic "global" `TestSuite` to host the hook. Previously that suite was also eagerly registered as a top-level `Deno.test`, so any nested `describe` would be reported as a child step of `global` — inflating the step count by one and changing the test output. Now the synthetic suite is only registered with `Deno.test` if a top-level `it()` is actually added to it. Otherwise, each child `describe` is promoted to its own `Deno.test` and inherits the global hooks at run time (so `beforeAll`/`afterAll` wrap the nested tests, and the `active` stack picks up the global `beforeEach`/`afterEach`). Fixes #7056
1 parent 6510612 commit ad8ba5f

3 files changed

Lines changed: 274 additions & 51 deletions

File tree

testing/_test_suite.ts

Lines changed: 117 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,27 @@ export class TestSuiteInternal<T> implements TestSuite<T> {
6969
protected describe: DescribeDefinition<T>;
7070
protected steps: (TestSuiteInternal<T> | ItDefinition<T>)[];
7171
protected hasOnlyStep: boolean;
72+
/**
73+
* Whether this is the synthetic "global" suite created when a top-level
74+
* `beforeAll`/`afterAll`/`beforeEach`/`afterEach` is called outside any
75+
* `describe`. Synthetic suites are only registered with `Deno.test` if a
76+
* top-level `it()` is also added; otherwise their hooks are inherited by
77+
* child describes promoted to top-level `Deno.test`s.
78+
*/
79+
protected isSynthetic: boolean;
80+
/**
81+
* For a child of a synthetic global suite, this points back to the synthetic
82+
* suite so its hooks can be invoked around the child's tests at run time.
83+
*/
84+
protected syntheticParent: TestSuiteInternal<T> | null;
7285
#registeredOptions: Deno.TestDefinition | undefined;
7386

74-
constructor(describe: DescribeDefinition<T>) {
87+
constructor(describe: DescribeDefinition<T>, isSynthetic = false) {
7588
this.describe = describe;
7689
this.steps = [];
7790
this.hasOnlyStep = false;
91+
this.isSynthetic = isSynthetic;
92+
this.syntheticParent = null;
7893

7994
const { suite } = describe;
8095
if (suite && !TestSuiteInternal.suites.has(suite.symbol)) {
@@ -138,41 +153,82 @@ export class TestSuiteInternal<T> implements TestSuite<T> {
138153
}
139154
}
140155

141-
if (testSuite) {
156+
if (testSuite && testSuite.isSynthetic) {
157+
// Promote: a child describe of the synthetic global is registered as its
158+
// own top-level Deno.test rather than as a step of the global suite. The
159+
// child inherits the global's hooks at run time via syntheticParent.
160+
this.syntheticParent = testSuite;
161+
this.registerAsDenoTest();
162+
} else if (testSuite) {
142163
TestSuiteInternal.addStep(testSuite, this);
143-
} else {
144-
const {
145-
name,
146-
ignore,
147-
permissions,
148-
sanitizeExit = globalSanitizersState.sanitizeExit,
149-
sanitizeOps = globalSanitizersState.sanitizeOps,
150-
sanitizeResources = globalSanitizersState.sanitizeResources,
151-
} = describe;
152-
let { only } = describe;
153-
if (!ignore && this.hasOnlyStep) {
154-
only = true;
155-
}
156-
const options: Deno.TestDefinition = {
157-
name,
158-
fn: async (t) => {
159-
TestSuiteInternal.runningCount++;
160-
try {
161-
const context = {} as T;
162-
const { beforeAll } = this.describe;
164+
} else if (!this.isSynthetic) {
165+
this.registerAsDenoTest();
166+
}
167+
// Synthetic suites without a parent are not registered eagerly. They are
168+
// registered lazily by `addStep` when a top-level `it()` is added.
169+
}
170+
171+
/** Builds the Deno.test options for this suite and registers them. */
172+
protected registerAsDenoTest() {
173+
if (this.#registeredOptions) return;
174+
const {
175+
name,
176+
ignore,
177+
permissions,
178+
sanitizeExit = globalSanitizersState.sanitizeExit,
179+
sanitizeOps = globalSanitizersState.sanitizeOps,
180+
sanitizeResources = globalSanitizersState.sanitizeResources,
181+
} = this.describe;
182+
let { only } = this.describe;
183+
if (!ignore && this.hasOnlyStep) {
184+
only = true;
185+
}
186+
const options: Deno.TestDefinition = {
187+
name,
188+
fn: async (t) => {
189+
TestSuiteInternal.runningCount++;
190+
try {
191+
const context = {} as T;
192+
const parent = this.syntheticParent;
193+
if (parent) {
194+
const { beforeAll } = parent.describe;
163195
if (typeof beforeAll === "function") {
164196
await beforeAll.call(context);
165197
} else if (beforeAll) {
166198
for (const hook of beforeAll) {
167199
await hook.call(context);
168200
}
169201
}
170-
try {
171-
TestSuiteInternal.active.push(this.symbol);
172-
await TestSuiteInternal.run(this, context, t);
173-
} finally {
202+
}
203+
const { beforeAll } = this.describe;
204+
if (typeof beforeAll === "function") {
205+
await beforeAll.call(context);
206+
} else if (beforeAll) {
207+
for (const hook of beforeAll) {
208+
await hook.call(context);
209+
}
210+
}
211+
try {
212+
if (parent) {
213+
TestSuiteInternal.active.push(parent.symbol);
214+
}
215+
TestSuiteInternal.active.push(this.symbol);
216+
await TestSuiteInternal.run(this, context, t);
217+
} finally {
218+
TestSuiteInternal.active.pop();
219+
if (parent) {
174220
TestSuiteInternal.active.pop();
175-
const { afterAll } = this.describe;
221+
}
222+
const { afterAll } = this.describe;
223+
if (typeof afterAll === "function") {
224+
await afterAll.call(context);
225+
} else if (afterAll) {
226+
for (const hook of afterAll) {
227+
await hook.call(context);
228+
}
229+
}
230+
if (parent) {
231+
const { afterAll } = parent.describe;
176232
if (typeof afterAll === "function") {
177233
await afterAll.call(context);
178234
} else if (afterAll) {
@@ -181,31 +237,31 @@ export class TestSuiteInternal<T> implements TestSuite<T> {
181237
}
182238
}
183239
}
184-
} finally {
185-
TestSuiteInternal.runningCount--;
186240
}
187-
},
188-
};
189-
if (ignore !== undefined) {
190-
options.ignore = ignore;
191-
}
192-
if (only !== undefined) {
193-
options.only = only;
194-
}
195-
if (permissions !== undefined) {
196-
options.permissions = permissions;
197-
}
198-
if (sanitizeExit !== undefined) {
199-
options.sanitizeExit = sanitizeExit;
200-
}
201-
if (sanitizeOps !== undefined) {
202-
options.sanitizeOps = sanitizeOps;
203-
}
204-
if (sanitizeResources !== undefined) {
205-
options.sanitizeResources = sanitizeResources;
206-
}
207-
this.#registeredOptions = TestSuiteInternal.registerTest(options);
241+
} finally {
242+
TestSuiteInternal.runningCount--;
243+
}
244+
},
245+
};
246+
if (ignore !== undefined) {
247+
options.ignore = ignore;
248+
}
249+
if (only !== undefined) {
250+
options.only = only;
208251
}
252+
if (permissions !== undefined) {
253+
options.permissions = permissions;
254+
}
255+
if (sanitizeExit !== undefined) {
256+
options.sanitizeExit = sanitizeExit;
257+
}
258+
if (sanitizeOps !== undefined) {
259+
options.sanitizeOps = sanitizeOps;
260+
}
261+
if (sanitizeResources !== undefined) {
262+
options.sanitizeResources = sanitizeResources;
263+
}
264+
this.#registeredOptions = TestSuiteInternal.registerTest(options);
209265
}
210266

211267
/** Stores how many test suites are executing. */
@@ -289,6 +345,17 @@ export class TestSuiteInternal<T> implements TestSuite<T> {
289345
suite: TestSuiteInternal<T>,
290346
step: TestSuiteInternal<T> | ItDefinition<T>,
291347
) {
348+
// When adding a top-level `it()` to the synthetic global suite, the global
349+
// needs to become a real `Deno.test` so the test has a place to run.
350+
// Child `describe`s are promoted at construction time and never reach
351+
// `addStep` with the synthetic suite as their parent.
352+
if (
353+
suite.isSynthetic && !suite.#registeredOptions &&
354+
!(step instanceof TestSuiteInternal)
355+
) {
356+
suite.registerAsDenoTest();
357+
}
358+
292359
if (!suite.hasOnlyStep) {
293360
if (step instanceof TestSuiteInternal) {
294361
if (step.hasOnlyStep || step.describe.only) {

testing/bdd.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -834,7 +834,7 @@ function addHook<T>(
834834
TestSuiteInternal.current = new TestSuiteInternal({
835835
name: "global",
836836
[name]: fn,
837-
});
837+
}, true);
838838
} else {
839839
TestSuiteInternal.setHook(TestSuiteInternal.current!, name, fn);
840840
}

testing/bdd_test.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,162 @@ Deno.test("beforeAll(), afterAll(), beforeEach() and afterEach()", async () => {
123123
assertSpyCalls(afterEachFn, 2);
124124
});
125125

126+
Deno.test(
127+
"top-level beforeAll() with only nested describe() does not add an extra step",
128+
async () => {
129+
using test = stub(Deno, "test");
130+
const fns = [spy(), spy()] as const;
131+
const { beforeAllFn, afterAllFn, beforeEachFn, afterEachFn } = hookFns();
132+
133+
const context = new TestContext("the describe");
134+
try {
135+
beforeAll(beforeAllFn);
136+
afterAll(afterAllFn);
137+
beforeEach(beforeEachFn);
138+
afterEach(afterEachFn);
139+
140+
describe("the describe", () => {
141+
it({ name: "test 1", fn: fns[0] });
142+
it({ name: "test 2", fn: fns[1] });
143+
});
144+
145+
// Only one Deno.test should be registered, named after the user's
146+
// describe — not a synthetic "global" wrapper. Without the fix this
147+
// is called twice (once for "global", once as a step), inflating the
148+
// step count reported by `deno test`.
149+
assertSpyCalls(test, 1);
150+
const options = test.calls[0]?.args[0] as Deno.TestDefinition;
151+
assertEquals(Object.keys(options).sort(), ["fn", "name"]);
152+
assertEquals(options.name, "the describe");
153+
154+
const result = options.fn(context);
155+
assertStrictEquals(Promise.resolve(result), result);
156+
assertEquals(await result, undefined);
157+
// Only the two user tests should appear as steps; no extra wrapping step.
158+
assertSpyCalls(context.spies.step, 2);
159+
} finally {
160+
TestSuiteInternal.reset();
161+
}
162+
163+
assertSpyCalls(fns[0], 1);
164+
assertSpyCalls(fns[1], 1);
165+
166+
// Top-level hooks still run around the nested tests.
167+
assertSpyCalls(beforeAllFn, 1);
168+
assertSpyCalls(afterAllFn, 1);
169+
assertSpyCalls(beforeEachFn, 2);
170+
assertSpyCalls(afterEachFn, 2);
171+
},
172+
);
173+
174+
Deno.test(
175+
"top-level beforeAll() with only nested describe() still wires up hooks when describe has its own hooks",
176+
async () => {
177+
using test = stub(Deno, "test");
178+
const fns = [spy(), spy()] as const;
179+
const globalBeforeAll = spy();
180+
const globalAfterAll = spy();
181+
const globalBeforeEach = spy();
182+
const globalAfterEach = spy();
183+
const localBeforeAll = spy();
184+
const localAfterAll = spy();
185+
const localBeforeEach = spy();
186+
const localAfterEach = spy();
187+
188+
const order: string[] = [];
189+
const trackOrder = (label: string) => () => {
190+
order.push(label);
191+
};
192+
193+
const context = new TestContext("d");
194+
try {
195+
beforeAll(spy(trackOrder("globalBeforeAll")));
196+
beforeAll(globalBeforeAll);
197+
afterAll(globalAfterAll);
198+
afterAll(spy(trackOrder("globalAfterAll")));
199+
beforeEach(spy(trackOrder("globalBeforeEach")));
200+
beforeEach(globalBeforeEach);
201+
afterEach(globalAfterEach);
202+
afterEach(spy(trackOrder("globalAfterEach")));
203+
204+
describe("d", () => {
205+
beforeAll(spy(trackOrder("localBeforeAll")));
206+
beforeAll(localBeforeAll);
207+
afterAll(localAfterAll);
208+
afterAll(spy(trackOrder("localAfterAll")));
209+
beforeEach(spy(trackOrder("localBeforeEach")));
210+
beforeEach(localBeforeEach);
211+
afterEach(localAfterEach);
212+
afterEach(spy(trackOrder("localAfterEach")));
213+
214+
it({ name: "t1", fn: fns[0] });
215+
it({ name: "t2", fn: fns[1] });
216+
});
217+
218+
assertSpyCalls(test, 1);
219+
const options = test.calls[0]?.args[0] as Deno.TestDefinition;
220+
assertEquals(options.name, "d");
221+
222+
await options.fn(context);
223+
} finally {
224+
TestSuiteInternal.reset();
225+
}
226+
227+
// Global hooks wrap local hooks around the tests.
228+
assertEquals(order, [
229+
"globalBeforeAll",
230+
"localBeforeAll",
231+
"globalBeforeEach",
232+
"localBeforeEach",
233+
"localAfterEach",
234+
"globalAfterEach",
235+
"globalBeforeEach",
236+
"localBeforeEach",
237+
"localAfterEach",
238+
"globalAfterEach",
239+
"localAfterAll",
240+
"globalAfterAll",
241+
]);
242+
243+
assertSpyCalls(globalBeforeAll, 1);
244+
assertSpyCalls(globalAfterAll, 1);
245+
assertSpyCalls(globalBeforeEach, 2);
246+
assertSpyCalls(globalAfterEach, 2);
247+
assertSpyCalls(localBeforeAll, 1);
248+
assertSpyCalls(localAfterAll, 1);
249+
assertSpyCalls(localBeforeEach, 2);
250+
assertSpyCalls(localAfterEach, 2);
251+
assertSpyCalls(fns[0], 1);
252+
assertSpyCalls(fns[1], 1);
253+
},
254+
);
255+
256+
Deno.test(
257+
"top-level beforeAll() with multiple nested describes registers each describe as its own Deno.test",
258+
() => {
259+
using test = stub(Deno, "test");
260+
const beforeAllFn = spy();
261+
try {
262+
beforeAll(beforeAllFn);
263+
264+
describe("d1", () => {
265+
it({ name: "t1", fn: () => {} });
266+
});
267+
describe("d2", () => {
268+
it({ name: "t2", fn: () => {} });
269+
});
270+
271+
assertSpyCalls(test, 2);
272+
const first = test.calls[0]?.args[0] as Deno.TestDefinition;
273+
const second = test.calls[1]?.args[0] as Deno.TestDefinition;
274+
assertEquals(first.name, "d1");
275+
assertEquals(second.name, "d2");
276+
} finally {
277+
TestSuiteInternal.reset();
278+
}
279+
},
280+
);
281+
126282
Deno.test("beforeAll() with it.only() propagates only to Deno.test", () => {
127283
using test = stub(Deno, "test");
128284
try {

0 commit comments

Comments
 (0)