Skip to content

Commit d07894c

Browse files
committed
fix(@angular/build): warn when TestBed.overrideComponent is used in AOT mode
TestBed.overrideComponent is a JIT-first API that relies on runtime decorator metadata to dynamically recompile components. When tests are compiled in AOT mode (which is the default for vitest and some karma configurations), decorator metadata is compiled away, meaning template or imports overrides can fail silently or cause confusing NG0304/NG0303 errors. To improve developer experience, this adds a runtime warning when TestBed.overrideComponent is called on an AOT-compiled component in AOT mode. A warning is printed to the console suggesting to disable AOT (aot: false) in the test build configuration as a workaround.
1 parent 035c72a commit d07894c

3 files changed

Lines changed: 170 additions & 1 deletion

File tree

packages/angular/build/src/builders/karma/application_builder.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import type { Config, ConfigOptions, FilePattern, InlinePluginDef, Server } from
1111
import { randomUUID } from 'node:crypto';
1212
import { rmSync } from 'node:fs';
1313
import * as fs from 'node:fs/promises';
14-
import { createRequire } from 'node:module';
1514
import path from 'node:path';
1615
import { ReadableStream } from 'node:stream/web';
1716
import { createVirtualModulePlugin } from '../../tools/esbuild/virtual-module-plugin';
@@ -225,6 +224,26 @@ async function runEsbuild(
225224
`});`,
226225
];
227226

