Understanding async tests in Angular

scooperdev

Stephen Cooper

Posted on April 25, 2022

Understanding async tests in Angular

If you are testing an Angular application, then at some point, you will be required to test asynchronous behaviour. In this article, we will demonstrate how to write an asynchronous test with both fakeAsync and async/await. We will explain each step in detail to give you the understanding and confidence to write your own asynchronous tests.

Full application code along with tests is available at StephenCooper/async-angular-testing

Application for our Test

We will 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. You can try the application out for yourself here.

Filter data by text input

We are going to test that we can filter our data to a specific country of interest. Our test will validate that:

  1. Our grid shows the full set of 1000 rows and our application displays the row count of 1000.
  2. Upon entering the text "Germany" the grid should filter the rows to only show German athletes
  3. Our 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 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>
Enter fullscreen mode Exit fullscreen mode

The number of rows will be kept up to date by using the grid callback (modelUpdated). 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();
  }
}

Enter fullscreen mode Exit fullscreen mode

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, i.e displayedRows
  • rendered HTML output of the {{ displayedRows }} binding

We will 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)
}
Enter fullscreen mode Exit fullscreen mode

The .withContext() is a helpful Jasmine method to give us clearer error messages when values are not equal.

Configuring 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'))
});
Enter fullscreen mode Exit fullscreen mode

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 initialised. 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 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 })
  }))
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

This happens because the grid setup code runs synchronously and so has 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, i.e it has not 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 correctly handle asynchronous callbacks.

Writing an Async Test

We are going to cover two approaches for writing our test that handles the asynchronous grid behaviour:

  • Using fakeAsync
  • Using async await

FakeAsync

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.

It is worth noting that there is also a flushMicrotasks() function. For an example of when you might use flushMicrotasks instead of flush take a look at this article Angular Testing Flush vs FlushMiscrotasks.

Controlling Change Detection in our test

You will 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.

Quick Filter Test with FakeAsync

We will now walk through the full fakeAsync test to validate that our application correctly filters data and updates the number of displayed rows.

Test setup

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(() => {
    ...
}))
Enter fullscreen mode Exit fullscreen mode

At the start of our test, our application component has been created but it has not been initialised. i.e 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 undefined
expect(component.grid).toBeUndefined()

// Initialise our app component which creates our grid
fixture.detectChanges()

// Validate that the grid has now been created
expect(component.grid.api).toBeDefined()
Enter fullscreen mode Exit fullscreen mode

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. i.e the (modelUpdated) @Output has not 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 updates
validateState({ gridRows: 1000, displayedRows: 0, templateRows: 0 })
Enter fullscreen mode Exit fullscreen mode

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 initialisation 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. Be 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. i.e flush(100).

// Flush all async tasks from the queue
flush();
Enter fullscreen mode Exit fullscreen mode

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 callback
validateState({ gridRows: 1000, displayedRows: 1000, templateRows: 0 })
// Force the template to be updated
fixture.detectChanges()
// Component state is stable and consistent
validateState({ gridRows: 1000, displayedRows: 1000, templateRows: 1000 })
Enter fullscreen mode Exit fullscreen mode

Update Filter Text

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 Germany
quickFilterDE.nativeElement.value = 'Germany'
quickFilterDE.nativeElement.dispatchEvent(new Event('input'));

// Input [quickFilterText]="quickFilterText" has not been updated yet so grid is not filtered
validateState({ gridRows: 1000, displayedRows: 1000, templateRows: 1000 })
Enter fullscreen mode Exit fullscreen mode

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 component
fixture.detectChanges()
// Grid uses filter value to update its internal model
validateState({ gridRows: 68, displayedRows: 1000, templateRows: 1000 })
Enter fullscreen mode Exit fullscreen mode

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 that our quick filter and model update logic is correct.

//flush all the asynchronous callbacks.
flush()
// Component property is updated as the callback has now run
validateState({ gridRows: 68, displayedRows: 68, templateRows: 1000 })

// Run change detection to reflect the changes in our template
fixture.detectChanges()
validateState({ gridRows: 68, displayedRows: 68, templateRows: 68 })
Enter fullscreen mode Exit fullscreen mode

Full Test Code

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.

it('should filter rows by quickFilterText using fakeAsync', fakeAsync(() => {

    // Setup grid, run async tasks, update HTML
    fixture.detectChanges()
    flush();
    fixture.detectChanges()

    // Validate full set of data is displayed
    validateState({ gridRows: 1000, displayedRows: 1000, templateRows: 1000 })

    // Update the filter text input
    quickFilterDE.nativeElement.value = 'Germany'
    quickFilterDE.nativeElement.dispatchEvent(new Event('input'));

    // Push filter text to grid, run async tasks, update HTML
    fixture.detectChanges()
    flush()
    fixture.detectChanges()

    // Validate correct number of rows are shown for our filter text
    validateState({ gridRows: 68, displayedRows: 68, templateRows: 68 })

  }))
Enter fullscreen mode Exit fullscreen mode

Using Auto Detect Changes

Now that we understand the data flow in the test above we can simplify the test by using fixture.autoDetectChanges().

When autodetect is true, the test fixture calls detectChanges immediately after creating the component. Then it listens for pertinent zone events and calls detectChanges 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 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 })
  }))
Enter fullscreen mode Exit fullscreen mode

As you can see, writing the test with auto-detect hides a lot of complexity and so maybe a good starting point for your asynchronous tests. Just be aware you will lose precise control of when change detection is run.

Using async await

Another way that we can test our application is to use the built-in async and await 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 you may run into the following error.

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

If you run into this situation you may have more success with the async and await approach.

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 })
  }))
Enter fullscreen mode Exit fullscreen mode

As you may have noticed the structure of the test is very similar and we have 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 is 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 })
  }))
Enter fullscreen mode Exit fullscreen mode

Complete Test Application Code

You can find the full application, complete with tests in the Github repo: StephenCooper/async-angular-testing

Conclusion

We have taken a step by step walkthrough of an asynchronous Angular test. We explained how to write the test with both fakeAsync and async / await, starting with first principles and then showing how to take advantage of autoDetectChanges. I hope that you will have found this breakdown useful and it will enable you to confidently write tests for your applications' asynchronous behaviour.


Stephen Cooper - Senior Developer at AG Grid

💖 💪 🙅 🚩
scooperdev
Stephen Cooper

Posted on April 25, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related