发布于 2026-01-06 5 阅读
0

理解 Angular 中的异步测试 编写异步测试

了解 Angular 中的异步测试

编写异步测试

如果您正在测试 Angular 应用程序,那么您迟早会需要测试异步行为。本文将演示如何使用 `and`fakeAsyncasync`/ ` 编写异步测试await。我们将详细解释每个步骤,帮助您理解并自信地编写自己的异步测试。

完整的应用程序代码和测试用例可在StephenCooper/async-angular-testing获取。

我们的测试申请

我们将测试一个使用AG Grid 的应用程序。该应用程序会显示一个奥运奖牌获得者表格,并提供一个文本框,供用户按任意字段筛选奖牌获得者。您可以在这里亲自体验该应用程序。

通过文本输入筛选数据

我们将测试能否将数据筛选到特定国家/地区。我们的测试将验证以下几点:

  1. 我们的网格显示了全部 1000 行,我们的应用程序也显示了 1000 行。
  2. 输入文本“德国”后,表格应筛选出仅显示德国运动员的行。
  3. 我们的应用程序行数应更新为 68(德国运动员人数)。

选择此应用程序的原因是它包含异步代码,这使得同步测试几乎不可能。

应用程序代码

我们的应用程序中有一个文本输入框,它绑定到quickFilterText组件的属性。我们在模板中显示当前行数,并将该行数传递quickFilterText给网格组件,以便它可以根据需要筛选行。

<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

行数将通过网格回调函数保持最新(modelUpdated)。每次网格模型更新时(包括执行筛选操作时),都会触发此回调函数。

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

测试助手

在开始测试之前,让我先快速解释一下我们将要使用的断言辅助函数。这个函数将帮助我们深入了解测试的内部运作机制,尤其是在我们开始使用异步回调时。

该函数验证以下内容:

  • 内部网格状态
  • 组件变量的状态,即displayedRows
  • {{ displayedRows }}绑定的渲染 HTML 输出

我们将看到,由于异步回调,这些值不会同步更新,如果需要运行变更检测来更新属性。

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

.withContext()是一个有用的 Jasmine 方法,可以在值不相等时提供更清晰的错误信息。

配置测试模块

测试的第一部分是配置测试模块。它需要 AG GridAgGridModule和 AngularFormModule提供支持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

这里需要注意的一点是,beforeEach我们特意没有将某些内容包含fixture.detectChanges()在设置逻辑中。这样做是为了确保所有测试尽可能地相互隔离,并允许我们在组件初始化之前对其进行断言。最后,也是最重要的一点,在使用时,fakeAsync我们不希望组件在测试上下文之外创建fakeAsync。否则,可能会导致各种测试不一致和错误。

请注意,我们不会在 beforeEach 方法内部运行 fixture.detectChanges()!这在测试异步代码时可能会导致很多问题!

同步测试失败

为了证明我们需要异步处理此测试,让我们先尝试同步编写测试。

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

虽然看起来这个测试应该通过,但实际上并没有。我们预期在每次validateState调用断言时,应该都能正确显示 1000 行。然而,只有内部网格模型有 1000 行,而组件属性和渲染输出都显示为 0。这导致了以下测试错误:

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

这是因为网格设置代码是同步运行的,因此在我们的断言之前就已经完成了。然而,组件属性仍然为 0,因为网格回调是异步的,当我们执行到断言语句时,它仍在 JavaScript 事件队列中,也就是说它尚未执行。

如果您不熟悉 Javascript 事件队列以及异步任务的运行方式,那么阅读以下文章可能会对您有所帮助:

由于我们甚至无法同步验证测试的初始状态,显然我们需要更新测试以正确处理异步回调。

编写异步测试

我们将介绍两种编写测试用例来处理异步网格行为的方法:

  • 使用fakeAsync
  • 使用async await

伪异步

由于异步代码非常常见,Angular 为我们提供了fakeAsync测试工具。它使我们能够控制时间流逝,并通过方法tick()和来控制异步任务的执行时间flush()

其基本概念是fakeAsync,当测试需要执行异步任务时,该任务会被添加到基于时间的队列中,而不是立即执行。作为开发人员,我们可以选择何时运行这些任务。如果我们想要运行所有当前已排队的异步任务,可以调用 `flush()` 方法flush()。顾名思义,此方法会刷新所有已排队的任务,并在任务从队列中移除时执行它们。

如果我们的代码使用了超时机制,例如,setTimeout(() => {}, 500)那么它会被添加到伪异步队列中,并延迟 500 毫秒。我们可以使用 ` ticktick` 函数将时间推进一个设定值。这将遍历队列,并执行在此时间延迟之前调度的任务。与 `flush` 相比,`tick` 函数可以让我们更精确地控制从队列中移除的任务数量。

值得注意的是,还有一个flushMicrotasks()函数。例如,您可以参考这篇文章《Angular Testing Flush vs FlushMiscrotasks》,flushMicrotasks了解何时可以使用该函数而不是其他方法flush

控制我们测试中的变化检测

你会在很多 Angular 测试中看到以下代码行fixture.detectChanges()。它允许你控制何时运行变更检测。作为变更检测的一部分,输入绑定会接收更新后的值,并且 HTML 模板会使用更新后的组件值重新渲染。当你想要验证代码是否正常工作时,这些步骤都非常重要。在下面的测试代码中,我们将重点说明为什么需要fixture.detectChanges()在多个阶段调用它。

使用 FakeAsync 进行快速筛选测试

现在我们将完成整个fakeAsync测试,以验证我们的应用程序是否能够正确筛选数据并更新显示的行数。

测试设置

首先,我们需要将测试主体包裹在 `.` 标签中fakeAsync。这样可以对所有异步函数进行修补,从而控制它们的执行。

