Skip to content

Commit c83fb87

Browse files
committed
feat(@angular/build): support runtime Zone.js detection in Vitest unit test runner
This commit improves how the Vitest unit test runner handles Zone.js and its testing polyfills. Previously, the inclusion of provideZoneChangeDetection and zone.js/testing was determined solely by a build-time check for zone.js in the polyfills array. Now, the runner supports three strategies for zone.js/testing inclusion: - none: If zone.js is not installed in the project. - static: If zone.js is explicitly included in the polyfills build option. - dynamic: If zone.js is installed but not explicitly in polyfills. This uses a runtime check and dynamic import to load testing support if Zone is present. Additionally, TestBed initialization now dynamically provides ZoneChangeDetection based on the runtime presence of Zone.js, better supporting zoneless applications and implicit Zone.js loading scenarios.
1 parent cad7a7c commit c83fb87

File tree

2 files changed

+109
-7
lines changed

2 files changed

+109
-7
lines changed

packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,20 @@
77
*/
88

99
import path from 'node:path';
10+
import { createRequire } from 'node:module';
1011
import { toPosixPath } from '../../../../utils/path';
1112
import type { ApplicationBuilderInternalOptions } from '../../../application/options';
1213
import { OutputHashing } from '../../../application/schema';
13-
import { NormalizedUnitTestBuilderOptions, injectTestingPolyfills } from '../../options';
14+
import { NormalizedUnitTestBuilderOptions } from '../../options';
1415
import { findTests, getTestEntrypoints } from '../../test-discovery';
1516
import { RunnerOptions } from '../api';
1617

1718
function createTestBedInitVirtualFile(
1819
providersFile: string | undefined,
1920
projectSourceRoot: string,
2021
teardown: boolean,
21-
polyfills: string[] = [],
22+
zoneTestingStrategy: 'none' | 'static' | 'dynamic',
2223
): string {
23-
const usesZoneJS = polyfills.includes('zone.js');
2424
let providersImport = 'const providers = [];';
2525
if (providersFile) {
2626
const relativePath = path.relative(projectSourceRoot, providersFile);
@@ -31,12 +31,25 @@ function createTestBedInitVirtualFile(
3131

3232
return `
3333
// Initialize the Angular testing environment
34-
import { NgModule${usesZoneJS ? ', provideZoneChangeDetection' : ''} } from '@angular/core';
34+
import { NgModule, provideZoneChangeDetection } from '@angular/core';
3535
import { getTestBed, ɵgetCleanupHook as getCleanupHook } from '@angular/core/testing';
3636
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
3737
import { afterEach, beforeEach } from 'vitest';
3838
${providersImport}
3939
40+
${
41+
zoneTestingStrategy === 'static'
42+
? `import 'zone.js/testing';`
43+
: zoneTestingStrategy === 'dynamic'
44+
? `
45+
if (typeof Zone !== 'undefined') {
46+
// 'zone.js/testing' is used to initialize the ZoneJS testing environment.
47+
// It must be imported dynamically to avoid a static dependency on 'zone.js'.
48+
await import('zone.js/testing');
49+
}`
50+
: ''
51+
}
52+
4053
// The beforeEach and afterEach hooks are registered outside the globalThis guard.
4154
// This ensures that the hooks are always applied, even in non-isolated browser environments.
4255
// Same as https://github.com/angular/angular/blob/05a03d3f975771bb59c7eefd37c01fa127ee2229/packages/core/testing/srcs/test_hooks.ts#L21-L29
@@ -52,7 +65,10 @@ function createTestBedInitVirtualFile(
5265
// The guard condition above ensures that the setup is only performed once.
5366
5467
@NgModule({
55-
providers: [${usesZoneJS ? 'provideZoneChangeDetection(), ' : ''}...providers],
68+
providers: [
69+
...(typeof Zone !== 'undefined' ? [provideZoneChangeDetection()] : []),
70+
...providers,
71+
],
5672
})
5773
class TestModule {}
5874
@@ -145,13 +161,28 @@ export async function getVitestBuildOptions(
145161
externalDependencies,
146162
};
147163

148-
buildOptions.polyfills = injectTestingPolyfills(buildOptions.polyfills);
164+
// Inject the zone.js testing polyfill if Zone.js is installed.
165+
let zoneTestingStrategy: 'none' | 'static' | 'dynamic' = 'none';
166+
let isZoneJsInstalled = false;
167+
try {
168+
const projectRequire = createRequire(path.join(projectSourceRoot, 'package.json'));
169+
projectRequire.resolve('zone.js');
170+
isZoneJsInstalled = true;
171+
} catch {}
172+
173+
if (isZoneJsInstalled) {
174+
if (buildOptions.polyfills?.includes('zone.js')) {
175+
zoneTestingStrategy = 'static';
176+
} else {
177+
zoneTestingStrategy = 'dynamic';
178+
}
179+
}
149180

150181
const testBedInitContents = createTestBedInitVirtualFile(
151182
providersFile,
152183
projectSourceRoot,
153184
!options.debug,
154-
buildOptions.polyfills,
185+
zoneTestingStrategy,
155186
);
156187

157188
const mockPatchContents = `
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { execute } from '../../index';
2+
import {
3+
BASE_OPTIONS,
4+
describeBuilder,
5+
UNIT_TEST_BUILDER_INFO,
6+
setupApplicationTarget,
7+
} from '../setup';
8+
9+
describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => {
10+
describe('Behavior: "Vitest Zone initialization"', () => {
11+
// Zone.js does not current provide fakAsync support for Vitest
12+
xit('should load Zone and Zone testing support by default', async () => {
13+
setupApplicationTarget(harness); // Defaults include zone.js
14+
15+
harness.useTarget('test', {
16+
...BASE_OPTIONS,
17+
});
18+
19+
harness.writeFile(
20+
'src/app/app.component.spec.ts',
21+
`
22+
import { describe, it, expect } from 'vitest';
23+
import { fakeAsync, tick } from '@angular/core/testing';
24+
25+
describe('Zone Test', () => {
26+
it('should have Zone defined', () => {
27+
expect((globalThis as any).Zone).toBeDefined();
28+
});
29+
30+
it('should support fakeAsync', fakeAsync(() => {
31+
let val = false;
32+
setTimeout(() => { val = true; }, 100);
33+
tick(100);
34+
expect(val).toBeTrue();
35+
}));
36+
});
37+
`,
38+
);
39+
40+
const { result } = await harness.executeOnce();
41+
expect(result?.success).toBeTrue();
42+
});
43+
44+
it('should NOT load Zone when zoneless (no zone.js in polyfills)', async () => {
45+
// Setup application target WITHOUT zone.js in polyfills
46+
setupApplicationTarget(harness, {
47+
polyfills: [],
48+
});
49+
50+
harness.useTarget('test', {
51+
...BASE_OPTIONS,
52+
});
53+
54+
harness.writeFile(
55+
'src/app/app.component.spec.ts',
56+
`
57+
import { describe, it, expect } from 'vitest';
58+
59+
describe('Zoneless Test', () => {
60+
it('should NOT have Zone defined', () => {
61+
expect((globalThis as any).Zone).toBeUndefined();
62+
});
63+
});
64+
`,
65+
);
66+
67+
const { result } = await harness.executeOnce();
68+
expect(result?.success).toBe(true);
69+
});
70+
});
71+
});

0 commit comments

Comments
 (0)