vitest-browser-angular

August 11, 2026 · View on GitHub

Note: This repository is a fork of the official vitest-browser-angular library. The implementations contained here are developed independently and are not shared with the official project.

This community package renders Angular components in Vitest Browser Mode.

import { Component, input } from '@angular/core';
import { expect, test } from 'vitest';
import { render } from 'vitest-browser-angular';

@Component({
  selector: 'app-hello-world',
  template: '<h1>Hello, {{ name() }}!</h1>',
})
export class HelloWorld {
  name = input.required<string>();
}

test('renders name', async () => {
  const { locator } = await render(HelloWorld, {
    inputs: {
      name: 'World',
    },
  });

  await expect.element(locator).toHaveTextContent('Hello, World!');
});

Setup

There are currently two ways to set up Vitest for Angular:

While Angular CLI's unit-test builder is the official way to set up Vitest for Angular, it has some limitations. Analog's vitest-angular plugin provides more Vitest features and greater flexibility.

Setup with Analog Plugin

  1. Set up Vitest
npm add -D @analogjs/platform vitest-browser-angular

ng g @analogjs/platform:setup-vitest
  1. Activate browser mode in the generated Vitest configuration by following the browser mode configuration instructions.

Setup with Angular CLI

  1. Configure your Angular project to use the @angular/build:unit-test builder, and add the browsers of your choice.
{
  ...,
  "projects": {
    "my-app": {
      ...,
      "architect": {
        "test": {
          "builder": "@angular/build:unit-test",
          "options": {
            "browsers": ["Chromium", "Firefox", "Webkit"]
          }
        }
      }
    }
  }
}

Since Angular v21, Vitest is the default runner so you don't need to set the runner option.

  1. Install the browser provider of your choice using ng add
# With Playwright
ng add @vitest/browser-playwright

# or with WebdriverIO
ng add @vitest/browser-webdriverio
  1. Add the vitest-browser-angular package to your project.
npm add -D vitest-browser-angular
  1. (Optional) By default the browser UI is emptied after every test, so you can't see what a component rendered. To keep the last component mounted and visible after the test, enable the builder's debug option in angular.json:
{
  ...,
  "architect": {
    "test": {
      "builder": "@angular/build:unit-test",
      "options": {
        "browsers": ["Chromium"],
        "debug": true
      }
    }
  }
}

debug maps to teardown: { destroyAfterEach: false } in the test environment generated by the CLI, which stops Angular from destroying the component at the end of the test. It can also be passed per run with ng test --debug.

Zone.js VS Zoneless Setup

Angular CLI will automatically set up the test environment for you depending on the presence of zone.js in your project's polyfills.

When using the Analog plugin, you can control the behavior using the zoneless option of setupTestBed() in test-setup.ts:

import { setupTestBed } from '@analogjs/vitest-angular/setup-testbed';

setupTestBed({
  zoneless: true,
});

For detailed setup instructions for both Zone.js and Zoneless configurations, please refer to the Analog Vitest documentation.

Component Preview

To preview, debug and interact with a component in the browser after the test, you can prevent Angular from destroying it.

In Angular CLI, enable this using the --debug option (it sets teardown: { destroyAfterEach: false } in the CLI-generated test environment).

With the Analog plugin, enable this using the teardown.destroyAfterEach option of setupTestBed() in test-setup.ts:

import { setupTestBed } from '@analogjs/vitest-angular/setup-testbed';

setupTestBed({
  teardown: { destroyAfterEach: false },
});

Usage

Basic Example

The render function supports two query patterns:

import { test, expect } from 'vitest';
import { render } from 'vitest-browser-angular';

@Component({
  template: ` <h1>Welcome</h1> `,
})
export class MyComponent {}

test('query elements', async () => {
  // Pattern 1: Use locator to query within the component element
  const { locator } = await render(MyComponent);
  await expect.element(locator.getByText('Welcome')).toBeVisible();

  // Pattern 2: Use screen to query from document.body (useful for portals/overlays)
  const screen = await render(MyComponent);
  await expect.element(screen.getByText('Welcome')).toBeVisible();
  await expect.element(screen.getByText('Some Popover Content')).toBeVisible();
});

Query Methods

