Angular tests with Vitest browser mode

Unit testing our Angular applications is something we take seriously.

The TestBed helps, but I've always found it very low-level. It looks to me like it was designed mainly to test the framework itself rather than applications using the framework. For example, there is nothing to help you interact with the UI elements (form inputs, selects, etc.) other than the low-level DOM APIs.

Recent versions, along with zoneless change detection, have improved the situation a little bit. We can now replace the many fixture.detectChanges() calls with await fixture.whenStable(), and let the framework detect the changes automatically, making the code under test behave more like it would behave in real-life. You can check out this conference talk by Cédric Exbrayat (in French) if you want to learn more about this topic.

To make tests easier to write and maintain, we have developed, and still heavily use ngx-speculoos. It's a thin layer on top of the TestBed, but it offers several interesting advantages:

  • it makes it easier to interact with elements, in particular form elements
  • it offers frequently used jasmine assertions to check the state of form and other DOM elements
  • it drastically reduces the number of fixture.detectChanges() / fixture.whenStable() calls necessary
  • with the page object pattern, it allows defining the selectors to access the elements to test once, and use them across all the tests of a suite

Angular 21 changes everything for the better. Change detection is zoneless by default, and Vitest is now the default testing framework instead of Jasmine. In particular, the Browser mode of Vitest makes unit testing awesome.

Instead of using a Node-based DOM implementation like Jest (and Vitest by default) do, the Browser mode allows the code under test to run in the real production environment: the browser(s) (Chromium, Firefox and Webkit). So we keep this feature that Karma offered.

But it goes much further. Vitest comes with two killer features.

Retried assertions

Retried assertions eliminate, most of the time, the need to call fixture.detectChanges() or await fixture.whenStable(). If you've used Playwright or Cypress for end-to-end tests, you should know the principle already.

Let's say you want to check that the title of the page contains "Hello". If you write:

expect(title).toHaveTextContent('Hello');

that will just make that check, immediately, and only once.

In order for that test to pass, you need to make sure that the asynchronous task that sets the title is done, and that the framework has detected the changes and updated the DOM. That's why we litter the code with fixture.detectChanges() or await fixture.whenStable(). But Vitest allows doing the following:

await expect.element(title).toHaveTextContent('Hello');

A small change, but with big implications! This time Vitest will execute the assertion, and retry it, again and again (every N milliseconds), until it passes or until the test times out. So if you haven't waited for the fixture to be stable, no problem: the assertion will fail once, but by the time it retries, the framework will have stabilized and updated the DOM, and the test will pass.

Imagine you're using a resource and you want to test that the page displays a spinner while the resource is loading, with Jasmine and the TestBed:

it('should display a spinner while loading', async () => {
  const fixture = TestBed.createComponent(MyComponent);
  await fixture.whenStable();
  expect(fixture.nativeElement.querySelector('[data-testid=spinner]')).toBeTruthy();
});

This looks simple enough, but it doesn't work: the application won't become stable until the resource has loaded. The fix is really not easy to figure out: you need to call TestBed.tick() in that case:

it('should display a spinner while loading', async () => {
  const fixture = TestBed.createComponent(MyComponent);
  TestBed.tick();
  expect(fixture.nativeElement.querySelector('#spinner')).toBeTruthy();
});

No need to deal with these low-level details thanks to retried assertions:

it('should display a spinner while loading', async () => {
  const fixture = TestBed.createComponent(MyComponent);
  await expect(page.getByText('Loading...')).toBeVisible();
});

Locators, their interactivity and assertion API

Instead of using the DOM API and interacting directly with DOM elements, in a Vitest browser test, you use locators. A locator is an object which allows getting zero, one or several elements on the page (or inside the element of another locator).

Every time you interact with the locator, it executes the query to find the element. This means that you can use the same locator throughout the tests of a component without worrying that it might be null, or stale, or not in the DOM anymore.

In fact, in my tests, I use the same page object pattern as with ngx-speculoos. Instead of using getters to query the element(s) every time I want to interact with them, check their presence or their state, I simply initialize locators (see the complete example below).

Vitest heavily promotes writing tests that work as if you were a user in front of the screen. So instead of locating elements by their ID or element names, you locate them by their text, role, label, etc.

Locators, in addition to querying components, also offer an API to test the state of their element(s) with rich (and extensible) assertions. And they also have an interaction API to manipulate them as a user would: click, fill, select, etc.

All this combined allows writing really expressive and simple tests suites like the following (we will not mock the service that it uses, but Vitest of course has a mocking API too):

class LoginTester {
  readonly fixture = TestBed.createComponent(LoginPage);
  readonly root = page.elementLocator(this.fixture.nativeElement);
  readonly login = page.getByLabelText('Login');
  readonly password = page.getByLabelText('Password');
  readonly signIn = page.getByRole('button', { name: 'Sign in' });
  readonly error = page.getByText('Wrong credentials, try again');
}

describe('LoginPage', () => {
  let tester: LoginTester;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [provideRouter([])]
    });
    tester = new LoginTester();
  });

  it('should display an empty form and no error initially', async () => {
    await expect.element(tester.login).toHaveDisplayValue('');
    await expect.element(tester.password).toHaveDisplayValue('');
    await expect.element(tester.error).not.toBeInTheDocument();
  });

  it('should validate', async () => {
    await tester.signIn.click();
    await expect.element(tester.root).toHaveTextContent('The login is required');
    await expect.element(tester.root).toHaveTextContent('The password is required');
  });

  it('should display an error when login fails', async () => {
    await tester.login.fill('john');
    await tester.password.fill('wrong-password');
    await tester.signIn.click();

    await expect.element(tester.error).toBeVisible();
  });

  it('should navigate away when login succeeds', async () => {
    const router = TestBed.inject(Router);
    vi.spyOn(router, 'navigate');

    await tester.login.fill('john');
    await tester.password.fill('correct-password');
    await tester.signIn.click();

    expect(router.navigate).toHaveBeenCalled();
  });
});

Isn't that beautiful?

Another cool thing? If you edit the component or its test and save, Vitest will reexecute just that test! Jest users had this for years, but we, poor Karma users, have been struggling with the whole test suite re-running on - every - single - save.

I still wonder how much time a very big test suite would take (the biggest app we're working on has more than 7500 tests), but given that Vitest can run tests in parallel, I hope it will scale reasonably well.

Anyway, we won't be able to rewrite all these tests any time soon.

For new projects, Vitest browser mode would be my preferred choice. What would be yours?


Picture of Jean-Baptiste Nizet
Jean-Baptiste Nizet

← Older post

Newer post →