How to write async tests in Angular using async/await

Overview

If we're testing an Angular application, then at some point, we'll have to test asynchronous behavior. This article will demonstrate writing an asynchronous test with async/await. We'll examine each step in detail to write our asynchronous tests.

We can view the entire application code and tests at StephenCooper/async-angular-testing.

Application

We'll test an application that uses AG Grid. Our application displays a table of Olympic medal winners and provides users with a text box to filter the medal winners by field. Try the application here.

Filter the data by text input
Filter the data by text input

We will test how to filter our data to a specific country of interest. Our test will validate that:

  1. The grid shows the complete set of 1000 rows, and the application displays the row count of 1000.
  2. Enter the text "Germany." The grid should filter the rows only to show German athletes.
  3. The application row count should update to 68 (the number of German athletes).

The reason for choosing this application is that it contains asynchronous code making it virtually impossible to test synchronously.

Application code

In our application, we've got a text input box bound to our quickFilterText component's property. We display the current number of rows in our template and pass the quickFilterText to our grid component so that it can filter its rows as required.

<input id="quickFilter" type="text" [(ngModel)]="quickFilterText"/>
<div id="numberOfRows">Number of rows: {{ displayedRows }}</div>
<ag-grid-angular #grid
[quickFilterText]="quickFilterText"
(modelUpdated)="onModelUpdated($event)"
></ag-grid-angular>

The number of rows will be kept up to date by using the grid callback. This is fired every time the grid model is updated, including when filtering is performed.

export class AppComponent implements OnInit {
public displayedRows: number = 0;
public quickFilterText: string = '';
@ViewChild('grid') grid: AgGridAngular;
onModelUpdated(params: ModelUpdatedEvent) {
this.displayedRows = params.api.getDisplayedRowCount();
}
}

Test helpers

Before we get to the tests, let me quickly explain the assertion helper function we will be using. This function will give us an insight into the inner workings of our test, especially when we start working with asynchronous callbacks.

The function validates the following:

  • internal grid state
  • state of the component variable that is displayedRows
  • rendered HTML output of the {{ displayedRows }} binding

We'll see that these values do not update in sync due to asynchronous callbacks and if change detection is required to have run to update the property.

function validateState({ gridRows, displayedRows, templateRows }) {
// Validate the internal grid model by calling its api method to get the row count
expect(component.grid.api.getDisplayedRowCount())
.withContext('api.getDisplayedRowCount')
.toEqual(gridRows)
// Validate the component property displayedRows
expect(component.displayedRows)
.withContext('component.displayedRows')
.toEqual(displayedRows)
// Validate the rendered html content that the user would see
expect(rowNumberDE.nativeElement.innerHTML)
.withContext('<div> {{displayedRows}} </div>')
.toContain("Number of rows: " + templateRows)
}

The .withContext() is a helpful Jasmine method to give us clearer error messages when values aren't equal.

Configuration of the test module

The first part of the test is to configure the test module. It requires AG Grid's AgGridModule and also Angular's FormModule to provide support for ngModel.

import { DebugElement } from '@angular/core';
import { ComponentFixture, fakeAsync, flush, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { AgGridModule } from 'ag-grid-angular';
import { AppComponent } from './app.component';
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [AppComponent],
imports: [AgGridModule, FormsModule],
});
// Create the test component fixture
fixture = TestBed.createComponent(AppComponent);
component = fixture.componentInstance;
let compDebugElement = fixture.debugElement;
// Get a reference to the quickFilter input and rendered template
quickFilterDE = compDebugElement.query(By.css('#quickFilter'))
rowNumberDE = compDebugElement.query(By.css('#numberOfRows'))
});

An important thing to note here is what is missing from beforeEach. We have purposefully not included fixture.detectChanges() as part of our setup logic. By doing this, we ensure that all our tests are as isolated, and it enables us to make assertions on our component before it is initialized. Finally, and most importantly, when working with fakeAsync , we don't want our component to be created outside of our test's fakeAsync context. If we do this, we can end up with all sorts of test inconsistencies and bugs.

Note that we do not run fixture.detectChanges() inside the beforeEach method. This can lead to numerous issues when testing asynchronous code.

Broken Synchronous Test

To prove that we need to handle this test asynchronously, let's first try to write the test synchronously.

it('should filter rows by quickfilter (sync version)', (() => {
// When the test starts our test harness component has been created but not our child grid component
expect(component.grid).toBeUndefined()
// Our first call to detectChanges, causes the grid to be created
fixture.detectChanges()
// Grid has now been created
expect(component.grid.api).toBeDefined()
// Run change detection to update template
fixture.detectChanges()
validateState({ gridRows: 1000, displayedRows: 1000, templateRows: 1000 })
}))

While it looks like this test should pass, it does not. We would expect that by the point we call validateState each assertion would correctly show 1000 rows. However, only the internal grid model has 1000 rows, and both the component property and rendered output display 0. This results in the following test errors:

Error: component.displayedRows: Expected 0 to equal 1000.
Error: <div> {{displayedRows}} </div>: Expected 'Number of rows: 0 for' to contain 1000.

This happens because the grid setup code runs synchronously and so has been completed before our assertion. However, the component property is still 0 because the grid callback is asynchronously and is still in the Javascript event queue when we reach the assertion statement. That is, it hasn't run yet.

