GeezerVison is showing github.com — V8 Coverage showing unexpected results · Issue #32836 · angular/angular-cli
V8 Coverage showing unexpected results #32836DescriptionWhich @angular/* package(s) are the source of the bug?
Don't known / other
Is this a regression?
No
Description
I'm in the process of migrating from jasmine/karma with istanbul coverage to vitest with v8 coverage. I'm currently migrating around 2500 unit tests. With Istanbul I had over 95% coverage across statements, branches, functions and lines. With vitest I'm seeing lower coverage, mainly on the branches with 90%.
I've noticed that even empty classes (template only) show 90% branch coverage (9/10).
A minimal reproduction:
- Create a new Angular 21 application:
ng new ng21
- Install v8 coverage
npm i --save-dev @vitest/coverage-v8
- Remove the protected title variable from app.ts
-- protected readonly title = signal('ng21');
- Remove the
should render title test from app.spec.ts
- Run
ng test --coverage
This is the coverage I see:

Similar from my project:

with the spec file as:
import { TestBed } from '@angular/core/testing';
import { HeaderComponent } from './header.component';
import { HeaderComponent as ClsHeaderComponent, TenantDropdownComponent } from '@cls/ui-util';
import { MockComponent } from '@cls/ui-util/testing';
import { SearchComponent } from '../../schedule/components/search/search.component';
describe('HeaderComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HeaderComponent],
providers: []
})
.overrideComponent(HeaderComponent, {
remove: { imports: [SearchComponent, ClsHeaderComponent, TenantDropdownComponent] },
add: {
imports: [
MockComponent({ selector: 'app-search' }) as any,
MockComponent({ selector: 'cls-header' }) as any,
MockComponent({ selector: 'cls-tenant-dropdown' }) as any
]
}
})
.compileComponents();
});
function setup() {
const fixture = TestBed.createComponent(HeaderComponent);
return {
fixture,
app: fixture.componentInstance,
compiled: fixture.debugElement.nativeElement
};
}
it('should create the app', () => {
const { app } = setup();
expect(app).toBeTruthy();
});
});
angular.json
"test": {
"builder": "@angular/build:unit-test",
"options": {
"browsers": ["chromium"],
"buildTarget": "admin-ui:build",
"headless": true,
"tsConfig": "./tsconfig.spec.json",
"runnerConfig": "./vitest.config.ts",
"coverage": true,
"coverageInclude": ["src/app/**/*.ts"],
"coverageExclude": ["index.ts", "main.ts", "polyfills.ts", "**/*.routes.ts", "**/*.model.ts", "**/*.interface.ts"],
"coverageReporters": ["text", "html", "lcov"],
"coverageThresholds": {
"statements": 90,
"branches": 90,
"functions": 90,
"lines": 90
}
}
}
vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['src/test-setup.ts'],
restoreMocks: true,
coverage: {
provider: 'v8'
},
browser: {
screenshotFailures: false
}
}
});
Have I misconfigured something? Do I have an incorrect assumption of v8 coverage or is there an issue here?
Please provide a link to a minimal reproduction of the bug
No response
Please provide the exception or error you saw
Please provide the environment you discovered this bug in (run ng version)
Angular CLI : 21.2.3
Angular : 21.2.5
Node.js : 22.13.1
Package Manager : npm 10.9.2
Operating System : darwin arm64
┌───────────────────────────────────┬───────────────────┬───────────────────┐
│ Package │ Installed Version │ Requested Version │
├───────────────────────────────────┼───────────────────┼───────────────────┤
│ @angular/animations │ 21.2.5 │ ~21.2.0 │
│ @angular/build │ 21.2.3 │ ^21.2.0 │
│ @angular/cdk │ 21.2.3 │ ~21.2.0 │
│ @angular/cli │ 21.2.3 │ ~21.2.0 │
│ @angular/common │ 21.2.5 │ ~21.2.0 │
│ @angular/compiler │ 21.2.5 │ ~21.2.0 │
│ @angular/compiler-cli │ 21.2.5 │ ~21.2.0 │
│ @angular/core │ 21.2.5 │ ~21.2.0 │
│ @angular/forms │ 21.2.5 │ ~21.2.0 │
│ @angular/language-service │ 21.2.5 │ ~21.2.0 │
│ @angular/platform-browser │ 21.2.5 │ ~21.2.0 │
│ @angular/platform-browser-dynamic │ 21.2.5 │ ~21.2.0 │
│ @angular/router │ 21.2.5 │ ~21.2.0 │
│ rxjs │ 7.8.2 │ ~7.8.2 │
│ typescript │ 5.9.3 │ ~5.9.3 │
│ vitest │ 4.1.0 │ ^4.0.18 │
│ zone.js │ 0.15.1 │ ~0.15.0 │
└───────────────────────────────────┴───────────────────┴──────────────────
Anything else?
No response Reacted by Hayley Price, rinckemd, Andrew Gnagy, Ron Olsen, Manoj Sridhar Srinivasan, Nate Radebaugh and Maartje Kanaar ActivitySkyZeroZx commented on Mar 24, 2026 daniemo2 commented on Mar 24, 2026 Thanks for the quick response @SkyZeroZx.
Apologies, I'm using 21.2.5 for my own project. The previous ng version output above was from the minimal reproduction. I've updated it.
No improvements with 21.2.5. thePunderWoman commented on Mar 25, 2026 This looks to be a CLI related issue. I'll move this issue over to that repo. Martinspire commented on Mar 30, 2026 I can confirm this on my repo as well. Its on a lot of files on the export class part of the file. But also on component providers.
However, I also noticed this on the istanbul coverage provider. Not the same amount (its less) but there are still some lines showing to be uncovered which are definitely covered. manojsridharsrinivasan commented on Apr 6, 2026 I have the same issue with our project as well. Tried with v8 and istanbul. Both have same issue. Using Angular 21.2.6 and vitest 4.1.1
 cexbrayat commented on Apr 10, 2026 Here is a simple but surprising workaround: add fixture.detectChanges() to the test, and branch coverage goes back to 100%.
