21

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(...);
    })();
  });
ggorlen
  • 44,755
  • 7
  • 76
  • 106
JW.
  • 50,691
  • 36
  • 115
  • 143

4 Answers4

17

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');
      }));
Alex Okrushko
  • 7,212
  • 6
  • 44
  • 63
  • 1
    Cool, thanks. Looks like this was fixed a few months after I posted. – JW. Oct 11 '21 at 14:51
  • 3
    It is valid, but also risky. If your await is applied to a microtask, you don't have any issues because fakeAsync flushes microtasks at the end. If it is macrotask tough, you get an error. `tick` can't help you after the `await` anymore because it would already be executed in that macrotask. – rainerhahnekamp Mar 31 '22 at 20:49
5

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.

Joe
  • 633
  • 4
  • 11
  • 18
  • thank you, this solved my issue. After upgrading to Jest 29.5.0 fakeAsync errors related to tick() appeared for tests that passed just fine under Jest 28.x. – pete19 Apr 22 '23 at 00:53
  • I could absolutely kiss you right now. Does anybody know why this fixes it? – secondbreakfast Jun 06 '23 at 00:50
2

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.

ggorlen
  • 44,755
  • 7
  • 76
  • 106
Eric Simonton
  • 5,702
  • 2
  • 37
  • 54
1

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

ggorlen
  • 44,755
  • 7
  • 76
  • 106
Coderer
  • 25,844
  • 28
  • 99
  • 154
  • Why would using a `then` be preferred over async/await? Seems silly – Frederick Sep 20 '21 at 20:46
  • 2
    `fakeAsync` works by monkey-patching runtime behavior of the global Promise object, but there are no hooks in the browser for modifying the behavior of `async/await` statements. It's the reason why [you can't emit native async functions without breaking Zone](https://github.com/angular/angular/issues/31730). Using an `await` statement in your spec, that is eventually downleveled by your transpiler before running, might be fine though. – Coderer Sep 21 '21 at 10:45