Reputation: 51668
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(...);
})();
});
Upvotes: 27
Views: 15585
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
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
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 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.)
Upvotes: 1
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