import { fakeAsync, flush } from '@angular/core/testing';

it('should filter rows by quickFilterText', fakeAsync(() => {
    ...
}))
Enter fullscreen mode Exit fullscreen mode

在测试开始时,我们的应用程序组件已经创建,但尚未初始化,也就是说,它还没有ngOnInit运行。这意味着我们的<ag-grid-angular>组件尚未创建。为了验证这一点,我们可以测试网格是否未定义。

首次调用 `Grid`fixture.detectChanges()会创建网格并通过其 `@Inputs` 注解将组件值传递给网格。使用 `Grid` 时,请fakeAsync确保首次调用fixture.detectChanges()位于测试主体内,而不是`<section> ` 部分beforeEach。这一点至关重要,因为它意味着在网格构建过程中,所有异步函数调用都会被正确处理。

// 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

接下来,我们验证内部网格模型是否正确。它应该有 1000 行。此时,异步网格回调尚未执行,即 `(modelUpdated)` 注解尚未触发。这就是为什么内部网格状态有 1000 行,但组件和模板的值仍然为 0 的原因。

// 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

要运行当前位于伪任务队列中的回调函数,我们需要调用 `flush` 函数flush()。该函数会执行网格初始化期间添加的所有异步任务,以及在刷新操作期间创建的任何其他任务,直到任务队列为空。异步任务在执行过程中可能会创建新的异步任务。默认情况下,`flush` 函数flush()会尝试清空队列中这些新添加的调用,但默认限制为 20 回合。如果由于某种原因,您的异步任务触发其他异步任务超过 20 次,您可以通过向 `flush` 函数传递参数来增加此限制。例如flush(100)

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

现在,组件的displayedRows属性已通过(modelUpdated)事件处理程序更新。但是,由于变更检测尚未运行,因此模板中尚未反映此更新。要使渲染后的模板反映更新后的组件属性,我们需要触发变更检测。

我们的测试状态现已一致。内部网格模型、组件数据和渲染器模板在应用任何筛选条件之前均能正确显示 1000 行数据。

// 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

更新筛选文本

现在该在筛选器中输入文本了。我们将筛选器值设置为“德国”,并触发输入事件,以便ngModel对筛选器更改做出反应。

此时,文本输入框已更新,但网格输入绑定 [quickFilterText]="quickFilterText", 尚未更新,因为这需要运行变更检测。这就是为什么即使在筛选器更改后,内部网格模型仍然报告 1000 行的原因。

// 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

现在我们运行变更检测,将文本“德国”传递给网格输入框 [quickFilterText]="quickFilterText"。然后我们验证内部行数是否已减少到 68,因为网格是异步筛选的。但是,displayedRows由于网格回调是异步的,并且位于任务队列中,因此该属性尚未更新。

// 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

我们现在flush使用异步任务队列,它会触发事件处理程序(modelUpdated)并更新组件的displayedRows属性。然后,我们运行变更检测来使用新值更新模板。

我们的组件测试状态再次稳定,我们可以验证我们的快速筛选和模型更新逻辑是正确的。

//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

完整测试代码

这里提供一个更简洁的测试版本,省略了所有中间验证步骤。希望现在您能明白为什么会出现这种重复的detectChanges-> flush->模式detectChanges。在这两种情况下,您都可以将其理解为:更新组件输入、运行异步任务,然后使用结果值更新模板。

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

使用自动检测更改

现在我们已经了解了上述测试中的数据流,我们可以通过使用fixture.autoDetectChanges()来简化测试

当 `autodetect` 设置为 `true` 时,测试装置会在创建组件后立即调用 `detectChanges` 函数。然后,它会监听相关的区域事件,并据此调用相应的 `detectChanges` 函数。
默认值为 `false`。喜欢精细控制测试行为的测试人员通常会将其设置为 `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

如您所见,使用自动检测编写测试隐藏了很多复杂性,因此可能是编写异步测试的一个很好的起点。但请注意,您将无法精确控制变更检测的运行时间。

使用 async/await

另一种测试应用程序的方法是使用内置的语法async以及awaitfixture 方法fixture.whenStable()。有时,这种方法编写异步测试会更简单,因为您无需担心手动运行异步任务。

值得注意的是,有些情况下无法编写测试fakeAsync。如果任何已执行的代码使用了递归的 `setTimeout` 作为轮询超时,那么在刷新期间,`fakeAsync` 任务队列永远不会清空。每次移除并执行一个任务时,都会无限期地向队列中添加一个新任务。这就是您可能会遇到以下错误的原因。

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

如果你遇到这种情况,采用async这种await方法可能会更成功。

现在让我们重写测试以使其与async和 一起使用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

您可能已经注意到,测试的结构非常相似,我们只是简单地替换了flush原来的代码await fixture.whenStable。然而,这些测试的底层运行方式截然不同,因此在许多其他示例中,这种替换并不能直接应用。

以下是一个简洁版本,autoDetectChanges也是我们目前为止最短的有效测试版本。它在概念上也最容易理解,并且对测试人员隐藏了许多复杂性。

  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

完整测试应用程序代码

您可以在 GitHub 代码库中找到完整的应用程序,包括测试:StephenCooper/async-angular-testing

结论

我们一步步地讲解了异步 Angular 测试。我们解释了如何使用 `and`fakeAsync和 ` async/`编写测试await,从基本原理入手,然后展示了如何利用 `and` 的优势autoDetectChanges。希望这篇讲解对您有所帮助,让您能够自信地为应用程序的异步行为编写测试。


Stephen Cooper - AG Grid高级开发人员

文章来源:https://dev.to/angular/understanding-async-tests-in-angular-f8n