Both locator and screen provide the following query methods:

  • getByRole - Locate by ARIA role and accessible name
  • getByText - Locate by text content
  • getByLabelText - Locate by associated label text
  • getByPlaceholder - Locate by placeholder text
  • getByAltText - Locate by alt text (images)
  • getByTitle - Locate by title attribute
  • getByTestId - Locate by data-testid attribute

When to use which pattern:

  • locator: (full name: "Component Locator") - queries are scoped to the component's host element. Best for most component tests.
  • screen: Queries start from baseElement (defaults to document.body). Use when testing components that render content outside their host element (modals, tooltips, portals).

Container Element

Access the component's host element directly via container (shortcut for fixture.nativeElement):

const { container, locator } = await render(MyComponent);
expect(container).toBe(locator.element());

Base Element

Customize the root element for screen queries (useful for portal/overlay testing):

const customContainer = document.querySelector('#modal-root');
const screen = await render(ModalComponent, {
  baseElement: customContainer,
});
// screen queries now start from customContainer instead of document.body

Inputs

Pass input values to components using the inputs option:

import { Component, input } from '@angular/core';

@Component({
  template: '<h2>{{ name() }}</h2><p>Price: ${{ price() }}</p>',
  standalone: true,
})
export class ProductComponent {
  name = input('Unknown Product');
  price = input(0);
}

test('render with inputs', async () => {
  const screen = await render(ProductComponent, {
    inputs: {
      name: 'Laptop',
      price: 1299.99,
    },
  });

  await expect.element(screen.getByText('Laptop')).toBeVisible();
  await expect.element(screen.getByText(/Price: \$1299\.99/)).toBeVisible();
});

render() is built around Angular's modern signal-based APIs. Signal inputs (input()) bound as a WritableSignal stay reactive — updating the signal propagates to the component once change detection runs. Passing a model() input as a signal enables automatic two-way binding: values written inside the component are written back to your signal, mirroring Angular's [(x)] semantics.

Plain @Input() decorator inputs are still supported for setting values, but the reactive-binding and write-back behaviors above only apply to signal inputs.

Rerender

Update component inputs after rendering using rerender:

import { Component, input } from '@angular/core';
import { signal } from '@angular/core';
import { test, expect } from 'vitest';
import { render } from 'vitest-browser-angular';

@Component({
  template: '<h2>{{ name() }}</h2><p>Price: ${{ price() }}</p>',
})
export class ProductComponent {
  name = input('Unknown Product');
  price = input(0);
}

test('rerender with new inputs', async () => {
  const { locator, rerender } = await render(ProductComponent, {
    inputs: { name: 'Laptop', price: 1299.99 },
  });

  await expect.element(locator.getByText('Laptop')).toBeVisible();
  await expect.element(locator.getByText(/Price: \$1299\.99/)).toBeVisible();

  // Partial update — only the specified inputs change
  await rerender({ price: 999.99 });
  await expect.element(locator.getByText(/Price: \$999\.99/)).toBeVisible();
  await expect.element(locator.getByText('Laptop')).toBeVisible();
});

You can also pass WritableSignal values to keep the binding reactive:

test('rerender with signals', async () => {
  const { locator, rerender } = await render(ProductComponent, {
    inputs: { name: 'Laptop', price: 1299.99 },
  });

  const price$ = signal(799.99);
  await rerender({ price: price$ });

  await expect.element(locator.getByText(/Price: \$799\.99/)).toBeVisible();

  // Update the signal — the component updates once change detection runs
  price$.set(649.99);
  await expect.element(locator.getByText(/Price: \$649\.99/)).toBeVisible();
});

rerender is not available when using withRouting.

Model Inputs (Two-way Binding)

model() inputs are supported with automatic two-way binding. Pass a WritableSignal (e.g. signal(...)) as the value — render() binds the signal's current value to the component and writes any value the component emits back into the same signal, mirroring Angular's [(count)] template syntax.

import { Component, model } from '@angular/core';
import { signal } from '@angular/core';
import { test, expect } from 'vitest';
import { render } from 'vitest-browser-angular';

@Component({
  selector: 'app-counter',
  template: `
    <p data-testid="count">Count: {{ count() }}</p>
    <button (click)="count.update(v => v + 1)">Increment</button>
  `,
})
export class CounterComponent {
  count = model(0);
}