227+
if (buildOptions.aot !== false) {
228+
contents.push(
229+
`const originalOverrideComponent = getTestBed().overrideComponent;`,
230+
`const warnTracker = new Set();`,
231+
`getTestBed().overrideComponent = function (component, override) {`,
232+
` const isAotComponent = !!component.ɵcmp;`,
233+
` if (isAotComponent && !warnTracker.has(component)) {`,
234+
` warnTracker.add(component);`,
235+
` console.warn(`,
236+
` "[Angular] WARNING: 'TestBed.overrideComponent' was called on '" + component.name + "' in AOT mode. " +`,
237+
` "Overriding template or imports on AOT-compiled components is not fully supported and may cause " +`,
238+
` "NG0304/NG0303 element resolution errors. \\n" +`,
239+
` "👉 Workaround: Set 'aot: false' in the build configuration used for tests (e.g. 'buildTarget' in angular.json)."`,
240+
` );`,
241+
` }`,
242+
` return originalOverrideComponent.call(this, component, override);`,
243+
`};`,
244+
);
245+
}
246+
228247
return {
229248
contents: contents.join('\n'),
230249
loader: 'js',

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ function createTestBedInitVirtualFile(
3535
teardown: boolean,
3636
zoneTestingStrategy: 'none' | 'static' | 'dynamic' | 'dynamic-zone',
3737
hasLocalize: boolean,
38+
isAot: boolean,
3839
): string {
3940
let providersImport = 'const providers = [];';
4041
if (providersFile) {
@@ -127,6 +128,28 @@ function createTestBedInitVirtualFile(
127128
errorOnUnknownProperties: true,
128129
${teardown === false ? 'teardown: { destroyAfterEach: false },' : ''}
129130
});
131+
132+
${
133+
isAot
134+
? `
135+
const originalOverrideComponent = getTestBed().overrideComponent;
136+
const warnTracker = new Set();
137+
getTestBed().overrideComponent = function (component, override) {
138+
const isAotComponent = !!component.ɵcmp;
139+
if (isAotComponent && !warnTracker.has(component)) {
140+
warnTracker.add(component);
141+
console.warn(
142+
\`[Angular] WARNING: 'TestBed.overrideComponent' was called on '\${component.name}' in AOT mode. \` +
143+
\`Overriding template or imports on AOT-compiled components is not fully supported and may cause \` +
144+
\`NG0304/NG0303 element resolution errors. \\n\` +
145+
\`👉 Workaround: Set 'aot: false' in the build configuration used for tests (e.g. 'buildTarget' in angular.json).\`
146+
);
147+
}
148+
return originalOverrideComponent.call(this, component, override);
149+
};
150+
`
151+
: ''
152+
}
130153
}
131154
`;
132155
}
@@ -279,6 +302,7 @@ export async function getVitestBuildOptions(
279302
!options.debug,
280303
zoneTestingStrategy,
281304
hasLocalize,
305+
buildOptions.aot !== false,
282306
);
283307

284308
const mockPatchContents = `
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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: "TestBed.overrideComponent warning in AOT"', () => {
11+
it('should warn when overrideComponent is called in AOT mode', async () => {
12+
setupApplicationTarget(harness, {
13+
aot: true,
14+
});
15+
16+
harness.useTarget('test', {
17+
...BASE_OPTIONS,
18+
});
19+
20+
harness.writeFile(
21+
'src/app/aot-warning.spec.ts',
22+
`
23+
import { Component } from '@angular/core';
24+
import { TestBed } from '@angular/core/testing';
25+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
26+
27+
@Component({
28+
selector: 'test-comp',
29+
template: '',
30+
})
31+
class TestComponent {}
32+
33+
describe('Override Warning', () => {
34+
let warnSpy: any;
35+
36+
beforeEach(() => {
37+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
38+
});
39+
40+
afterEach(() => {
41+
warnSpy.mockRestore();
42+
});
43+
44+
it('should log warning', () => {
45+
TestBed.configureTestingModule({
46+
imports: [TestComponent],
47+
}).overrideComponent(TestComponent, {});
48+
49+
expect(warnSpy).toHaveBeenCalled();
50+
expect(warnSpy.mock.calls[0][0]).toContain('WARNING: \\'TestBed.overrideComponent\\' was called');
51+
});
52+
});
53+
`,
54+
);
55+
56+
// Overwrite default to avoid noise
57+
harness.writeFile(
58+
'src/app/app.component.spec.ts',
59+
`
60+
import { describe, it, expect } from 'vitest';
61+
describe('Ignored', () => { it('pass', () => expect(true).toBe(true)); });
62+
`,
63+
);
64+
65+
const { result } = await harness.executeOnce();
66+
expect(result?.success).toBeTrue();
67+
});
68+
69+
it('should NOT warn when overrideComponent is called in JIT mode', async () => {
70+
setupApplicationTarget(harness, {
71+
aot: false,
72+
});
73+
74+
harness.useTarget('test', {
75+
...BASE_OPTIONS,
76+
});
77+
78+
harness.writeFile(
79+
'src/app/jit-no-warning.spec.ts',
80+
`
81+
import { Component } from '@angular/core';
82+
import { TestBed } from '@angular/core/testing';
83+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
84+
85+
@Component({
86+
selector: 'test-comp',
87+
template: '',
88+
})
89+
class TestComponent {}
90+
91+
describe('JIT No Warning', () => {
92+
let warnSpy: any;
93+
94+
beforeEach(() => {
95+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
96+
});
97+
98+
afterEach(() => {
99+
warnSpy.mockRestore();
100+
});
101+
102+
it('should not log warning', () => {
103+
TestBed.configureTestingModule({
104+
imports: [TestComponent],
105+
}).overrideComponent(TestComponent, {});
106+
107+
expect(warnSpy).not.toHaveBeenCalled();
108+
});
109+
});
110+
`,
111+
);
112+
113+
// Overwrite default to avoid noise
114+
harness.writeFile(
115+
'src/app/app.component.spec.ts',
116+
`
117+
import { describe, it, expect } from 'vitest';
118+
describe('Ignored', () => { it('pass', () => expect(true).toBe(true)); });
119+
`,
120+
);
121+
122+
const { result } = await harness.executeOnce();
123+
expect(result?.success).toBeTrue();
124+
});
125+
});
126+
});

0 commit comments

Comments
 (0)