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

feat(core): support fakeAsync.wrap() functionality across multiple test cases. #40611

Closed
wants to merge 3 commits into from

Conversation

JiaLiPassion
Copy link
Contributor

@JiaLiPassion JiaLiPassion commented Jan 28, 2021

Close #40387

Enable global fakeAsync() feature by introducing hooks functions.
fakeAsync.wrap({beforeEach?: () => void, afterEach?: () => void);

Motivation

Consider the similar function from jest.useFakeTimers(), we can write test cases like this.

describe('test', () => {
   beforeAll(() => {
     jest.useFakeTimers();   
   });

   afterAll(() => {
     jest.clearAllTimers();
     jest.useRealTimers();
   });

   test('async test1', () => {
      setInterval(() => {}, 100);
      jest.advanceTimersByTime(100);
      expect(...);
   });

    test('async test2', () => {
      setInterval(() => {}, 200);
      jest.advanceTimersByTime(200);
      expect(...);
   });
});

So with jest.useFakeTimers(), all the following tests are automatically using the fake timers, and the developers can write the common init, cleanup logic in beforeEach()/afterEach().

But with fakeAsync(), we can not do the same thing for now.

For example, we have a Component like this.

@component({...})
export class AppComponent {
  timerId: number;
  ngOnInit() {
    this.timerId = setTimeout(() => {});
  }

  ngOnDestroy() {
    clearTimeout(this.timerId);
  }
}

And for now, we need to write test
like this.

describe('AppComponent test', () => {
  let fixture: ComponentFixture<AppComponent>;
  beforeEach(() => {
    ...
    fixture = TestBed.createComponent(AppComponent);
  });

  it('test case1', fakeAsync(() => {
    fixture.detectChanges();
    // do some test with fixture
    fixture.destroy();
  }));

  it('test case2', fakeAsync(() => {
    fixture.detectChanges();
    // do some test with fixture
    fixture.destroy();
  }));
});

We need to call fixture.destroy() inside each tests, since each it() use
it's own fakeAsync() and we need to clean up the timerId created in that
FakeAsyncZoneSpec. Otherwise the fakeAsync() will throw there are still pending timers error.

Solution

So in this PR, there are two hooks functions are introduced.

With the hook functions, we can write case in this way.

   describe('AppComponent test', () => {
     let fixture: ComponentFixture<AppComponent>;
   
     const fakeAsyncWithFixture = fakeAsync.wrap({
       beforeEach: () => {
         fixture = TestBed.createComponent(AppComponent);
         fixture.detectChanges();
       }
       afterEach: () => fixture.destroy();
     });
   
     it('test case1', fakeAsyncWithFixture(() => {
       // do some test with fixture
     }));
   
     it('test case2', fakeAsyncWithFixture(() => {
       // do some test with fixture
     }));
   });
   
   Also the `wrap()` function support nesting.
   
   describe('AppComponent test', () => {
     let fixture: ComponentFixture<AppComponent>;
   
     const fakeAsyncWithFixture = fakeAsync.wrap({
       beforeEach: () => {
         fixture = TestBed.createComponent(AppComponent);
         fixture.detectChanges();
       }
       afterEach: () => fixture.destroy();
     });
   
     it('test case1', fakeAsyncWithFixture(() => {
       // do some test with fixture
     }));
   
     it('test case2', fakeAsyncWithFixture(() => {
       // do some test with fixture
     }));
   
     describe('AppComponent sub test: auth test', () => {
       const fakeAsyncNested = fakeAsyncWithFeature.wrap({
         beforeEach: () => fixture.componentInstance.login(),
         afterEach: () => fixture.componentInstance.logout();
       });
   
       it('should show user info', () => {
         // do some test with fixture with authenticated user.
       });
     });
   });

This feature will be useful when we want to do some global cleanup
in afterEach() when the component have some async tasks
(especially setInterval) running by 3rd party library.

@JiaLiPassion JiaLiPassion added feature Issue that requests a new feature area: testing Issues related to Angular testing features, such as TestBed area: zones labels Jan 28, 2021
@ngbot ngbot bot added this to the Backlog milestone Jan 28, 2021
@google-cla google-cla bot added the cla: yes label Jan 28, 2021
@pullapprove pullapprove bot requested a review from IgorMinar January 28, 2021 11:48
@JiaLiPassion JiaLiPassion marked this pull request as draft January 29, 2021 00:02
@mhevery
Copy link
Contributor

mhevery commented Jan 29, 2021

What happens when developer forgets to invoke endFakeAsync()? Is there a sane failure mode?

An alternative approach may be something as shown below. Have you consider it? What do you think are advantages/disadvantages of the two approaches?

describe('AppComponent test', () => {
  let fixture: ComponentFixture<AppComponent>;
  const withFixtureFakeAsync = fakeAsync.bind({
    before:  () => fixture = TestBed.createComponent(AppComponent),
    after: () => fixture.destroy()
  });

  it('test case1', withFixtureFakeAsync(() => {
    fixture.detectChanges();
    // do some test with fixture
  }));

  it('test case2', withFixtureFakeAsync(() => {
    fixture.detectChanges();
    // do some test with fixture
  }));
});

@JiaLiPassion
Copy link
Contributor Author

@mhevery, yeah, I like your idea,

  1. This is much safer, the users don't need to worry about forgetting to call endFakeAsync().
  2. And the user will know that their tests are under fakeAsync(), also in the same describe, they can write fakeAsync(), async(), normal test together.

I will update the PR

packages/core/testing/src/fake_async.ts Outdated Show resolved Hide resolved
packages/core/testing/src/fake_async.ts Outdated Show resolved Hide resolved
packages/zone.js/lib/jasmine/jasmine.ts Outdated Show resolved Hide resolved
packages/zone.js/lib/zone-spec/fake-async-test.ts Outdated Show resolved Hide resolved
@petebacondarwin
Copy link
Member

By the way, should the hooks be beforeEach and afterEach to be more inline with the jasmine API?

@JiaLiPassion
Copy link
Contributor Author

@petebacondarwin , yes, you are right, beforeEach() and afterEach() are better names, I updated the PR, thank you.

@JiaLiPassion JiaLiPassion force-pushed the global-fakeasync branch 5 times, most recently from d34964d to fe770f6 Compare February 10, 2021 00:43
@mary-poppins
Copy link

You can preview fe770f6 at https://pr40611-fe770f6.ngbuilds.io/.

@mary-poppins
Copy link

You can preview 0f8d2ac at https://pr40611-0f8d2ac.ngbuilds.io/.

@JiaLiPassion JiaLiPassion changed the title feat(core): support global fakeAsync functionality across multiple test cases. feat(core): support fakeAsync.wrap() functionality across multiple test cases. Feb 10, 2021
@mary-poppins
Copy link

You can preview 153891e at https://pr40611-153891e.ngbuilds.io/.

@JiaLiPassion JiaLiPassion force-pushed the global-fakeasync branch 4 times, most recently from bedc39e to f7b9aeb Compare February 20, 2022 08:51
dylhunn
dylhunn previously approved these changes Feb 22, 2022
Copy link
Contributor

@dylhunn dylhunn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reviewed-for: public-api

@atscott
Copy link
Contributor

atscott commented Feb 22, 2022

Hi @JiaLiPassion, it looks like the issue you linked was closed by 0652b29. Is this still necessary?

That question aside, I'm not sure I see why this specific thing should be a feature provided by the core/zone/fakeAsync API. How is it different from this:

  describe('AppComponent test', () => {
     let fixture: ComponentFixture<AppComponent>;
   
     beforeEach(() => {
       fixture = TestBed.createComponent(AppComponent);
       fixture.detectChanges();
     });
     afterEach(() => fixture.destroy());
   
     it('test case1', fakeAsync(() => {
       // do some test with fixture
     }));
   
     it('test case2', fakeAsync(() => {
       // do some test with fixture
     }));
   });

The above is actually shorter than your example using this new feature.

@JiaLiPassion
Copy link
Contributor Author

@atscott thank you for the review.
The reason we need a new wrap API is to make sure before/after hook can run in the same fakeAsync() zone with the it() test case.
If we write case like this,

@component({...})
export class AppComponent {
  timerId: number;
  ngOnInit() {
    this.timerId = setTimeout(() => {});
  }

  ngOnDestroy() {
    clearTimeout(this.timerId);
  }
}
  describe('AppComponent test', () => {
     let fixture: ComponentFixture<AppComponent>;
   
     beforeEach(() => {
       fixture = TestBed.createComponent(AppComponent);
       fixture.detectChanges();
     });
     afterEach(() => fixture.destroy());
   
     it('test case1', fakeAsync(() => {
       // do some test with fixture
      tick();
     }));
   
     it('test case2', fakeAsync(() => {
       // do some test with fixture
     }));
   });

the setTimeout in beforeEach() will not in the fakeAsync() and will run asynchronize, so the it('test case1') and it('test case2') will run before the setTimeout callback executed, and calling tick() in it() will not trigger the setTimeout in the beforeEach() which may not match the user's expection.

@atscott
Copy link
Contributor

atscott commented Feb 23, 2022

@JiaLiPassion Okay, I think that makes sense. Could you please update the documentation to describe this better? The way it is now, it seems to imply what I wrote should work fine. "without hook functions, we need to write test like this" Unless I'm missing something, the example after that sentence is exactly what I wrote in my comment.

Also, I'm still curious about 0652b29 and if it makes this PR obsolete. If not, the commit message should be changed to reflect that this solves a different issue (one that's not closed).