test('two-way model binding', async () => {
  const count = signal(0);
  const { locator } = await render(CounterComponent, {
    inputs: { count },
  });

  await expect.element(locator.getByTestId('count')).toHaveTextContent('Count: 0');

  // The component updates the model — the value is written back to `count`
  await locator.getByRole('button', { name: 'Increment' }).click();
  expect(count()).toBe(1);
  await expect.element(locator.getByTestId('count')).toHaveTextContent('Count: 1');
});

You can also pass a plain value to set the model's initial state; in that case there is no source signal to write back to.

Outputs

Subscribe to component outputs using the outputs option:

import { Component, output } from '@angular/core';
import { vi } from 'vitest';

@Component({
  template: '<button (click)="send.emit()">Send</button>',
  standalone: true,
})
export class MessageComponent {
  send = output<void>();
}

test('render with outputs', async () => {
  const sendHandler = vi.fn();
  const { locator } = await render(MessageComponent, {
    outputs: {
      send: sendHandler,
    },
  });

  await locator.getByRole('button', { name: 'Send' }).click();
  expect(sendHandler).toHaveBeenCalled();
});

Handlers receive the emitted value, so you can assert on the payload:

@Component({
  template: '<button (click)="save.emit({ id: 1 })">Save</button>',
  standalone: true,
})
export class SaveComponent {
  save = output<{ id: number }>();
}

test('assert on output payload', async () => {
  const saveHandler = vi.fn();
  const { locator } = await render(SaveComponent, {
    outputs: {
      save: saveHandler,
    },
  });

  await locator.getByRole('button', { name: 'Save' }).click();
  expect(saveHandler).toHaveBeenCalledWith({ id: 1 });
});

Works with signal-based outputs (output()).

When using withRouting, outputs cannot be passed directly to render().

Routing

Simple Routing

Enable routing with withRouting: true for components that use routing features but don't require specific route configuration:

import { test, expect } from 'vitest';
import { render } from 'vitest-browser-angular';
import { Component } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';

@Component({
  template: `
    <nav>
      <a routerLink="/home">Home</a>
      <a routerLink="/about">About</a>
    </nav>
    <router-outlet></router-outlet>
  `,
  imports: [RouterLink, RouterOutlet],
})
export class RoutedComponent {}

test('render with simple routing', async () => {
  const screen = await render(RoutedComponent, {
    withRouting: true,
  });

  await expect.element(screen.getByText('Home')).toBeVisible();
  await expect.element(screen.getByText('About')).toBeVisible();
});

Routing with Configuration

Configure specific routes and optionally set an initial route:

import { test, expect } from 'vitest';
import { render } from 'vitest-browser-angular';
import { Component, inject } from '@angular/core';
import { Router, RouterLink, RouterOutlet, Routes } from '@angular/router';

@Component({
  template: '<h1>Home Page</h1>',
})
export class HomeComponent {}

@Component({
  template: '<h1>About Page</h1>',
  standalone: true,
})
export class AboutComponent {}

@Component({
  template: `
    <nav>
      <a routerLink="/home">Home</a>
      <a routerLink="/about">About</a>
    </nav>
    <router-outlet></router-outlet>
  `,
  imports: [RouterLink, RouterOutlet],
  standalone: true,
})
export class AppComponent {
  router = inject(Router);
}

const routes: Routes = [
  { path: 'home', component: HomeComponent },
  { path: 'about', component: AboutComponent },
  { path: '', redirectTo: '/home', pathMatch: 'full' },
];

test('render with route configuration', async () => {
  const { locator, routerHarness, router } = await render(AppComponent, {
    withRouting: {
      routes,
      initialRoute: '/home',
    },
  });

  await expect.element(locator).toHaveTextContent('Home Page');

  // Navigate programmatically (prefer routerHarness over router)
  await routerHarness.navigateByUrl('/about');
  await expect.element(locator).toHaveTextContent('About Page');

  // Use router to inspect state
  expect(router.url).toBe('/about');
});

Route Params

When rendering a routed component, componentClassInstance provides access to the actual component instance with full routing context:

import { Component, inject } from '@angular/core';
import { ActivatedRoute, Routes } from '@angular/router';

