Angular Material provides component harnesses for testing, which lets you interact with their components by await
ing 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(...);
})();
});
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');
}));
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.
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 await
s 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.)
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With