If we are testing an Angular application, then at some point, we'll be required to test asynchronous behavior. In this Answer, we'll demonstrate how to write an asynchronous test with fakeAsync
. We'll explain each step in detail to give the understanding and confidence to write your own asynchronous tests.
We'll be testing an application that uses AG Grid. Our application displays a table of Olympic medal winners and also provides users with a text box to filter the medal winners by any field.
We are going to test that we can 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 have a text input box that is bound to the quickFilterText
property of our component. We display the current number of rows in our template and we 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's quickly explain the assertion helper function we'll use. 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 }}
binding.We 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 do not 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 thebeforeEach
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 asynchronous and is still in the Javascript event queue when we reach the assertion statement, i.e it has not run yet.
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 correctly handle asynchronous callbacks.
We are going to cover the fakeAsync
approach for writing our test that handles the asynchronous grid behavior.
fakeAsync
test utility As asynchronous code is very common, Angular provides us with the fakeAsync test utility. It enables us to control the flow of time and when asynchronous tasks are executed with the methods tick()
and flush()
.
The high-level concept with fakeAsync
is that when the test comes to execute an asynchronous task, it is added into a time-based queue instead of being executed. As a developer, we can then choose when the tasks are run. If we want to run all the currently queued async tasks we call flush()
. As the name suggests this flushes all the queued tasks executing them as they are removed from the queue.
If we have code that uses a timeout, for example, setTimeout(() => {}, 500)
, then this will be added to the fake async queue with a time delay of 500. We can use the tick
function to advance time by a set amount. This will walk through the queue and execute tasks that are scheduled before this time delay. Tick gives us more control over how many tasks are removed from the queue as compared to flush.
We can see the following line of code fixture.detectChanges()
in a lot of Angular tests. This enables you to control when change detection is run. As part of change detection, Input bindings receive their updated values and HTML templates are re-rendered with updated component values. Each of these is important when you want to validate that code is working correctly. In the test code below, we will highlight why we are required to call fixture.detectChanges()
at multiple stages.
fakeAsync
We'll now walk through the full fakeAsync
test to validate that our application correctly filters data and updates the number of displayed rows.
The first thing to do is wrap our test body in fakeAsync
. This causes all async functions to be patched so that we can control their execution.
import { fakeAsync, flush } from '@angular/core/testing';it('should filter rows by quickFilterText', fakeAsync(() => {...}))
At the start of our test, our application component has been created but it has not been initialized, ngOnInit
has not run. This means that our <ag-grid-angular>
component has not been created yet. To validate this, we can test that the grid is undefined.
The first call to fixture.detectChanges()
will create the grid and pass the component values to the grid via its @Inputs
. When working with fakeAsync
, ensure the first call to fixture.detectChanges()
is within the test body and not in a beforeEach
section. This is vital as it means that during the construction of the grid all async function calls are correctly patched.
// At the start of the test the grid is undefinedexpect(component.grid).toBeUndefined()// Initialise our app component which creates our gridfixture.detectChanges()// Validate that the grid has now been createdexpect(component.grid.api).toBeDefined()
Next, we validate that the internal grid model is correct. It should have 1000 rows. At this point, the asynchronous grid callbacks have not run, such as the (modelUpdated) @Output
has not been fired. This is why the internal grid state has 1000 rows, but the component and template still have 0 values.
// Validate the synchronous grid setup code has been completed but not any async updatesvalidateState({ gridRows: 1000, displayedRows: 0, templateRows: 0 })
To run the callbacks, that are currently in the fake task queue, we call flush()
. This executes all the async tasks that were added during the initialization of the grid and also any others that are created during the flush itself until the task queue is empty. Async tasks may create new async tasks as they are executed. By default, flush()
will attempt to drain the queue of these newly added calls up to a default limit of 20 turns. If for some reason your async tasks trigger other async tasks more than 20 times, you can increase this limit by passing it to flush, such as flush(100)
.
// Flush all async tasks from the queueflush();
Now the component has its displayedRows
property updated by the (modelUpdated)
event handler. However, this is not reflected in the template as change detection has not yet run. For the rendered template to reflect the updated component property we need to trigger change detection.
Our test state is now consistent. The internal grid model, component data, and renderer template all correctly show 1000 rows before any filtering is applied.
// Validate that our component property has now been updated by the onModelUpdated callbackvalidateState({ gridRows: 1000, displayedRows: 1000, templateRows: 0 })// Force the template to be updatedfixture.detectChanges()// Component state is stable and consistentvalidateState({ gridRows: 1000, displayedRows: 1000, templateRows: 1000 })
Now it's time to enter text into the filter. We set the filter value to 'Germany' and fire the input event which is required for ngModel
to react to the filter change.
At this point, the text input has been updated but the grid input binding, [quickFilterText]="quickFilterText"
, has not been updated as that requires change detection to run. This is why even the internal grid model still reports 1000 rows after the filter change.
// Mimic user entering GermanyquickFilterDE.nativeElement.value = 'Germany'quickFilterDE.nativeElement.dispatchEvent(new Event('input'));// Input [quickFilterText]="quickFilterText" has not been updated yet so grid is not filteredvalidateState({ gridRows: 1000, displayedRows: 1000, templateRows: 1000 })
We now run change detection which passes the text 'Germany' to the grid input [quickFilterText]="quickFilterText"
. We then validate that the internal number of rows has been reduced to 68 as the grid filters asynchronously. However, the displayedRows
property has not been updated as grid callbacks are asynchronous and sitting in the task queue.
// Run change detection to push new filter value into the grid componentfixture.detectChanges()// Grid uses filter value to update its internal modelvalidateState({ gridRows: 68, displayedRows: 1000, templateRows: 1000 })
We now flush
our async task queue which causes the event handler (modelUpdated)
to fire and update our component's displayedRows
property. We then run change detection to update the template with the new value.
Our component test state is once again stable and we can validate our quick filter and model update logic is correct.
//flush all the asynchronous callbacks.flush()// Component property is updated as the callback has now runvalidateState({ gridRows: 68, displayedRows: 68, templateRows: 1000 })// Run change detection to reflect the changes in our templatefixture.detectChanges()validateState({ gridRows: 68, displayedRows: 68, templateRows: 68 })
Here is a more concise version of the test without all the intermediary validation steps. Hopefully, it is now clear why we have this repeating pattern of detectChanges
-> flush
-> detectChanges
. In both cases, you can think of it as updating component inputs, running async tasks, and then updating the template with the resulting values.
autodetect
changesNow that we understand the data flow in the test above, we can simplify the test by using fixture.autoDetectChanges()
.
Note: When
autodetect
is true, the test fixture callsdetectChanges
immediately after creating the component. Then it listens for pertinent zone events and callsdetectChanges
accordingly. The default is false. Testers who prefer fine control over test behaviour tend to keep it false.
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 })}))
As you can see, writing the test with auto-detect hides a lot of complexity and so may be a good starting point for your asynchronous tests. Just be aware you will lose precise control of when change detection is run.
Full application code along with tests is available at StephenCooper/async-angular-testing.
We have taken a step-by-step walkthrough of an asynchronous Angular test. We explained how to write the test with fakeAsync
, starting with first principles and then showing how to take advantage of autoDetectChanges
. We hope that you will have found this breakdown useful and it will enable you to confidently write tests for your applications' asynchronous behavior.
Free Resources