@Component({
  template: '<h1>User: {{ userId }}</h1>',
})
export class UserComponent {
  private route = inject(ActivatedRoute);
  userId = this.route.snapshot.params['id'];
}

test('access route params', async () => {
  const routes: Routes = [{ path: 'user/:id', component: UserComponent }];

  const { componentClassInstance } = await render(UserComponent, {
    withRouting: {
      routes,
      initialRoute: '/user/42',
    },
  });

  expect(componentClassInstance.userId).toBe('42');
});

Passing Inputs via Route Data

By default, withComponentInputBinding() is enabled, which automatically binds route data, route params, and query params to matching component inputs. This works with both signal inputs (input()) and @Input() decorators:

import { Component, input } from '@angular/core';
import { Routes } from '@angular/router';

@Component({
  template: `
    <h2>{{ name() }}</h2>
    <p>Age: {{ age() }}</p>
    <p>Role: {{ role() }}</p>
  `,
})
export class ProfileComponent {
  name = input('Guest');
  age = input(0);
  role = input('user');
}

test('pass inputs via route data', async () => {
  const routes: Routes = [
    {
      path: 'profile',
      component: ProfileComponent,
      data: {
        name: 'Jane Doe',
        age: 30,
        role: 'admin',
      },
    },
  ];

  const { locator, componentClassInstance } = await render(ProfileComponent, {
    withRouting: {
      routes,
      initialRoute: '/profile',
    },
  });

  // Inputs are automatically bound from route data
  expect(componentClassInstance.name()).toBe('Jane Doe');
  expect(componentClassInstance.age()).toBe(30);
  expect(componentClassInstance.role()).toBe('admin');

  await expect.element(locator.getByText('Jane Doe')).toBeVisible();
});

Disabling Input Binding

If you need to manually handle route data via ActivatedRoute instead of automatic input binding, use disableInputBinding:

test('disable automatic input binding', async () => {
  const routes: Routes = [
    {
      path: 'profile',
      component: ProfileComponent,
      data: { name: 'Jane Doe' },
    },
  ];

  const { componentClassInstance } = await render(ProfileComponent, {
    withRouting: {
      routes,
      initialRoute: '/profile',
      disableInputBinding: true, // Inputs will NOT be bound from route data
    },
  });

  // Inputs retain their default values
  expect(componentClassInstance.name()).toBe('Guest');
});

HTTP Testing

Enable Angular's HttpClient testing support with the withHttp option. When enabled, the render result exposes an httpTesting instance (HttpTestingController) you can use to assert on outgoing requests and flush mocked responses — there's no need to manually wire up provideHttpClient/provideHttpClientTesting.

Basic HTTP Testing

Enable HTTP testing with withHttp: true:

