Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

TestBed should be zoneless compatible #48198

Closed
yjaaidi opened this issue Nov 23, 2022 · 10 comments
Closed

TestBed should be zoneless compatible #48198

yjaaidi opened this issue Nov 23, 2022 · 10 comments
Assignees
Labels
area: core Issues related to the framework runtime area: testing Issues related to Angular testing features, such as TestBed area: zones
Milestone

Comments

@yjaaidi
Copy link
Contributor

yjaaidi commented Nov 23, 2022

Which @angular/* package(s) are relevant/related to the feature request?

core

Description

TL;DR: TestBed should provide a way to run zoneless and avoid importing zone.js when we don't need it.
And I'd love to help with a PR?

TestBed assumes that zone.js is available while there are contexts when this is not necessary and here are some examples:

  • zoneless apps & libs
  • tests that do not depend on change detection: service tests, pipe tests, "isolated" component testing etc...
  • tests where CD is triggered manually using fixture.detectChanges()

These tests must load zone.js when they don't need it.
While this might sound like a really minor issue, loading zone.js can cause trouble in some situations.

For instance, integrating TestBed and zone.js with vitest causes the following error Method Promise.prototype.then called on incompatible receiver which is probably due to zone.js patching promises and breaking Node.js's safe promises when importing ESMs dynamically or something like that. This is similar to the issue described here #47872.

Proposed solution

A first quick win is replacing this:

const ngZone = new NgZone({enableLongStackTrace: true});
const providers: Provider[] = [
{provide: NgZone, useValue: ngZone},

with:

{provide: NgZone, useFactory: () => new NgZone({enableLongStackTrace: true}); }

this would then allow us to go zoneless with this configuration:

TestBed.configureTestingModule({
  providers: [
    {
      provide: NgZone,
      useClass: ɵNoopNgZone,
    },
  ],
});

The main problem with this approach is the boilerplate and having to use the ɵNoopNgZone implementation detail.

A second step would be adding the bootstrap's ngZone option to initTestEnvironment:

getTestBed().initTestEnvironment(
  BrowserDynamicTestingModule,
  platformBrowserDynamicTesting(),
  {ngZone: 'noop'}
);

Alternatives considered

Disable promises monkey patching using globalThis.__Zone_disable_ZoneAwarePromise = true
but this causes the following issue #37349
Cf. #48359

@dylhunn dylhunn added area: testing Issues related to Angular testing features, such as TestBed area: core Issues related to the framework runtime labels Nov 23, 2022
@ngbot ngbot bot modified the milestone: needsTriage Nov 23, 2022
@AndrewKushnir
Copy link
Contributor

@yjaaidi thanks for submitting this ticket.

The first step of making the new NgZone creation lazy makes sense, please let us know if you are interested in submitting a PR. As you mentioned, that should provide an ability to replace ZoneJS implementation and experiment with vitest integration.

The second step of exposing a config option would require some discussions within the team. I'll share an update once I have it.

Thank you.

@AndrewKushnir AndrewKushnir self-assigned this Nov 23, 2022
@yjaaidi
Copy link
Contributor Author

yjaaidi commented Nov 30, 2022

Thanks @AndrewKushnir for your quick feedback.
This is great! And I'll be happy to help a PR pretty soon 😉

While investigating a bit further, I discovered another spot.
When there is a global afterEach (jasmine, jest, cypress, vitest with globals set to true), TestBed's cleanup hook calls resetFakeAsyncZone which throws the following error.

throw new Error(fakeAsyncTestModuleNotLoadedErrorMessage);

IMO, this shouldn't throw an error. The error doesn't make much sense if we're not using fakeAsync and fakeAsync already throws the error.

Then the function should actually be named something like tryResetFakeAsyncZone but as the function is part of the public API we will have to keep it as is or alias it resetFakeAsyncZone = tryResetFakeAsyncZone.

@AndrewKushnir
Copy link
Contributor

@yjaaidi the mentioned call is likely coming from here:

function getCleanupHook(expectedTeardownValue: boolean) {
return () => {
const testBed = TestBedImpl.INSTANCE;
if (testBed.shouldTearDownTestingModule() === expectedTeardownValue) {
testBed.resetTestingModule();
resetFakeAsyncZone();
}
};
}

We need to experiment with this more, but we can probably update it eventually to detect if we are in "zone-less" mode:

function getCleanupHook(expectedTeardownValue: boolean) {
  return () => {
    const testBed = TestBedImpl.INSTANCE;
    if (testBed.shouldTearDownTestingModule() === expectedTeardownValue) {
      const ngZone = testBed.get(NgZone); // access injector before it gets destroyed in the call below
      testBed.resetTestingModule();
      if (!(ngZone instanceof ɵNoopNgZone)) {
        resetFakeAsyncZone();
      }
    }
  };
}

@yjaaidi
Copy link
Contributor Author

yjaaidi commented Dec 1, 2022

Thanks @AndrewKushnir ! In fact, this could be a quick win.
Though, I still think that throwing an error about fakeAsync while nobody is using it is misleading. What do you think?

If someone is using fakeAsync, the error is already thrown anyway.

@AndrewKushnir
Copy link
Contributor

If someone is using fakeAsync, the error is already thrown anyway.

@yjaaidi could you please let me know which error you refer to?

@yjaaidi
Copy link
Contributor Author

yjaaidi commented Dec 1, 2022

@AndrewKushnir it's this one:

export function fakeAsync(fn: Function): (...args: any[]) => any {
if (fakeAsyncTestModule) {
return fakeAsyncTestModule.fakeAsync(fn);
}
throw new Error(fakeAsyncTestModuleNotLoadedErrorMessage);
}

@LayZeeDK
Copy link
Contributor

LayZeeDK commented Apr 19, 2023

@yjaaidi

In component tests, the NgZone is disabled by providing true as the value of ComponentFixtureNoNgZone as seen in

TestBed.configureTestingModule({
declarations: [MyComp, ContainerComp, EmbeddedViewComp],
providers: [{provide: ComponentFixtureNoNgZone, useValue: true}]
});

Example

import { ComponentFixtureNoNgZone, TestBed } from '@angular/core/testing'
import { MyComponent } from './my.component';

it('supports zoneless', () => {
  TestBed.configureTestingModule({
    providers: [
      { provide: ComponentFixtureNoNgZone, useValue: true },
    ],
  });
  
  const fixture = TestBed.createComponent(MyComponent);
});

The tests for ComponentFixtureNoNgZone are at

describe('No NgZone', () => {
beforeEach(() => {
TestBed.configureTestingModule(
{providers: [{provide: ComponentFixtureNoNgZone, useValue: true}]});
});
it('calling autoDetectChanges raises an error', () => {
const componentFixture = TestBed.createComponent(SimpleComp);
expect(() => {
componentFixture.autoDetectChanges();
}).toThrowError(/Cannot call autoDetectChanges when ComponentFixtureNoNgZone is set/);
});
it('should instantiate a component with valid DOM', waitForAsync(() => {
const componentFixture = TestBed.createComponent(SimpleComp);
expect(componentFixture.ngZone).toBeNull();
componentFixture.detectChanges();
expect(componentFixture.nativeElement).toHaveText('Original Simple');
}));
it('should allow changing members of the component', waitForAsync(() => {
const componentFixture = TestBed.createComponent(MyIfComp);
componentFixture.detectChanges();
expect(componentFixture.nativeElement).toHaveText('MyIf()');
componentFixture.componentInstance.showMore = true;
componentFixture.detectChanges();
expect(componentFixture.nativeElement).toHaveText('MyIf(More)');
}));
});

The all but missing docs for ComponentFixtureNoNgZone are at https://angular.io/api/core/testing/ComponentFixtureNoNgZone

@yjaaidi
Copy link
Contributor Author

yjaaidi commented Apr 29, 2023

Hey @LayZeeDK !

Thanks for the help! Actually, ComponentFixtureNoNgZone skips injecting NgZone but NgZone was still instantiated and provided here (even if it's never injected nor used).

const ngZone = new NgZone({enableLongStackTrace: true});
const providers: Provider[] = [
{provide: NgZone, useValue: ngZone},

But then it's already too late as the constructor of NgZone asserts the presence of Zone.js:

if (typeof Zone == 'undefined') {
throw new Error(`In this configuration Angular requires Zone.js`);
}

This made it mandatory to import zone.js even if it's not used... and that's what doesn't play well with Vitest & ESM in Node (due to promise monkey patching).

But guess what! Thanks to your comment, I checked again and this was indirectly fixed by the new provideNgZoneChangeDetection() function and mainly this commit d7d6514 by @atscott

Thanks so much to both of you!!!

Also thank you @AndrewKushnir ! and sorry for the delay... I was off for some time and still catching up.

@yjaaidi
Copy link
Contributor Author

yjaaidi commented Apr 29, 2023

This was partially and indirectly solved by #49557 by @atscott

Now, we still have to fix the clean up: #48198 (comment)

@yjaaidi yjaaidi closed this as completed Apr 29, 2023
@yjaaidi yjaaidi reopened this Apr 29, 2023
atscott added a commit to atscott/angular that referenced this issue Mar 28, 2024
The test hooks should not throw if applications choose not to load ZoneJS.

fixes angular#48198
atscott added a commit to atscott/angular that referenced this issue Mar 28, 2024
The test hooks should not throw if applications choose not to load ZoneJS.

fixes angular#48198
atscott added a commit to atscott/angular that referenced this issue Mar 28, 2024
The test hooks should not throw if applications choose not to load ZoneJS.

fixes angular#48198
atscott added a commit that referenced this issue Mar 28, 2024
The test hooks should not throw if applications choose not to load ZoneJS.

fixes #48198

PR Close #55096
ilirbeqirii pushed a commit to ilirbeqirii/angular that referenced this issue Apr 6, 2024
…lar#55096)

The test hooks should not throw if applications choose not to load ZoneJS.

fixes angular#48198

PR Close angular#55096
@angular-automatic-lock-bot
Copy link

This issue has been automatically locked due to inactivity.
Please file a new issue if you are encountering a similar or related problem.

Read more about our automatic conversation locking policy.

This action has been performed automatically by a bot.

@angular-automatic-lock-bot angular-automatic-lock-bot bot locked and limited conversation to collaborators Apr 28, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
area: core Issues related to the framework runtime area: testing Issues related to Angular testing features, such as TestBed area: zones
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants