Signals

Angular Zoneless Unit Testing

The Future Is Zoneless — What Can We Do Today? You’ve probably heard: Angular is moving toward a Zoneless future. Migrating your Angular app to run without Zone.js brings several benefits, but for medium-to-large applications, the process might not be trivial. The good news is that you can migrate a

Angular Zoneless Unit Testing — Signals article by Francesco Borzì on Angular In Depth
Angular Zoneless Unit Testing — Signals article by Francesco Borzì on Angular In Depth
On this page · 7 sections

Angular Zoneless Unit Testing — figure 1

The Road Ahead: Zoneless and What It Means for Testing

By now, you've likely encountered the news: Angular is heading toward a Zoneless future. Shifting an existing application away from Zone.js has its advantages, though for sizable projects, the transition can be challenging.

Fortunately, the migration path doesn't have to happen all at once. Incremental steps, like moving components to OnPush change detection, are recommended prerequisites for Zoneless compatibility. This move also yields immediate performance wins, and the process is more straightforward now with the adoption of Signals.

Another crucial step is ensuring your unit tests run in Zoneless mode. This verifies that your components can function without Zone.js, even if your main application hasn't fully completed the switch yet.

While the primary theme here is test adaptation, I suggest checking out this article from Angular Experts

Activating Zoneless Mode in Tests

To begin, include provideZonelessChangeDetection() in your providers array:

TestBed.configureTestingModule({
  providers: [
    provideZonelessChangeDetection(),
    // ...
  ]
});

It's wise to make this the default for any new component tests while working through the migration of existing ones. Be prepared for some tests to fail once this is enabled, depending on how your components and test structure are organized.

Limit Calls to detectChanges() — Ideally, Just Avoid It

The Angular documentation explicitly discourages the manual invocation of fixture.detectChanges() in your test suites:

To ensure tests have the most similar behavior to production code, avoid using fixture.detectChanges() when possible. This forces change detection to run when Angular might otherwise have not scheduled change detection.

Rather than forcing change detection, you should await fixture.whenStable():

// not recommended (still ok)
it('should do something', () => {
  const { page } = setup();

  page.triggerSomeAction();
  page.fixture.detectChanges();

  expect(something).toBe(true);
});

// recommended
it('should do something', async () => {
  const { page } = setup();

  await page.fixture.whenStable();

  expect(something).toBe(true);
});

I've marked “still ok” in the code comment for detectChanges() for the following reason:

For existing test suites, using fixture.detectChanges() is a common pattern and it is likely not worth the effort of converting these to await fixture.whenStable(). TestBed will still enforce that the fixture's component is OnPush compatible and throws ExpressionChangedAfterItHasBeenCheckedError if it finds that template values were updated without a change notification

That said, my experience has shown that invoking detectChanges() multiple times tends to lead to problems, especially in the context of OnPush or Zoneless setup:

// usually problematic - avoid!
it('should correctly react on action 1 and action 2', () => {
  const { page } = setup();

  page.triggerActionOne();
  page.fixture.detectChanges(); // first call to detectChanges()
  expect(something).toBe(true);

  page.triggerActionTwo();
  page.fixture.detectChanges(); // second call, often problematic!
  expect(something).toBe(true);
});

// do this instead - keep each action separate!
it('should correctly react on action 1', async () => {
  const { page } = setup();

  page.triggerActionOne();
  await page.fixture.whenStable();
  
  expect(something).toBe(true);
});

it('should correctly react on action 2', async () => {
  const { page } = setup();

  page.triggerActionTwo();
  await page.fixture.whenStable();

  expect(something).toBe(true);
});

Moving Away from fakeAsync() and tick()

Indeed, this caught me off guard, too. The helpers fakeAsync() and tick() are built on top of Zone.js and will lose their functionality once it's fully removed from the test environment.

As I discussed in a previous article, these utilities have been standard practice in Angular testing. If you've been writing Angular tests for a while, you've almost certainly used them.

// will not work without the zone.js dependency
it('should do something async', fakeAsync(() => {
  const { page } = setup();

  page.doSomething();
  tick();

  expect(something).toBe(true);
}));