import { Component, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { test, expect } from 'vitest';
import { render } from 'vitest-browser-angular';

@Component({
  template: `
    <h1 data-testid="title">{{ data()?.title ?? '' }}</h1>
    <button data-testid="fetch" (click)="load()">Fetch</button>
  `,
})
export class HttpDemoComponent {
  private http = inject(HttpClient);
  data = signal<{ title: string } | null>(null);

  load() {
    this.http.get<{ title: string }>('/api/data').subscribe(res => this.data.set(res));
  }
}

test('mocks an HTTP response', async () => {
  const { locator, httpTesting } = await render(HttpDemoComponent, {
    withHttp: true,
  });

  await locator.getByTestId('fetch').click();

  const req = httpTesting.expectOne('/api/data');
  expect(req.request.method).toBe('GET');
  req.flush({ title: 'Hello HTTP' });

  await expect.element(locator.getByTestId('title')).toHaveTextContent('Hello HTTP');
});

HTTP with Interceptors

Pass an HttpConfig with custom interceptors to register them via Angular's withInterceptors:

import { HttpInterceptorFn } from '@angular/common/http';

const authInterceptor: HttpInterceptorFn = (req, next) =>
  next(req.clone({ setHeaders: { 'X-Custom': 'test-value' } }));

test('applies custom interceptors', async () => {
  const { locator, httpTesting } = await render(HttpDemoComponent, {
    withHttp: { interceptors: [authInterceptor] },
  });

  await locator.getByTestId('fetch').click();

  const req = httpTesting.expectOne('/api/data');
  expect(req.request.headers.get('X-Custom')).toBe('test-value');
  req.flush({ title: 'Intercepted' });

  await expect.element(locator.getByTestId('title')).toHaveTextContent('Intercepted');
});

When withHttp is omitted, httpTesting is undefined and HttpClient is not configured.

Defer Blocks

Angular's @defer blocks defer part of the template until a trigger fires (idle, interaction, viewport, timer, ...). By default, render() keeps defer blocks paused (deferBlockBehavior: DeferBlockBehavior.Manual), so deferred content is not rendered and triggers don't fire on their own. This gives you deterministic control over when each block transitions.

<div>
  @defer {
    <p>Deferred content</p>
  } @placeholder {
    <p>Placeholder</p>
  } @loading {
    <p>Loading...</p>
  }
</div>

Render a defer block

Use renderDeferBlock(state, index?) on the render result to transition one (or all) defer blocks. The available states come from DeferBlockState (imported from @angular/core/testing): Placeholder, Loading, Complete, and Error.

Render all blocks in a given state:

import { DeferBlockState } from '@angular/core/testing';
import { test, expect } from 'vitest';
import { render } from 'vitest-browser-angular';

test('renders deferred content', async () => {
  const { locator, renderDeferBlock } = await render(DeferDemoComponent);

  await expect.element(locator.getByTestId('placeholder')).toBeVisible();

  await renderDeferBlock(DeferBlockState.Complete);

  await expect.element(locator.getByTestId('deferred')).toBeVisible();
});

Target a single block by its index (matching the order of fixture.getDeferBlocks()):

test('renders only the first defer block', async () => {
  const { locator, renderDeferBlock } = await render(DeferDemoComponent);

  await renderDeferBlock(DeferBlockState.Loading, 0);

  await expect.element(locator.getByTestId('loading')).toBeVisible();
});

Passing an index with no matching block throws an error.

Set initial states at render time

The deferBlockStates option applies a state (or several) right after render. Pass a single DeferBlockState to apply to every block:

const { locator } = await render(DeferDemoComponent, {
  deferBlockStates: DeferBlockState.Complete,
});

await expect.element(locator.getByTestId('deferred')).toBeVisible();

Or an array of { deferBlockState, deferBlockIndex } to target specific blocks:

const { locator } = await render(DeferDemoComponent, {
  deferBlockStates: [{ deferBlockState: DeferBlockState.Loading, deferBlockIndex: 0 }],
});

await expect.element(locator.getByTestId('loading')).toBeVisible();

Playthrough mode

Set deferBlockBehavior: DeferBlockBehavior.Playthrough to let defer triggers fire naturally, like in a real browser:

import { Component } from '@angular/core';
import { DeferBlockBehavior } from '@angular/core/testing';
import { test, expect } from 'vitest';
import { render } from 'vitest-browser-angular';

@Component({
  template: `
    <div>
      @defer (on interaction) {
        <p data-testid="deferred">Interaction content</p>
      } @placeholder {
        <button data-testid="show">Show</button>
      }
    </div>
  `,
})
export class DeferInteractionComponent {}

test('plays through interaction triggers', async () => {
  const { locator } = await render(DeferInteractionComponent, {
    deferBlockBehavior: DeferBlockBehavior.Playthrough,
  });

  await locator.getByTestId('show').click();
  await expect
    .element(locator.getByTestId('deferred'))
    .toHaveTextContent('Interaction content');
});

renderDeferBlock and deferBlockStates work in both modes. renderDeferBlock is available on every render result: render(), routed render() (with withRouting), and renderDirective().

Component Providers

If you need to replace a component provider declared on the component itself (e.g. to mock a service), use the overrideProvidersComponent option.

replace must match the exact provider shape declared by the component: the same object shape (same provide/useClass/useValue/useFactory keys) or the bare class when the component uses the shorthand providers: [Foo]. with is a full provider that typically provides the same token as replace.

@Component({
  template: '<h1 data-testid="greeting">{{ greeting }}</h1>',
  providers: [GreetingService],
})
export class HelloWorldComponent {
  private greetingService = inject(GreetingService);
  greeting = this.greetingService.getGreeting('World');
}

test('replaces component service provider with a mock', async () => {
  const screen = await render(HelloWorldComponent, {
    overrideProvidersComponent: [
      { replace: GreetingService, with: { provide: GreetingService, useClass: FakeGreetingService } },
    ],
  });

  await expect.element(screen.getByTestId('greeting')).toHaveTextContent('Fake Greeting');
});

For overriding a providedIn: 'root' service (provided at root/test level, where the last provider wins), use the providers option instead.

Directives

Use renderDirective to test both attribute and structural directives. It wraps the directive in a generated host component and renders the provided template, so you can drive the directive with real DOM events and assert against the host element.

import { Directive, input, output } from '@angular/core';
import { test, expect } from 'vitest';
import { renderDirective } from 'vitest-browser-angular';

@Directive({
  selector: '[appHighlight]',
  host: { '[style.color]': 'color()' },
})
export class HighlightDirective {
  color = input('black');
  blurred = output<FocusEvent>();
}

test('renders directive', async () => {
  const { directiveInstance, locator } = await renderDirective(HighlightDirective, {
    template: `<button appHighlight>Test</button>`,
  });

  expect(directiveInstance.color()).toBe('black');
  await expect.element(locator.getByText('Test')).toBeVisible();
});

The template must include the directive selector otherwise an error will be thrown.

Host Props

Pass reactive values and handlers to the template through hostProps. Each property is assigned onto the host component instance, so you can reference it directly in the template binding:

import { signal } from '@angular/core';

test('binds host inputs and outputs', async () => {
  const color = signal('red');
  const onBlur = vi.fn();

  const { getByText } = await renderDirective(HighlightDirective, {
    template: `<button appHighlight [color]="color()" (blurred)="onBlur($event)">Test</button>`,
    hostProps: { color, onBlur },
  });

  expect(getByText('Test')).toHaveStyle({ color: 'rgb(255, 0, 0)' });

  color.set('blue');
  await expect.element(getByText('Test')).toHaveStyle({ color: 'rgb(0, 0, 255)' });
});

Signals passed via hostProps keep the binding reactive — updating them propagates to the directive once change detection runs.

Imports and Providers

Pass additional modules (pipes, directives, components used in the template) via imports, and register DI providers via providers:

import { JsonPipe } from '@angular/common';

const { getByText } = await renderDirective(HighlightDirective, {
  template: `<button appHighlight>{{ color() | json }}</button>`,
  imports: [JsonPipe],
  providers: [{ provide: SomeService, useValue: fakeService }],
});

Structural directives

Structural directives work out of the box: renderDirective finds the directive on its <ng-template> anchor even though it never appears as a real element in the DOM.

import { Directive, effect, input, TemplateRef, ViewContainerRef } from '@angular/core';

@Directive({ selector: '[appUnless]' })
export class UnlessDirective {
  readonly unless = input(false, { alias: 'appUnless' });
  private templateRef = inject(TemplateRef);
  private vcr = inject(ViewContainerRef);
  constructor() {
    effect(() => {
      this.vcr.clear();
      if (!this.unless()) this.vcr.createEmbeddedView(this.templateRef);
    });
  }
}

test('renders a structural directive', async () => {
  const show = signal(false);
  const { directiveInstance, container, hostFixture } = await renderDirective(UnlessDirective, {
    template: `<div *appUnless="show()">Hidden content</div>`,
    hostProps: { show },
  });

  expect(container.textContent).toContain('Hidden content');

  show.set(true);
  await hostFixture.whenStable();
  expect(container.textContent).not.toContain('Hidden content');
});

Render options

renderDirective forwards the same render options as render (except routing, inputs, outputs and inferTagName):

  • overrideImportsDirective / overrideProvidersDirective — override the tested directive's imports/providers metadata to mock its dependencies.

Result

The render result mirrors render and adds directive-specific helpers:

  • directiveInstance — the instance of the tested directive, resolved from the host element's injector.
  • locator — Vitest browser locator scoped to the host component's container.
  • fixture — the host component's ComponentFixture.
  • container / baseElement — the rendered elements.
  • debug — pretty-print the DOM for debugging.
  • inject — resolve dependencies from the directive's injector (also works for providers declared on the directive itself).
  • httpTesting — the HttpTestingController when withHttp is enabled.
  • getByRole, getByText, … — the standard LocatorSelectors scoped to baseElement.

Dependency Injection

The render result provides an inject method to resolve dependencies from the rendered component's injector. Unlike TestBed.inject(), which only resolves from the root injector, result.inject() starts at the component injector and falls back up the hierarchy — so it works with both global providers and component-level providers.

import { Component, inject, Injectable } from '@angular/core';
import { test, expect } from 'vitest';
import { render } from 'vitest-browser-angular';

@Injectable()
class AnalyticsService {
  track(event: string) {
    return `tracked: ${event}`;
  }
}

@Component({
  template: '<button (click)="click()">Click me</button>',
  providers: [AnalyticsService],
})
class TrackedComponent {
  private analytics = inject(AnalyticsService);
  click() {
    this.analytics.track('click');
  }
}

test('resolves a component-level service', async () => {
  const { inject } = await render(TrackedComponent);

  const analytics = inject(AnalyticsService);
  expect(analytics.track('click')).toBe('tracked: click');
});

inject also works with InjectionToken:

import { InjectionToken } from '@angular/core';

const API_URL = new InjectionToken<string>('api-url');

test('resolves an InjectionToken', async () => {
  const { inject } = await render(TrackedComponent, {
    providers: [{ provide: API_URL, useValue: 'https://api.example.com' }],
  });

  expect(inject(API_URL)).toBe('https://api.example.com');
});

This is particularly useful for asserting on service state after user interactions, without having to expose the service on the component class.

Schema Options

When your component template references elements that Angular does not recognise — such as web components (<my-widget>) or child components you do not want to import in the test — you can pass NO_ERRORS_SCHEMA or CUSTOM_ELEMENTS_SCHEMA via the schema option:

import { NO_ERRORS_SCHEMA } from '@angular/core';
import { test, expect } from 'vitest';
import { render } from 'vitest-browser-angular';

@Component({
  template: '<my-widget [config]="options"></my-widget>',
})
class ConsumerComponent {
  options = { theme: 'dark' };
}

test('ignores unknown elements with NO_ERRORS_SCHEMA', async () => {
  const { container } = await render(ConsumerComponent, {
    schema: NO_ERRORS_SCHEMA,
  });

  expect(container.querySelector('my-widget')).toBeTruthy();
});

Without the schema option, Angular would throw a compile-time error for unknown elements. This is a quick escape hatch when you want to isolate the component under test without mocking every child dependency.

Clean DOM

By default Angular adds the ng-version attribute to the root element of the rendered component. In some cases — such as DOM snapshot assertions or visual regression testing — you may want a cleaner DOM without framework-specific attributes.

Pass removeAngularAttributes: true to strip ng-version after render:

import { test, expect } from 'vitest';
import { render } from 'vitest-browser-angular';

@Component({
  template: '<h1>Hello</h1>',
})
class SimpleComponent {}

test('renders a clean DOM', async () => {
  const { container } = await render(SimpleComponent, {
    removeAngularAttributes: true,
  });

  expect(container.hasAttribute('ng-version')).toBe(false);
  expect(container.innerHTML).toContain('<h1>Hello</h1>');
});

The attribute is removed after Angular completes its initial change detection, so the component behaves normally — only the DOM output is cleaned up.

Infer Tag Name

By default, render() mounts the component on a <div> host element. Pass inferTagName: true to use the component's selector as the host tag name instead:

import { Component } from '@angular/core';
import { test, expect } from 'vitest';
import { render } from 'vitest-browser-angular';

@Component({
  selector: 'app-hello-world',
  template: '<h1>Hello World</h1>',
})
export class HelloWorld {}

test('uses the component selector as the host tag', async () => {
  const { container } = await render(HelloWorld, {
    inferTagName: true,
  });

  expect(container.tagName).toBe('APP-HELLO-WORLD');
});

This can be useful when the host element's tag name matters — for example when testing styles that target a specific element selector or when asserting on the DOM structure. inferTagName only works when withRouting is not enabled.

Code Of Conduct

Be kind to each other and please read our code of conduct.


Credits

This project is inspired by the following projects:

vitest-browser-vue angular-testing-library

License

MIT