/**
* Wrap the `fakeAsync()` function with the `beforeEach()/afterEach()` hooks.
*
* TODO: add code samples
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the example code supposed to be here rather than fake-async-test.ts? Or should it be in both locations

@JiaLiPassion
Copy link
Contributor Author

@atscott , got it, I will update the commit message, and about the aio doc, in the previous commit, the aio document and the sample code are also be updated, but it seems the aio project reference the 13.2.2 version instead of the local version, so the sample code will not be compiled since the new wrap() API.

@JiaLiPassion JiaLiPassion force-pushed the global-fakeasync branch 2 times, most recently from 995672d to 3547ad2 Compare February 25, 2022 13:20
…()/afterEach()` hooks.

Close angular#40387

Provide the new `fakeAsync.wrap()` function with the `beforeEach()/afterEach()` hooks.

Given the AppComponent:

@component({...})
export class AppComponent {
  timerId: number;
  ngOnInit() {
    this.timerId = setTimeout(() => {});
  }

  ngOnDestroy() {
    clearTimeout(this.timerId);
  }
}

And without hook functions, we need to write test
like this.

describe('AppComponent test', () => {
  let fixture: ComponentFixture<AppComponent>;
  beforeEach(() => {
    ...
    fixture = TestBed.createComponent(AppComponent);
  });

  it('test case1', fakeAsync(() => {
    fixture.detectChanges();
    // do some test with fixture
    fixture.destroy();
  }));

  it('test case2', fakeAsync(() => {
    fixture.detectChanges();
    // do some test with fixture
    fixture.destroy();
  }));
});

We need to call `fixture.destroy()` inside each tests, since each `it()` use
it's own fakeAsync() and we need to clean up the timerId created in that
FakeAsyncZone.

With the hook functions, we can write case in this way.

describe('AppComponent test', () => {
  let fixture: ComponentFixture<AppComponent>;

  const fakeAsyncWithFixture = fakeAsync.wrap({
    beforeEach: () => {
      fixture = TestBed.createComponent(AppComponent);
      fixture.detectChanges();
    }
    afterEach: () => fixture.destroy();
  });

  it('test case1', fakeAsyncWithFixture(() => {
    // do some test with fixture
  }));

  it('test case2', fakeAsyncWithFixture(() => {
    // do some test with fixture
  }));
});

Also the wrap() function support nesting.

describe('AppComponent test', () => {
  let fixture: ComponentFixture<AppComponent>;

  const fakeAsyncWithFixture = fakeAsync.wrap({
    beforeEach: () => {
      fixture = TestBed.createComponent(AppComponent);
      fixture.detectChanges();
    }
    afterEach: () => fixture.destroy();
  });

  it('test case1', fakeAsyncWithFixture(() => {
    // do some test with fixture
  }));

  it('test case2', fakeAsyncWithFixture(() => {
    // do some test with fixture
  }));

  describe('AppComponent sub test: auth test', () => {
    const fakeAsyncNested = fakeAsyncWithFeature.wrap({
      beforeEach: () => fixture.componentInstance.login(),
      afterEach: () => fixture.componentInstance.logout();
    });

    it('should show user info', () => {
      // do some test with fixture with authenticated user.
    });
  });
});

This feature is useful when we want to do some common initial/cleanup when the component has some `async`
tasks (especially `setInterval`) running by 3rd party library.
`zone.js` provides a new API `fakeAsync.wrap()`
to support `hooks` of `fakeAsync()` to allow user to write common `begin`/'after`
logic. This commit add the new `wrap()` API to `@angular/core/testing`.
1. add `fakeAsync.wrap()` example code.
1. add `fakeAsync.wrap()` doc in the `testing-components-scenarios.md`
doc.
@atscott
Copy link
Contributor

atscott commented Mar 3, 2022

@JiaLiPassion I've moved this back to a draft since it doesn't seem ready for review

@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 25, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
area: testing Issues related to Angular testing features, such as TestBed area: zones cla: yes feature Issue that requests a new feature
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Memory leak in tests with zone.js >= 0.9.0
7 participants