DEV Community

Cover image for How to use Angular 20 experimental Vitest support outside of ng test
Ítalo
Ítalo

Posted on • Edited on

How to use Angular 20 experimental Vitest support outside of ng test

I’m the kind of developer who likes to stay in the loop of the technologies they love, even though they may not be working with them right now. And I love Angular.

Announcing Angular v20. The past couple of years have been… | by Minko Gechev | May, 2025 | Angular Blog

The past couple of years have been transformative for Angular, as we’ve unleashed major advancements like reactivity with Signals and the…

favicon blog.angular.dev

Recently, the Angular team released its version 20, which comes with many improvements, largely discussed and explained in other sources, but there’s one improvement I haven’t found enough information about: its experimental support for Vitest.

There has been Vitest support for Angular for a while, thanks to Analog and its plugin, which I also tried when Angular 18 came out, and it works just fine. But Angular’s own support for Vitest is the new thing, so I had to learn how to use it. Luckily, Angular already has enough information in its docs to achieve this.

So, I had to test this new version in a personal project to learn how to use all these new things along with other technologies and tools I’ve used for a long time and also love, like VSCode, ESLint, Prettier, and a bunch of other. And I did, but there’s a catch which makes the whole “experimental” thing have more sense: you can only use this through ng test.

This is fine, but I’m also the kind of developer who likes to use every tool to improve my DX. So, I needed to make this work at least with Vitest’s own command npx vitest and with the Vitest official VSCode extension.

To achieve this, I had to deal with many errors and follow a bunch of steps to reverse-engineer Angular’s code, but I’ll save you the time and just show you the results.


I’ll assume you already have a working Angular 20 project with its Vitest support configured and running, and the starter App component with its corresponding spec file.

src/test-setup.ts

import { NgModule } from '@angular/core';
import {
  ɵgetCleanupHook as getCleanupHook,
  getTestBed
} from '@angular/core/testing';
import {
  BrowserTestingModule,
  platformBrowserTesting
} from '@angular/platform-browser/testing';
import { afterEach, beforeEach } from 'vitest';

const providers: NgModule['providers'] = [];

beforeEach(getCleanupHook(false));
afterEach(getCleanupHook(true));

@NgModule({ providers })
export class TestModule {}

getTestBed().initTestEnvironment(
  [BrowserTestingModule, TestModule],
  platformBrowserTesting(),
  {
    errorOnUnknownElements: true,
    errorOnUnknownProperties: true
  }
);
Enter fullscreen mode Exit fullscreen mode

vitest.config.ts

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    root: './',
    globals: true,
    setupFiles: ['src/test-setup.ts'],
    environment: 'jsdom',
    watch: false,
    reporters: ['default'],
    coverage: {
      enabled: false,
      excludeAfterRemap: true
    }
  },
  plugins: [
    {
      name: 'angular-coverage-exclude',
      configureVitest(context) {
        context.project.config.coverage.exclude = ['**/*.{test,spec}.?(c|m)ts'];
      }
    }
  ]
});
Enter fullscreen mode Exit fullscreen mode

src/app/app.spec.ts

import { provideZonelessChangeDetection } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';

import { App } from './app';

describe('App', () => {
  beforeAll(async () => {
    try {
      if (typeof process !== 'undefined' && process.versions?.node) {
        const { readFileSync } = await import('node:fs');
        const { ɵresolveComponentResources: resolveComponentResources } =
          await import('@angular/core');

        await resolveComponentResources(url =>
          Promise.resolve(readFileSync(new URL(url, import.meta.url), 'utf-8'))
        );
      }
    } catch {
      return;
    }
  });

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [App],
      providers: [provideZonelessChangeDetection()]
    }).compileComponents();
  });

  it('should create the app', () => {
    const fixture = TestBed.createComponent(App);
    const app = fixture.componentInstance;
    expect(app).toBeTruthy();
  });

  it('should render title', () => {
    const fixture = TestBed.createComponent(App);
    fixture.detectChanges();
    const compiled = fixture.nativeElement as HTMLElement;
    expect(compiled.querySelector('h1')?.textContent).toContain('Hello, ng20');
  });
});
Enter fullscreen mode Exit fullscreen mode

While this approach works outside of ng test as desired (as of version 20.0.0), it’s a method that comes with its complications:

  • It’s highly dependable in an experimental feature, so, along with the expected changes it will experience, this approach may easily stop working.
  • It uses Angular internal variables, which are also subject to change unexpectedly.
  • As you might have noticed, it has been tested in a very limited scenario, so there’s a really high possibility there will be edge cases where this fails.

I provide this code only for the fun of making it work with the tools I mentioned, and maybe as a way to open a discussion on finding better ways to do the same with less risks.

Any constructive criticism is welcome. Thanks for reading!

Top comments (0)