It's important to note that fakeAsync() and tick() will still function alongside provideZonelessChangeDetection() as long as zone.js remains a dependency in your test script. So, this part of the migration can be deferred.

What to Use Instead of fakeAsync() and tick()

Currently, the Angular team is coordinating with test framework authors like Jasmine and Jest to develop a standardized replacement. Expect updates to the official Angular docs with new guidance shortly. In the interim, the suggested approach is to use await fixture.whenStable().

However, although waiting for whenStable() is the right move when it works, it may not always be applicable based on my experience. Consider these scenarios:

  • It isn't available because you are testing a Service or another non-Component class.
  • It doesn't effectively wait for the specific asynchronous operation to settle.

To handle such cases, I've developed a small helper called tickAsync(). Its implementation is straightforward; you can grab it from the lightweight library ngx-page-object-model or copy it directly from this source. Here's how to use it:

import { tickAsync } from 'ngx-page-object-model'; // or copy it from GitHub

it('should do something async', async () => {
  const { page } = setup();

  page.doSomething();
  // use this instead of tick() whenever fixture.whenStable() cannot be used
  await tickAsync();

  expect(something).toBe(true);
});

This approach resolved the majority of issues where whenStable() fell short. There were, however, a few edge cases where I had no choice but to introduce a manual timer delay:

// ⚠️ it will ACTUALLY wait for 100ms - not ideal.
await tickAsync(100);

This is a last resort though. Relying on real-time delays in tests is not a practice you want to follow.

For handling fake timers, the optimal solution actually comes from your testing library itself, like Jasmine or Jest. While waiting for an official Angular recommendation (track the progress in this GitHub issue), it's helpful to look at an example of using mock clocks in Jasmine:

it('should write the changed file content to the sandbox filesystem', () => {
  jasmine.clock().install();
  jasmine.clock().mockDate();
  const newContent = 'new content';

  const nodeRuntimeSandboxSpy = spyOn(fakeNodeRuntimeSandbox, 'writeFile');

  dispatchDocumentChange(newContent);
  jasmine.clock().tick(EDITOR_CONTENT_CHANGE_DELAY_MILLIES);

  expect(nodeRuntimeSandboxSpy).toHaveBeenCalledWith(service.currentFile().filename, newContent);
  jasmine.clock().uninstall();
});

You should also check out this PR in the Jasmine repository submitted by Andrew Scott, who has been instrumental in Zoneless support. Thanks to Matthieu Riegler for pointing me to these resources.

Ultimately, the built-in mock clock APIs from your chosen testing framework are the appropriate substitutes for tick(). Other frameworks like Jest offer similar utilities, but covering them all is beyond the scope of this piece.

Eliminating zone.js from Your Tests Entirely

When your tests no longer have any reliance on Zone.js, you can strip out the dependency from your test environment completely.

Remove the following from the “test” target in your project.json (NX) or angular.json (Angular CLI) workspace file:

// remove this line
"polyfills": ["zone.js", "zone.js/testing"],

Alternatively, if you have a test.ts setup file in your project, verify that it no longer includes a zone.js import:

// delete these lines
import 'zone.js';
import 'zone.js/testing';
Mount Etna — Photo by Nienke Koedijk
Mount Etna — Photo by Nienke Koedijk

Final Thoughts

  • Angular is making the leap to being Zoneless, which will unlock several performance benefits
  • The migration to Zoneless can be intricate, but it's best approached incrementally
  • Adopting OnPush and Signals sets the stage for Zoneless readiness
  • Unit Tests are a valuable tool for verifying component readiness in a Zoneless setup prior to fully migrating your app
  • You can activate Zoneless mode specifically for individual test cases
  • Steer clear of fixture.detectChanges() in your tests—particularly repeated calls. Favor await fixture.whenStable()
  • Move.away from fakeAsync() and tick() Since they depend on Zone.js, switch to the mock clock APIs offered by your test framework

Angular Zoneless Unit Testing — figure 3
FB
Francesco Borzì

Writes about Signals. Active 2025.

All 1 article →