Disclaimer: I’m the author of the earlier signal-related coverage issue in core angular/angular#64583, and I spent way too much time digging into this.
The reason is that this minimal repro only creates the component. With the current ng test + Vitest setup, tests run against AoT-compiled templates by default. In this case the template is compiled to something like:
template: function App_Template(rf, ctx) {
if (rf & 1) {
i0.ɵɵelement(0, "router-outlet");
}
}
You can inspect this with ng test --dump-virtual-files.
In the test as written, Angular only executes the template in creation mode, so V8 sees the if (rf & 1) branch taken only on its truthy side. The template is never executed in update mode, so the other side is left uncovered. Because this function only exists in generated output, the coverage is remapped back to the component class, which is why the class shows the extra branch and ends up at 9/10.
If the test triggers change detection, the template runs again in update mode. That makes the rf & 1 condition evaluate false on that pass, so V8 considers both sides covered and branch coverage becomes 10/10.
So I don’t think this is a CLI bug. At most, Angular could decide to special-case this in generated code, for example by excluding that branch from coverage with an istanbul comment as done in signals, but that would be a compiler/core decision rather than a CLI one. emilycarter6561-byte commented on Apr 11, 2026 Thanks for the detailed explanation, @cexbrayat. I can confirm adding
fixture.detectChanges() fixes the branch coverage in my project too. Makes
sense that it’s related to template execution in AoT rather than a CLI bug.
…
On Fri, Apr 10, 2026, 12:41 PM Cédric Exbrayat ***@***.***> wrote:
*cexbrayat* left a comment ( angular/angular-cli#32836)
< #32836 (comment)>
Here is a simple but surprising workaround: add fixture.detectChanges()
to the test, and branch coverage goes back to 100%.
Disclaimer: I’m the author of the earlier signal-related coverage issue in
core angular/angular#64583
< angular/angular#64583>, and I spent way too
much time digging into this.
The reason is that this minimal repro only creates the component. With the
current ng test + Vitest setup, tests run against AoT-compiled templates by
default. In this case the template is compiled to something like:
template: function App_Template(rf, ctx) {
if (rf & 1) {
i0.ɵɵelement(0, "router-outlet");
}}
You can inspect this with ng test --dump-virtual-files.
In the test as written, Angular only executes the template in creation
mode, so V8 sees the if (rf & 1) branch taken only on its truthy side.
The template is never executed in update mode, so the other side is left
uncovered. Because this function only exists in generated output, the
coverage is remapped back to the component class, which is why the class
shows the extra branch and ends up at 9/10.
If the test triggers change detection, the template runs again in update
mode. That makes the rf & 1 condition evaluate false on that pass, so V8
considers both sides covered and branch coverage becomes 10/10.
So I don’t think this is a CLI bug. At most, Angular could decide to
special-case this in generated code, for example by excluding that branch
from coverage with an istanbul comment as done in signals, but that would
be a compiler/core decision rather than a CLI one.
—
Reply to this email directly, view it on GitHub
< #32836 (comment)>,
or unsubscribe
< https://github.com/notifications/unsubscribe-auth/B7Z2X6AF47JU5ILZQFO6YN34VEW5FAVCNFSM6AAAAACW7POFBOVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHM2DEMRVGYZDOMZVG4>
.
You are receiving this because you are subscribed to this thread.Message
ID: ***@***.***>
daniemo2 commented on Apr 13, 2026 Thanks for the insight @cexbrayat!
In our unit tests we don't necessarily run tests against the template (just our standard, whether we should is another discussion) and so we rarely call detectChanges as it's unnecessary for us and causes us to have setup component state.
Perhaps, like you say, there's some solution where it's detected if a test runs in only create mode. But as I imagine the ignore comments are compiled then it's a all or nothing ignore. Reacted by Manoj Sridhar Srinivasan Martinspire commented on Apr 14, 2026 I also see this popping up on components where I absolutely do more testing, but it still shows up. cexbrayat commented on Apr 14, 2026 @Martinspire you'll need to share a minimal repro if you want someone to take a look (the bare minimum in your component and test to reproduce). I know a few other workarounds, and there are also issues in how the sourcemap are remapped to the source code that are coming from vitest/v8 so maybe you running into that. manojsridharsrinivasan commented on Apr 14, 2026 I tested this in my Angular CLI + Vitest setup, and the issue still exists when the component test does not use the real template.
fixture.detectChanges() does fix the remapped branch/function coverage when the actual component template is compiled and executed. But in my case I intentionally override the template in the spec, because I only want to unit test the TypeScript behavior and I do not want HTML/template assertions in these tests.
I also cannot reliably use the real template here because it pulls in Custom web components, and there are known jsdom limitations around that stack. So for this class of component tests, using the real template is not a practical workaround.
What I am still seeing is:
- real template +
fixture.detectChanges() => branch coverage goes to 100%
- blank/fake overridden template => line coverage is fine, but branch/function coverage is still remapped to the component class and stays below 100%
So from a user perspective, the issue is still effectively present for TypeScript-only component tests that intentionally avoid the real template. Reacted by Daniel Morrison, Hayley Price, Maartje Kanaar, rexsuecia and Olivier Braud
MetadataMetadataDevelopmentNo branches or pull requestsIssue actions
|