JW.
JW.

Reputation: 51668

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(...);
    })();
  });

Upvotes: 27

Views: 15585

Answers (4)

Joe
Joe

Reputation: 691

After updating from Angular 12 to 14, tests that were previously running without issue started to fail. The specific tests that were failing depended on both fakeAsync as well as async.

The resolution in my case was to add the following target to the tsconfig.spec.json

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./out-tsc/spec",
    "module": "CommonJs",
    "target": "ES2016", // Resolved fakeAsync + async tests errors
    "types": ["jest"]
  },
  "include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
}

A contrived test example:

it('should load button with exact text', fakeAsync(async () => {
  const buttons = await loader.getAllHarnesses(
    MatButtonHarness.with({ text: 'Testing Button' })
  );

  tick(1000);

  expect(buttons.length).toBe(1);
  expect(await buttons[0].getText()).toBe('Testing Button');
}));

I was getting the following error and it pointed directly to the tick(1000):

The code should be running in the fakeAsync zone to call this function

After adding the ES2016 target to my tsconfig.spec.json all issues were resolved.

I use Jest, so those using other test runners may not have the same resolution.

Upvotes: 8

Eric Simonton
Eric Simonton

Reputation: 6039

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.

Upvotes: 2

Coderer
Coderer

Reputation: 27304

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.)

Upvotes: 1

Alex Okrushko
Alex Okrushko

Reputation: 7382

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');
      }));

Upvotes: 21

Related Questions