Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Angular testing: using fakeAsync with async/await

Angular Material provides component harnesses for testing, which lets you interact with their components by awaiting promises, like this:

  it('should click button', async () => {
    const matButton = await loader.getHarness(MatButtonHarness);
    await matButton.click();
    expect(...);
  });

But what if the button click triggers a delayed operation? Normally I would use fakeAsync()/tick() to handle it:

  it('should click button', fakeAsync(() => {
    mockService.load.and.returnValue(of(mockResults).pipe(delay(1000)));
    // click button
    tick(1000);
    fixture.detectChanges();
    expect(...);
  }));

But is there any way I can do both in the same test?

Wrapping the async function inside fakeAsync() gives me "Error: The code should be running in the fakeAsync zone to call this function", presumably because once it finishes an await, it's no longer in the same function I passed to fakeAsync().

Do I need to do something like this -- starting a fakeAsync function after the await? Or is there a more elegant way?

  it('should click button', async () => {
    mockService.load.and.returnValue(of(mockResults).pipe(delay(1000)));
    const matButton = await loader.getHarness(MatButtonHarness);

    fakeAsync(async () => {
      // not awaiting click here, so I can tick() first
      const click = matButton.click(); 
      tick(1000);
      fixture.detectChanges();
      await click;
      expect(...);
    })();
  });
like image 970
JW. Avatar asked May 11 '20 21:05

JW.


3 Answers

fakeAsync(async () => {...}) is a valid construct.

Moreover, Angular Material team is explicitly testing this scenario.

it('should wait for async operation to complete in fakeAsync test', fakeAsync(async () => {
        const asyncCounter = await harness.asyncCounter();
        expect(await asyncCounter.text()).toBe('5');
        await harness.increaseCounter(3);
        expect(await asyncCounter.text()).toBe('8');
      }));
like image 64
Alex Okrushko Avatar answered Oct 18 '22 22:10

Alex Okrushko


I just released a test helper that lets you do exactly what you're looking for. Among other features, it allows you to use material harnesses in a fakeAsync test and control the passage of time as you describe.

The helper automatically runs what you pass to its .run() method in the fake async zone, and it can handle async/await. It would look like this, where you create the ctx helper in place of TestBed.createComponent() (wherever you have done that):

it('should click button', () => {
  mockService.load.and.returnValue(of(mockResults).pipe(delay(1000)));
  ctx.run(async () => {
    const matButton = await ctx.getHarness(MatButtonHarness);
    await matButton.click();
    ctx.tick(1000);
    expect(...);
  });
});

The library is called @s-libs/ng-dev. Check out the documentation for this particular helper here and let me know about any issues via github here.

like image 2
Eric Simonton Avatar answered Oct 18 '22 23:10

Eric Simonton


You should not need a (real) async inside fakeAsync, at least to control the simulated flow of time. The point of fakeAsync is to allow you to replace awaits with tick / flush. Now, when you actually need the value, I think you're stuck reverting to then, like this:

  it('should click button', fakeAsync(() => {
    mockService.load.and.returnValue(of(mockResults).pipe(delay(1000)));
    const resultThing = fixture.debugElement.query(By.css("div.result"));
    loader.getHarness(MatButtonHarness).then(matButton => {
      matButton.click(); 
      expect(resultThing.textContent).toBeFalsy(); // `Service#load` observable has not emitted yet
      tick(1000); // cause observable to emit
      expect(resultThing.textContent).toBe(mockResults); // Expect element content to be updated
    });
  }));

Now, because your test body function is inside a call to fakeAsync, it should 1) not allow the test to complete until all Promises created (including the one returned by getHarness) are resolved, and 2) fail the test if there are any pending tasks.

(As an aside, I don't think you need a fixture.detectChanges() before that second expect if you're using the async pipe with the Observable returned by your Service, because the async pipe explicitly pokes the owner's change detector whenever its internal subscription fires. I'd be interested to know if I'm wrong, though.)

like image 1
Coderer Avatar answered Oct 18 '22 23:10

Coderer