If you are not familiar with the Javascript event queue and how asynchronous tasks are run, then you may find it beneficial to read these articles:

As we cannot even validate the starting state of our test synchronously, it is clear that we are going to need to update our tests to handle asynchronous callbacks correctly.

How to write an Async Test

We'll cover the async/await approach for writing our test that handles the asynchronous grid behavior.

How to use async await

We can test our application using the built-in asyncandawaitsyntax along with the fixture method fixture.whenStable(). This can, at times, be a simpler way to write async tests as you do not have to worry about manually running async tasks.

It is worth noting that there are cases when it is impossible to write a test with fakeAsync. If any of the executed code has a recursive setTimeout being used as a polling timeout, then the fakeAsync task queue can never empty during a flush. Each time a task is removed and executed, it adds a new one to the queue indefinitely. This is why we may run into the following error.

Error: flush failed after reaching the limit of 20 tasks. Does your code use a polling timeout?

If we run into this situation, we may have more success with the async and await approach. Here's the code with fakeAsync first:

it('should filter rows by quickFilterText using fakeAsync auto', fakeAsync(() => {
// Setup grid and start aut detecting changes, run async tasks and have HTML auto updated
fixture.autoDetectChanges()
flush();
// Validate full set of data is displayed
validateState({ gridRows: 1000, displayedRows: 1000, templateRows: 1000 })
// Update the filter text input, auto detect changes updates the grid input
quickFilterDE.nativeElement.value = 'Germany'
quickFilterDE.nativeElement.dispatchEvent(new Event('input'));
// Run async tasks, with auto detect then updating HTML
flush()
// Validate correct number of rows are shown for our filter text
validateState({ gridRows: 68, displayedRows: 68, templateRows: 68 })
}))

Let's now re-write our test to work with async and await.

it('should filter rows by quickFilterText (async version)', (async () => {
// Grid is created
expect(component.grid).toBeUndefined()
fixture.detectChanges()
expect(component.grid.api).toBeDefined()
// At this point in the test we see that the async callback onModelUpdated has not run
validateState({ gridRows: 1000, displayedRows: 0, templateRows: 0 })
// We wait for the fixture to be stable which allows all the asynchronous code to run.
await fixture.whenStable()
// Callbacks have now completed and our component property has been updated
validateState({ gridRows: 1000, displayedRows: 1000, templateRows: 0 })
// Run change detection to update the template
fixture.detectChanges()
validateState({ gridRows: 1000, displayedRows: 1000, templateRows: 1000 })
// Now let's test that updating the filter text input does filter the grid data.
// Set the filter to Germany
quickFilterDE.nativeElement.value = 'Germany'
quickFilterDE.nativeElement.dispatchEvent(new Event('input'));
// We force change detection to run which applies the update to our <ag-grid-angular [quickFilterText] Input.
fixture.detectChanges()
// Async tasks have not run yet
validateState({ gridRows: 68, displayedRows: 1000, templateRows: 1000 })
// Again we wait for the asynchronous code to complete
await fixture.whenStable()
validateState({ gridRows: 68, displayedRows: 68, templateRows: 1000 })
// Force template to update
fixture.detectChanges()
// Final test state achieved.
validateState({ gridRows: 68, displayedRows: 68, templateRows: 68 })
}))

As we may have noticed, the structure of the test is very similar, and we've just basically replaced flush with await fixture.whenStable. However, under the hood, these tests are running in very different ways, so this will not be a straight swap in many other examples.

Here's a concise version using autoDetectChanges , which is our shortest working test so far. It is also conceptually the most simple to understand and hides a lot of the complexity from the tester.

it('should filter rows by quickFilterText (async version)', (async () => {
// Run initial change detection and start watching for changes
fixture.autoDetectChanges()
// Wait for all the async task to complete before running validation
await fixture.whenStable()
validateState({ gridRows: 1000, displayedRows: 1000, templateRows: 1000 })
// Set the filter to Germany
quickFilterDE.nativeElement.value = 'Germany'
quickFilterDE.nativeElement.dispatchEvent(new Event('input'));
// Wait for callbacks to run
await fixture.whenStable()
// Changes automatically applied
validateState({ gridRows: 68, displayedRows: 68, templateRows: 68 })
}))

Complete test application code

Find the complete application with tests in the Github repo: StephenCooper/async-angular-testing

Run test

To test the application that we created, click on "Run" button.

<div class="example-wrapper">
  <div class="example-header">
    <input type="text" id="quickFilter" placeholder="Filter..." [(ngModel)]="quickFilterText">
    <div id="numberOfRows">Number of rows: {{displayedRows}} for {{quickFilterText}}</div>
  </div>
  <ag-grid-angular #myGrid style="width: 100%; height: 100%;" class="ag-theme-alpine" [columnDefs]="columnDefs"
    [rowData]="rowData" [quickFilterText]="quickFilterText" (modelUpdated)="onModelUpdated($event)"></ag-grid-angular>
</div>

Conclusion

We've taken a step-by-step walkthrough of an asynchronous Angular test. We explained how to write the test with async / await, starting with first principles and then showing how to take advantage of autoDetectChanges.