Command
build
Is this a regression?
The previous version in which this bug was not present was
i dont know
Description
@angular/build:unit-test (vitest runner) duplicates @angular/core/testing class definitions across bundles, causing NG0201 for TestComponentRenderer despite BrowserTestingModule providing it
Command
(target: @angular/build:unit-test with "runner": "vitest")
Description
@angular/build:unit-test with the vitest runner produces a virtual init-testbed.js that initializes TestBed with [BrowserTestingModule, TestModule]. BrowserTestingModule (@angular/platform-browser/testing) declares { provide: TestComponentRenderer, useClass: DOMTestComponentRenderer } in its ɵinj.providers, so inject(TestComponentRenderer) should succeed.
In practice, the inject fails with NG0201 even though the provider array contains the token. Runtime diagnostics show that the TestComponentRenderer class referenced inside BrowserTestingModule.ɵinj.providers[2].provide is a different class instance from the TestComponentRenderer that user code (and Angular's own TestBedImpl.tearDownTestingModule) imports from @angular/core/testing:
[DIAG] BrowserTestingModule.ɵinj.providers length: 3
[DIAG] provider[0]: APP_ID InjectionToken (useValue: 'a')
[DIAG] provider[1]: (ɵprovideFakePlatformNavigation result)
[DIAG] provider[2]: TestComponentRenderer useClass= DOMTestComponentRenderer
[DIAG] TestBed.inject(APP_ID): 'a' ← provider[0] works
[DIAG] TestBed.inject(TestComponentRenderer): null ← provider[2] does NOT work
[DIAG] token === TestComponentRenderer: false ← identity mismatch
[DIAG] token.name === TestComponentRenderer.name: true ('TestComponentRenderer')
APP_ID (defined in @angular/core) is fine because Vite normalizes @angular/core to a single pre-bundled module. TestComponentRenderer (defined in @angular/core/testing) breaks because the testing entry point is evaluated multiple times across separately-bundled chunks:
init-testbed.js (builder-generated virtual entry, imports BrowserTestingModule) → class A
vitest-mock-patch.js (builder-generated virtual entry)
test-setup.ts / *.spec.ts (user entries, import TestComponentRenderer directly) → class B
Angular's DI matches provide/inject by === on the class object, so a provider registered with class A cannot be retrieved via class B → NG0201.
The stack trace originates from purely Angular paths as well (not just ng-mocks):
ɵNotFound: NG0201: No provider found for `TestComponentRenderer`.
❯ TestBedImpl.inject (@angular/core/fesm2022/testing.mjs:1414)
❯ TestBedImpl.tearDownTestingModule(@angular/core/fesm2022/testing.mjs:1552)
❯ TestBedImpl.resetTestingModule (@angular/core/fesm2022/testing.mjs:1366)
❯ <getCleanupHook(true) callback> (@angular/core/fesm2022/testing.mjs:2469)
i.e. it fires from the globalThis.afterEach?.(getCleanupHook(true)) registered inside @angular/core/testing itself, confirming the auto-init TestBed truly cannot resolve TestComponentRenderer through the user-visible class identity.
Expected behavior
When the builder's init-testbed.js calls getTestBed().initTestEnvironment([BrowserTestingModule, ...], platformBrowserTesting(), ...), the TestComponentRenderer provider declared by BrowserTestingModule should be retrievable via the same TestComponentRenderer symbol that user code imports from @angular/core/testing. Either:
- Builder externalizes
@angular/core/testing (and ideally @angular/platform-browser/testing) by default so all bundles share the same module instance, OR
providersFile is accepted as EnvironmentProviders so users can write export default importProvidersFrom(BrowserTestingModule) to thread the user-side identity back in, OR
- Vite optimizer config emitted by the builder forces the testing entry points into a single shared pre-bundle without pulling in
@angular/core itself.
Happy to PR if a direction is preferred.
Minimal Reproduction
In any Angular 21 project with "runner": "vitest":
projects/<name>/src/test-setup.ts:
import { APP_ID } from '@angular/core';
import { getTestBed, TestComponentRenderer } from '@angular/core/testing';
import { BrowserTestingModule } from '@angular/platform-browser/testing';
const inj: any = (BrowserTestingModule as any).ɵinj;
const tokenInProviders = inj?.providers?.[2]?.provide;
console.log('[DIAG] token === TestComponentRenderer:', tokenInProviders === TestComponentRenderer); // → false
console.log('[DIAG] token.name:', tokenInProviders?.name, 'vs', TestComponentRenderer?.name); // both 'TestComponentRenderer'
beforeEach(() => {
const tb = getTestBed();
console.log('[DIAG] inject(APP_ID):', tb.inject(APP_ID, null)); // → 'a' (works)
console.log('[DIAG] inject(TestComponentRenderer):', tb.inject(TestComponentRenderer, null)); // → null (broken)
});
Any spec that calls TestBed.createComponent(...) or any test that relies on the default afterEach-driven tearDownTestingModule will then fail with NG0201.
Exception or Error
Your Environment
┌───────────────────────────────────┬───────────────────┬───────────────────┐
│ Package │ Installed Version │ Requested Version │
├───────────────────────────────────┼───────────────────┼───────────────────┤
│ @angular/build │ 21.2.6 │ 21.2.6 │
│ @angular/cli │ 21.2.6 │ 21.2.6 │
│ @angular/common │ 21.2.7 │ 21.2.7 │
│ @angular/compiler │ 21.2.7 │ 21.2.7 │
│ @angular/compiler-cli │ 21.2.7 │ 21.2.7 │
│ @angular/core │ 21.2.7 │ 21.2.7 │
│ @angular/elements │ 21.2.7 │ 21.2.7 │
│ @angular/forms │ 21.2.7 │ 21.2.7 │
│ @angular/language-service │ 21.2.7 │ 21.2.7 │
│ @angular/platform-browser │ 21.2.7 │ 21.2.7 │
│ @angular/platform-browser-dynamic │ 21.2.7 │ 21.2.7 │
│ @angular/router │ 21.2.7 │ 21.2.7 │
│ rxjs │ 7.8.1 │ 7.8.1 │
│ typescript │ 5.9.3 │ 5.9.3 │
│ vitest │ 4.1.4 │ ^4.1.4 │
└───────────────────────────────────┴───────────────────┴───────────────────┘
Anything else relevant?
Workarounds tried
| # |
Approach |
Result |
| 1 |
Add externalDependencies: ["@angular/core/testing"] to the build target referenced by buildTarget |
No change — identity still mismatched |
| 2 |
optimizeDeps.exclude: ['@angular/core/testing', '@angular/platform-browser/testing'] in vitest config |
No change |
| 3 |
optimizeDeps.include: ['@angular/core/testing', '@angular/platform-browser/testing'] |
NG0201 resolved (real DOMTestComponentRenderer works), but propagates identity duplication into @angular/core tokens — DestroyRef / _CustomHTTPClient start throwing NG0203 in test code that worked previously; BrowserTestingModule's APP_ID: 'a' override stops winning (inject(APP_ID) returns the Angular default 'ng' instead) |
| 4 |
optimizeDeps.include: ['@angular/platform-browser/testing'] only |
Same side effects as #3 (transitive) |
| 5 |
providersFile with [{ provide: TestComponentRenderer, useValue: stub }] (the class B token from user-land import) |
Works. This is what we shipped. The providersFile path threads the user-side class identity through, so inject(TestComponentRenderer) resolves. Downside: the stub must mirror DOMTestComponentRenderer's real behavior (e.g. insertRootElement must call removeAllRootElements first), and ng-mocks's default fixture detection via ngMocks.find(selector) stops working (had to add a helper that locates the root via document.querySelector('[id^="root"]') and getDebugNode) |
Command
build
Is this a regression?
The previous version in which this bug was not present was
i dont know
Description
@angular/build:unit-test(vitest runner) duplicates@angular/core/testingclass definitions across bundles, causing NG0201 forTestComponentRendererdespiteBrowserTestingModuleproviding itCommand
(target:
@angular/build:unit-testwith"runner": "vitest")Description
@angular/build:unit-testwith the vitest runner produces a virtualinit-testbed.jsthat initializes TestBed with[BrowserTestingModule, TestModule].BrowserTestingModule(@angular/platform-browser/testing) declares{ provide: TestComponentRenderer, useClass: DOMTestComponentRenderer }in itsɵinj.providers, soinject(TestComponentRenderer)should succeed.In practice, the inject fails with
NG0201even though the provider array contains the token. Runtime diagnostics show that theTestComponentRendererclass referenced insideBrowserTestingModule.ɵinj.providers[2].provideis a different class instance from theTestComponentRendererthat user code (and Angular's ownTestBedImpl.tearDownTestingModule) imports from@angular/core/testing:APP_ID(defined in@angular/core) is fine because Vite normalizes@angular/coreto a single pre-bundled module.TestComponentRenderer(defined in@angular/core/testing) breaks because the testing entry point is evaluated multiple times across separately-bundled chunks:init-testbed.js(builder-generated virtual entry, importsBrowserTestingModule) → class Avitest-mock-patch.js(builder-generated virtual entry)test-setup.ts/*.spec.ts(user entries, importTestComponentRendererdirectly) → class BAngular's DI matches provide/inject by
===on the class object, so a provider registered with class A cannot be retrieved via class B →NG0201.The stack trace originates from purely Angular paths as well (not just ng-mocks):
i.e. it fires from the
globalThis.afterEach?.(getCleanupHook(true))registered inside@angular/core/testingitself, confirming the auto-init TestBed truly cannot resolveTestComponentRendererthrough the user-visible class identity.Expected behavior
When the builder's
init-testbed.jscallsgetTestBed().initTestEnvironment([BrowserTestingModule, ...], platformBrowserTesting(), ...), theTestComponentRendererprovider declared byBrowserTestingModuleshould be retrievable via the sameTestComponentRenderersymbol that user code imports from@angular/core/testing. Either:@angular/core/testing(and ideally@angular/platform-browser/testing) by default so all bundles share the same module instance, ORprovidersFileis accepted asEnvironmentProvidersso users can writeexport default importProvidersFrom(BrowserTestingModule)to thread the user-side identity back in, OR@angular/coreitself.Happy to PR if a direction is preferred.
Minimal Reproduction
In any Angular 21 project with
"runner": "vitest":projects/<name>/src/test-setup.ts:Any spec that calls
TestBed.createComponent(...)or any test that relies on the defaultafterEach-driventearDownTestingModulewill then fail with NG0201.Exception or Error
Your Environment
Anything else relevant?
Workarounds tried
externalDependencies: ["@angular/core/testing"]to the build target referenced bybuildTargetoptimizeDeps.exclude: ['@angular/core/testing', '@angular/platform-browser/testing']in vitest configoptimizeDeps.include: ['@angular/core/testing', '@angular/platform-browser/testing']DOMTestComponentRendererworks), but propagates identity duplication into@angular/coretokens —DestroyRef/_CustomHTTPClientstart throwingNG0203in test code that worked previously;BrowserTestingModule'sAPP_ID: 'a'override stops winning (inject(APP_ID)returns the Angular default'ng'instead)optimizeDeps.include: ['@angular/platform-browser/testing']onlyprovidersFilewith[{ provide: TestComponentRenderer, useValue: stub }](the class B token from user-land import)providersFilepath threads the user-side class identity through, soinject(TestComponentRenderer)resolves. Downside: the stub must mirrorDOMTestComponentRenderer's real behavior (e.g.insertRootElementmust callremoveAllRootElementsfirst), andng-mocks's default fixture detection viangMocks.find(selector)stops working (had to add a helper that locates the root viadocument.querySelector('[id^="root"]')andgetDebugNode)