Skip to content

@angular/build:unit-test (vitest runner) duplicates @angular/core/testing class definitions across bundles, causing NG0201 for TestComponentRenderer despite BrowserTestingModule providing it #33216

@m-tojo-safie

Description

@m-tojo-safie

Command

build

Is this a regression?

  • Yes, this behavior used to work in the previous version

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

ng test

(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:

  1. Builder externalizes @angular/core/testing (and ideally @angular/platform-browser/testing) by default so all bundles share the same module instance, OR
  2. providersFile is accepted as EnvironmentProviders so users can write export default importProvidersFrom(BrowserTestingModule) to thread the user-side identity back in, OR
  3. 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 tokensDestroyRef / _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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions