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.
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.
We will test how to filter our data to a specific country of interest. Our test will validate that:
The reason for choosing this application is that it contains asynchronous code making it virtually impossible to test synchronously.
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();}}
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:
displayedRows
{{ displayedRows }}
bindingWe'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 countexpect(component.grid.api.getDisplayedRowCount()).withContext('api.getDisplayedRowCount').toEqual(gridRows)// Validate the component property displayedRowsexpect(component.displayedRows).withContext('component.displayedRows').toEqual(displayedRows)// Validate the rendered html content that the user would seeexpect(rowNumberDE.nativeElement.innerHTML).withContext('<div> {{displayedRows}} </div>').toContain("Number of rows: " + templateRows)}
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 fixturefixture = TestBed.createComponent(AppComponent);component = fixture.componentInstance;let compDebugElement = fixture.debugElement;// Get a reference to the quickFilter input and rendered templatequickFilterDE = 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.
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 componentexpect(component.grid).toBeUndefined()// Our first call to detectChanges, causes the grid to be createdfixture.detectChanges()// Grid has now been createdexpect(component.grid.api).toBeDefined()// Run change detection to update templatefixture.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.
We'll cover the async/await
approach for writing our test that handles the asynchronous grid behavior.
We can test our application using the built-in async
andawait
syntax 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 updatedfixture.autoDetectChanges()flush();// Validate full set of data is displayedvalidateState({ gridRows: 1000, displayedRows: 1000, templateRows: 1000 })// Update the filter text input, auto detect changes updates the grid inputquickFilterDE.nativeElement.value = 'Germany'quickFilterDE.nativeElement.dispatchEvent(new Event('input'));// Run async tasks, with auto detect then updating HTMLflush()// Validate correct number of rows are shown for our filter textvalidateState({ 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 createdexpect(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 runvalidateState({ 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 updatedvalidateState({ gridRows: 1000, displayedRows: 1000, templateRows: 0 })// Run change detection to update the templatefixture.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 GermanyquickFilterDE.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 yetvalidateState({ gridRows: 68, displayedRows: 1000, templateRows: 1000 })// Again we wait for the asynchronous code to completeawait fixture.whenStable()validateState({ gridRows: 68, displayedRows: 68, templateRows: 1000 })// Force template to updatefixture.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 changesfixture.autoDetectChanges()// Wait for all the async task to complete before running validationawait fixture.whenStable()validateState({ gridRows: 1000, displayedRows: 1000, templateRows: 1000 })// Set the filter to GermanyquickFilterDE.nativeElement.value = 'Germany'quickFilterDE.nativeElement.dispatchEvent(new Event('input'));// Wait for callbacks to runawait fixture.whenStable()// Changes automatically appliedvalidateState({ gridRows: 68, displayedRows: 68, templateRows: 68 })}))
Find the complete application with tests in the Github repo: StephenCooper/async-angular-testing
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>