Angular9-高级教程-十二-

132 阅读56分钟

Angular9 高级教程(十二)

原文:Pro Angular 9

协议:CC BY-NC-SA 4.0

二十八、使用动画

在这一章中,我描述了 Angular 动画系统,它使用数据绑定来动画化 HTML 元素,以反映应用状态的变化。从广义上讲,动画在 Angular 应用中有两个作用:强调内容的变化和平滑它们。

当内容以对用户不明显的方式变化时,强调变化是很重要的。在示例应用中,在编辑产品时使用“上一个”和“下一个”按钮会更改数据字段,但不会创建任何其他可视更改,这会导致用户可能不会注意到的转换。动画可以用来吸引人们对这种变化的注意,帮助用户注意到动作的结果。

平滑变化可以使应用更易于使用。当用户单击 Edit 按钮开始编辑产品时,示例应用显示的内容会以一种令人不快的方式切换。使用动画来减缓过渡可以帮助提供内容变化的上下文感,并使其不那么突兀。在这一章中,我将解释动画系统是如何工作的,以及如何用它来吸引用户的注意力或减少突然的过渡。表 28-1 将 Angular 动画放在上下文中。

表 28-1。

将 Angular 动画放在上下文中

|

问题

|

回答

| | --- | --- | | 它们是什么? | 动画系统可以改变 HTML 元素的外观来反映应用状态的变化。 | | 它们为什么有用? | 如果使用得当,动画可以让应用更容易使用。 | | 它们是如何使用的? | 动画使用特定于平台的模块中定义的函数来定义,使用@Component装饰器中的animations属性来注册,并使用数据绑定来应用。 | | 有什么陷阱或限制吗? | 主要的限制是只有少数浏览器完全支持 Angular 动画,因此,不能依赖它在 Angular 支持其其他功能的所有浏览器上正常工作。 | | 还有其他选择吗? | 唯一的选择是不要让应用动起来。 |

表 28-2 总结了本章内容。

表 28-2。

章节总结

|

问题

|

解决办法

|

列表

| | --- | --- | --- | | 将用户的注意力吸引到元素状态的转换上 | 应用动画 | 1–9 | | 动画显示从一个元素状态到另一个元素状态的变化 | 使用元素过渡 | 9–14 | | 并行执行动画 | 使用动画组 | Fifteen | | 在多个动画中使用相同的样式 | 使用通用样式 | Sixteen | | 动画元素的位置或大小 | 使用元素转换 | Seventeen | | 使用动画应用 CSS 框架样式 | 使用 DOM 和 CSS APIs | 18, 19 |

准备示例项目

在这一章中,我继续使用 exampleApp 项目,它最初是在第二十二章中创建的,从那以后一直是每一章的焦点。以下各节中的更改为本章中描述的功能准备了示例应用。

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

禁用 HTTP 延迟

本章的第一个准备步骤是禁用添加到异步 HTTP 请求的延迟,如清单 28-1 所示。

import { Injectable, Inject, InjectionToken } from "@angular/core";
import { HttpClient, HttpHeaders } from "@angular/common/http";
import { Observable, throwError } from "rxjs";
import { Product } from "./product.model";
import { catchError, delay } from "rxjs/operators";

export const REST_URL = new InjectionToken("rest_url");

@Injectable()
export class RestDataSource {
    constructor(private http: HttpClient,
        @Inject(REST_URL) private url: string) { }

    getData(): Observable<Product[]> {
        return this.sendRequest<Product[]>("GET", this.url);
    }

    saveProduct(product: Product): Observable<Product> {
        return this.sendRequest<Product>("POST", this.url, product);
    }

    updateProduct(product: Product): Observable<Product> {
        return this.sendRequest<Product>("PUT",
            `${this.url}/${product.id}`, product);
    }

    deleteProduct(id: number): Observable<Product> {
        return this.sendRequest<Product>("DELETE", `${this.url}/${id}`);
    }

    private sendRequest<T>(verb: string, url: string, body?: Product)
        : Observable<T> {

        let myHeaders = new HttpHeaders();
        myHeaders = myHeaders.set("Access-Key", "<secret>");
        myHeaders = myHeaders.set("Application-Names", ["exampleApp", "proAngular"]);

        return this.http.request<T>(verb, url, {
            body: body,
            headers: myHeaders
        })
        //.pipe(delay(5000))
        .pipe(catchError((error: Response) =>
            throwError(`Network Error: ${error.statusText} (${error.status})`)));
    }
}

Listing 28-1.Disabling the Delay in the rest.datasource.ts File in the src/app/model Folder

简化表格模板和路由配置

本章中的许多示例适用于产品表中的元素。本章的最后准备工作是简化表格组件的模板,这样我就可以专注于清单中的少量内容。

清单 28-2 显示了简化的模板,它删除了产生 HTTP 和路由错误的按钮,以及计算类别或产品的按钮和出口元素。该清单还删除了允许按类别过滤表格的按钮。

<table class="table table-sm table-bordered table-striped">
    <tr>
        <th>ID</th><th>Name</th><th>Category</th><th>Price</th><th></th>
    </tr>
    <tr *ngFor="let item of getProducts()">
        <td>{{item.id}}</td>
        <td>{{item.name}}</td>
        <td>{{item.category}}</td>
        <td>{{item.price | currency:"USD" }}</td>
        <td class="text-center">
            <button class="btn btn-danger btn-sm mr-1"
                    (click)="deleteProduct(item.id)">
                Delete
            </button>
            <button class="btn btn-warning btn-sm"
                [routerLink]="['/form', 'edit', item.id]">
                Edit
            </button>
        </td>
    </tr>
</table>
<div class="p-2 text-center">
    <button class="btn btn-primary m-1" routerLink="/form/create">
        Create New Product
    </button>
</div>

Listing 28-2.Simplifying the Template in the table.component.html File in the src/app/core Folder

清单 28-3 更新了应用的 URL 路由配置,这样路由就不会以已经从表格组件的模板中删除的 outlet 元素为目标。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { NotFoundComponent } from "./core/notFound.component";
import { ProductCountComponent } from "./core/productCount.component";
import { CategoryCountComponent } from "./core/categoryCount.component";
import { ModelResolver } from "./model/model.resolver";
import { TermsGuard } from "./terms.guard";
import { UnsavedGuard } from "./core/unsaved.guard";
import { LoadGuard } from "./load.guard";

const routes: Routes = [
    {
        path: "form/:mode/:id", component: FormComponent,
        canDeactivate: [UnsavedGuard]
    },
    { path: "form/:mode", component: FormComponent, canActivate: [TermsGuard] },
    { path: "table", component: TableComponent },
    { path: "table/:category", component: TableComponent },
    { path: "", redirectTo: "/table", pathMatch: "full" },
    { path: "**", component: NotFoundComponent }
]

export const routing = RouterModule.forRoot(routes);

Listing 28-3.Updating the Routing Configuration in the app.routing.ts File in the src/app Folder

打开一个新的命令提示符,导航到exampleApp文件夹,运行以下命令启动提供 RESTful web 服务器的服务器:

npm run json

打开一个单独的命令提示符,导航到exampleApp文件夹,运行以下命令启动 Angular 开发工具:

ng serve

打开一个新的浏览器窗口并导航到http://localhost:4200以查看如图 28-1 所示的内容。

img/421542_4_En_28_Fig1_HTML.jpg

图 28-1。

运行示例应用

Angular 动画入门

与大多数 Angular 特性一样,最好从一个例子开始,这个例子将让我介绍动画是如何工作的,以及它如何适应 Angular 功能的其余部分。在接下来的部分中,我创建了一个基本的动画,它将影响产品表中的行。一旦您看到了基本特性是如何工作的,我将深入研究每个不同配置选项的细节,并深入解释它们是如何工作的。

但是首先,我将向应用添加一个select元素,允许用户选择一个类别。当选择一个类别时,该类别中产品的表格行将显示为两种样式中的一种,如表 28-3 所述。

表 28-3。

动画示例的样式

|

描述

|

风格

| | --- | --- | | 该产品属于所选类别。 | 表格行将有绿色背景和较大的文本。 | | 该产品不在所选类别中。 | 表格行将具有红色背景和较小的文本。 |

启用动画模块

动画特性包含在它们自己的模块中,这些模块必须导入到应用的根模块中,如清单 28-4 所示。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ModelModule } from "./model/model.module";
import { CoreModule } from "./core/core.module";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { MessageModule } from "./messages/message.module";
import { MessageComponent } from "./messages/message.component";
import { routing } from "./app.routing";
import { AppComponent } from "./app.component";
import { TermsGuard } from "./terms.guard"
import { LoadGuard } from "./load.guard";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";

@NgModule({
    imports: [BrowserModule, CoreModule, MessageModule, routing,
              BrowserAnimationsModule],
    declarations: [AppComponent],
    providers: [TermsGuard, LoadGuard],
    bootstrap: [AppComponent]
})
export class AppModule { }

Listing 28-4.Importing the Animation Module in the app.module.ts File in the src/app Folder

创建动画

为了开始制作动画,我在src/app/core文件夹中创建了一个名为table.animations.ts的文件,并添加了清单 28-5 中所示的代码。

import { trigger, style, state, transition, animate } from "@angular/animations";

export const HighlightTrigger = trigger("rowHighlight", [
    state("selected", style({
        backgroundColor: "lightgreen",
        fontSize: "20px"
    })),
    state("notselected", style({
        backgroundColor: "lightsalmon",
        fontSize: "12px"
    })),
    transition("selected => notselected", animate("200ms")),
    transition("notselected => selected", animate("400ms"))
]);

Listing 28-5.The Contents of the table.animations.ts File in the src/app/core Folder

用于定义动画的语法可以是密集的,并且依赖于在@angular/animations模块中定义的一组函数。在接下来的几节中,我从顶部开始,一步步深入到细节,解释清单中使用的每个动画构建块。

Tip

如果以下部分中描述的所有构建模块不能立即理解,也不要担心。这是一个功能区域,只有当您看到所有部分是如何组合在一起时,它才开始变得更有意义。

定义样式组

动画系统的核心是样式组,它是一组将应用于 HTML 元素的 CSS 样式属性和值。使用style函数定义样式组,该函数接受 JavaScript 对象文字,提供属性名和值之间的映射,如下所示:

...
style({
    backgroundColor: "lightgreen",
    fontSize: "20px"
})
...

这个样式组告诉 Angular 将背景颜色设置为lightgreen并将字体大小设置为 20 像素。

CSS Property Name Conventions

使用style函数时,有两种方法可以指定 CSS 属性。您可以使用 JavaScript 属性命名约定,这样设置元素背景颜色的属性就被指定为backgroundColor(所有单词,无连字符,后续单词大写)。这是我在清单 28-5 中使用的约定:

...
style({
    backgroundColor: "lightgreen",
    fontSize: "20px"
})),
...

或者,您可以使用 CSS 约定,其中相同的属性表示为background-color(全部小写,单词之间有连字符)。如果使用 CSS 格式,则必须用引号将属性名括起来,以防止 JavaScript 试图将连字符解释为算术运算符,如下所示:

...
state("green", style({
    "background-color": "lightgreen",
    "font-size": "20px"
})),
...

只要保持一致,使用哪种命名约定并不重要。在撰写本文时,如果您混合和匹配属性名称约定,Angular 将无法正确应用样式。为了获得一致的结果,选择一个命名约定,并将其用于您在整个应用中设置的所有样式属性。

定义元素状态

Angular 需要知道何时需要对一个元素应用一组样式。这是通过定义元素状态来完成的,元素状态提供了一个名称,通过该名称可以引用样式集。元素状态是使用state函数创建的,该函数接受名称和应该与之关联的样式集。这是清单 28-5 中定义的两种元素状态之一:

...
state("selected", style({
    backgroundColor: "lightgreen",
    fontSize: "20px"
})),
...

列表中有两种状态,称为selectednotselected,它们将对应于表格行描述的产品是否在用户选择的类别中。

定义状态转换

当一个 HTML 元素处于使用state函数创建的状态之一时,Angular 将应用该状态的样式组中的 CSS 属性。transition函数用于告诉 Angular 应该如何应用新的 CSS 属性。清单 28-5 中有两个转换。

...
transition("selected => notselected", animate("200ms")),
transition("notselected => selected", animate("400ms"))
...

传递给transition函数的第一个参数告诉 Angular 该指令适用于哪个状态。参数是一个指定两种状态的字符串和一个表示它们之间关系的箭头。有两种箭头可供选择,如表 28-4 所示。

表 28-4。

动画过渡箭头类型

|

|

例子

|

描述

| | --- | --- | --- | | => | selected => notselected | 这个箭头指定了两个状态之间的单向转换,例如当元素从selected状态移动到notselected状态时。 | | <=> | selected <=> notselected | 该数组指定了两种状态之间的双向转换,例如当元素从selected状态转移到notselected状态,以及从notselected状态转移到selected状态时。 |

清单 28-5 中定义的转换使用单向箭头告诉 Angular 当一个元素从selected状态转移到notselected状态以及从notselected状态转移到selected状态时,它应该如何响应。

transition函数的第二个参数告诉 Angular 当状态发生变化时应该采取什么动作。animate函数告诉 Angular 在由两个元素状态定义的 CSS 样式集中定义的属性之间逐渐过渡。传递给清单 28-5 中的animate函数的参数指定了这个逐渐过渡应该花费的时间,要么 200 毫秒,要么 400 毫秒。

Guidance for Applying Animations

开发人员在应用动画时经常会忘乎所以,导致应用让用户感到沮丧。动画应该少用,应该简单,应该快速。使用动画来帮助用户理解你的应用,而不是作为展示你艺术技巧的工具。用户,尤其是公司业务线应用,必须重复执行相同的任务,过多和过长的动画只会碍事。

我深受这种倾向的困扰,如果不加检查,我的应用的行为就像拉斯维加斯的老虎机。我遵循两条规则来控制问题。首先,我连续 20 次执行应用中的主要任务或工作流。在示例应用中,这可能意味着创建 20 个产品,然后编辑 20 个产品。我会删除或缩短我发现自己必须等待完成的任何动画,然后才能继续下一步。

第二条规则是,我不会在开发过程中禁用动画。当我在开发一个特性的时候,注释掉一个动画是很有诱惑力的,因为我在写代码的时候会执行一系列的快速测试。但是任何妨碍我的动画也会妨碍用户,所以我把动画留在原地并调整它们——通常减少它们的持续时间——直到它们变得不那么突兀和烦人。

当然,你不必遵循我的规则,但重要的是要确保动画对用户有帮助,而不是快速工作的障碍或令人分心的烦恼。

定义触发器

最后一项工作是动画触发器,它将元素状态和转换打包,并分配一个可用于在组件中应用动画的名称。触发器是使用trigger函数创建的,如下所示:

...
export const HighlightTrigger = trigger("rowHighlight", [...])
...

第一个参数是触发器的名称,在本例中是rowHighlight,第二个参数是应用触发器时可用的状态和转换的数组。

应用动画

一旦定义了动画,就可以通过使用@Component装饰器的animations属性将它应用到一个或多个组件。清单 28-6 将清单 28-5 中定义的动画应用到表格组件,并添加一些支持动画所需的附加特性。

import { Component, Inject } from "@angular/core";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { ActivatedRoute } from "@angular/router";
import { HighlightTrigger } from "./table.animations";

@Component({
    selector: "paTable",
    templateUrl: "table.component.html",
    animations: [HighlightTrigger]
})
export class TableComponent {
    category: string = null;

    constructor(private model: Model, activeRoute: ActivatedRoute) {
        activeRoute.params.subscribe(params => {
            this.category = params["category"] || null;
        })
    }

    getProduct(key: number): Product {
        return this.model.getProduct(key);
    }

    getProducts(): Product[] {
        return this.model.getProducts()
            .filter(p => this.category == null || p.category == this.category);
    }

    get categories(): string[] {
        return this.model.getProducts()
            .map(p => p.category)
            .filter((category, index, array) => array.indexOf(category) == index);
    }

    deleteProduct(key: number) {
        this.model.deleteProduct(key);
    }

    highlightCategory: string = "";

    getRowState(category: string): string {
        return this.highlightCategory == "" ? "" :
            this.highlightCategory == category ? "selected" : "notselected";
    }
}

Listing 28-6.Applying an Animation in the table.component.ts File in the src/app/core Folder

属性被设置为一个触发器数组。您可以内联定义动画,但它们会很快变得复杂,使整个组件难以阅读,这就是为什么我使用一个单独的文件并从中导出一个常量值,然后将它赋给animations属性。

其他变化是在用户选择的类别和将分配给元素的动画状态之间提供映射。将使用一个select元素来设置highlightCategory属性的值,并在getRowState方法中使用该值来告诉 Angular 清单 28-7 中定义的动画状态应该根据产品类别进行分配。如果产品在所选择的类别中,那么该方法返回selected;否则,它返回notselected。如果用户没有选择类别,则返回空字符串。

最后一步是将动画应用到组件的模板,告诉 Angular 哪些元素将被动画化,如清单 28-7 所示。这个清单还添加了一个select元素,它使用ngModel绑定来设置组件的highlightCategory属性的值。

<div class="form-group bg-info text-white p-2">
    <label>Category</label>
    <select [(ngModel)]="highlightCategory" class="form-control">
        <option value="">None</option>
        <option *ngFor="let category of categories">
            {{category}}
        </option>
    </select>
</div>
<table class="table table-sm table-bordered table-striped">
    <tr>
        <th>ID</th><th>Name</th><th>Category</th><th>Price</th><th></th>
    </tr>
    <tr *ngFor="let item of getProducts()"
            [@rowHighlight]="getRowState(item.category)">
        <td>{{item.id}}</td>
        <td>{{item.name}}</td>
        <td>{{item.category}}</td>
        <td>{{item.price | currency:"USD" }}</td>
        <td class="text-center">
            <button class="btn btn-danger btn-sm mr-1"
                    (click)="deleteProduct(item.id)">
                Delete
            </button>
            <button class="btn btn-warning btn-sm"
                [routerLink]="['/form', 'edit', item.id]">
                Edit
            </button>
        </td>
    </tr>
</table>
<div class="p-2 text-center">
    <button class="btn btn-primary m-1" routerLink="/form/create">
        Create New Product
    </button>
</div>

Listing 28-7.Applying an Animation in the table.component.html File in the src/app/core Folder

使用特殊的数据绑定将动画应用于模板,这些数据绑定将动画触发器与 HTML 元素相关联。绑定的目标告诉 Angular 要应用哪个动画触发器,绑定的表达式告诉 Angular 如何计算出元素应该被分配到哪个状态,如下所示:

...
<tr *ngFor="let item of getProducts()" [@rowHighlight]="getRowState(item.category)">
...

绑定的目标是动画触发器的名称,以字符@为前缀,表示动画绑定。这个绑定告诉 Angular 它应该将rowHighlight触发器应用到tr元素。表达式告诉 Angular 它应该调用组件的getRowState方法,使用item.category值作为参数,计算出元素应该被分配到哪个状态。图 28-2 展示了动画数据绑定的剖析,以供快速参考。

img/421542_4_En_28_Fig2_HTML.jpg

图 28-2。

动画数据绑定的剖析

测试动画效果

上一节中的更改在产品表上方添加了一个select元素。要查看动画的效果,重新启动 Angular development tools,请求http://localhost:4200,然后从窗口顶部的列表中选择 Soccer。Angular 将使用触发器来计算每个元素应该应用于哪个动画状态。足球类产品的表格行将被分配到selected状态,而其他行将被分配到notselected状态,产生如图 28-3 所示的效果。

img/421542_4_En_28_Fig3_HTML.jpg

图 28-3。

选择产品类别

新的样式突然被应用。要查看更平滑的过渡,请从列表中选择 Chess 类别,当 Chess 行被分配到selected状态而其他行被分配到notselected状态时,您将看到一个渐变动画。发生这种情况是因为动画触发器包含这些状态之间的转换,告诉 Angular 动画 CSS 样式的变化,如图 28-4 所示。早期的更改没有过渡,因此 Angular 默认立即应用新样式。

img/421542_4_En_28_Fig4_HTML.jpg

图 28-4。

动画状态之间的逐渐过渡

Tip

用一系列截图来捕捉动画的效果是不可能的,我最多能做的就是呈现一些中间状态。这是一个需要第一手实验来理解的特性。我鼓励你从 GitHub 下载本章的项目,并创建自己的动画。

要理解 Angular 动画系统,您需要理解用于定义和应用动画的不同构建块之间的关系,可以这样描述:

  1. 评估数据绑定表达式告诉 Angular 主体元素被分配到哪个动画状态。

  2. 数据绑定目标告诉 Angular 哪个动画目标定义了元素状态的 CSS 样式。

  3. 状态告诉 Angular 哪些 CSS 样式应该应用于元素。

  4. 转换告诉 Angular,当评估数据绑定表达式导致元素状态发生变化时,它应该如何应用 CSS 样式。

当你通读本章的其余部分时,记住这四点,你会发现动画系统更容易理解。

了解内置动画状态

动画状态用于定义动画的最终结果,将应用于元素的样式组合在一起,元素的名称可由动画触发器选择。Angular 提供了两种内置状态,使得管理元素的外观更加容易,如表 28-5 所述。

表 28-5。

内置动画状态

|

状态

|

描述

| | --- | --- | | * | 这是一种回退状态,如果元素不处于动画触发器定义的任何其他状态,将应用该状态。 | | void | 当元素不是模板的一部分时,它们处于 void 状态。例如,当ngIf指令的表达式计算为false时,主机元素处于void状态。该状态用于动画显示元素的添加和移除,如下一节所述。 |

星号(*字符)用于表示特殊状态,Angular 应用于不在动画触发器定义的任何其他状态中的元素。清单 28-8 为示例应用中的动画添加了回退状态。

import { trigger, style, state, transition, animate } from "@angular/animations";

export const HighlightTrigger = trigger("rowHighlight", [
    state("selected", style({
        backgroundColor: "lightgreen",
        fontSize: "20px"
    })),
    state("notselected", style({
        backgroundColor: "lightsalmon",
        fontSize: "12px"
    })),
    state("*", style({
        border: "solid black 2px"
    })),
    transition("selected => notselected", animate("200ms")),
    transition("notselected => selected", animate("400ms"))
]);

Listing 28-8.Using the Fallback State in the table.animations.ts File in the src/app/core Folder

在示例应用中,一旦用户用select元素选择了一个值,元素就只被分配到selectednotselected状态。后退状态定义了一个样式组,该样式组将应用于元素,直到它们进入其他状态之一,如图 28-5 所示。

img/421542_4_En_28_Fig5_HTML.jpg

图 28-5。

使用回退状态

了解元素转换

转场是动画系统的真正力量;它们告诉 Angular 它应该如何管理从一种状态到另一种状态的变化。在接下来的部分中,我将描述创建和使用转换的不同方式。

为内置状态创建转换

表 28-5 中描述的内置状态可用于转换。后退状态可以通过表示任何状态来简化动画配置,如清单 28-9 所示。

import { trigger, style, state, transition, animate } from "@angular/animations";

export const HighlightTrigger = trigger("rowHighlight", [
    state("selected", style({
        backgroundColor: "lightgreen",
        fontSize: "20px"
    })),
    state("notselected", style({
        backgroundColor: "lightsalmon",
        fontSize: "12px"
    })),
    state("*", style({
        border: "solid black 2px"
    })),
    transition("* => notselected", animate("200ms")),
    transition("* => selected", animate("400ms"))
]);

Listing 28-9.Using the Fallback State in the table.animations.ts File in the src/app/core Folder

清单中的转换告诉 Angular 如何处理从任何状态到notselectedselected状态的变化。

添加和移除动画元素

void状态用于定义当一个元素被添加到模板或从模板中删除时的转换,如清单 28-10 所示。

import { trigger, style, state, transition, animate } from "@angular/animations";

export const HighlightTrigger = trigger("rowHighlight", [
    state("selected", style({
        backgroundColor: "lightgreen",
        fontSize: "20px"
    })),
    state("notselected", style({
        backgroundColor: "lightsalmon",
        fontSize: "12px"
    })),
    state("void", style({
        opacity: 0
    })),
    transition("* => notselected", animate("200ms")),
    transition("* => selected", animate("400ms")),
    transition("void => *", animate("500ms"))
]);

Listing 28-10Using the Void State in the table.animations.ts File in the src/app/core Folder

这个清单包含了对void状态的定义,它将opacity属性设置为零,这使得元素透明,因此不可见。还有一个转换告诉 Angular 将从void状态到任何其他状态的变化制作成动画。效果是当浏览器逐渐增加不透明度值直到达到填充不透明度时,表格中的行淡入视图,如图 28-6 所示。

img/421542_4_En_28_Fig6_HTML.jpg

图 28-6。

动画元素添加

控制过渡动画

到目前为止,本章中的所有例子都使用了最简单形式的animate函数,它指定了两个状态之间的转换需要多长时间,如下所示:

...
transition("void => *", animate("500ms"))
...

通过提供初始延迟并指定如何计算样式属性的中间值,传递给animate方法的string参数可用于对过渡的动画方式进行更细粒度的控制。

EXPRESSING ANIMATION DURATIONS

动画的持续时间使用 CSS 时间值来表示,CSS 时间值是包含一个或多个数字的字符串值,后跟代表秒的s或代表毫秒的ms。例如,该值指定 500 毫秒的持续时间:

...
transition("void => *", animate("500ms"))
...

持续时间可以灵活地表示,相同的值可以表示为几分之一秒,如下所示:

...
transition("void => *", animate("0.5s"))
...

我的建议是在整个项目中坚持使用一套单位以避免混淆,尽管使用哪一套并不重要。

指定计时功能

timing 函数负责在转换过程中计算 CSS 属性的中间值。表 28-6 中描述了网络动画规范中定义的计时功能。

表 28-6。

动画计时功能

|

名字

|

描述

| | --- | --- | | linear | 该函数等量改变数值。这是默认设置。 | | ease-in | 此功能从随时间推移而增加的微小变化开始,从而产生一个缓慢启动并加速的动画。 | | ease-out | 该函数从随时间推移而减少的较大变化开始,导致动画快速开始,然后变慢。 | | ease-in-out | 这个函数从大的变化开始,变小直到中间点,之后又变大。结果是动画开始很快,中间变慢,最后又加速。 | | cubic-bezier | 该函数用于使用贝塞尔曲线创建中间值。详见 http://w3c.github.io/web-animations/#time-transformations 。 |

清单 28-11 将一个计时函数应用于示例应用中的一个转换。计时函数在animate函数的参数中的持续时间之后指定。

import { trigger, style, state, transition, animate } from "@angular/animations";

export const HighlightTrigger = trigger("rowHighlight", [
    state("selected", style({
        backgroundColor: "lightgreen",
        fontSize: "20px"
    })),
    state("notselected", style({
        backgroundColor: "lightsalmon",
        fontSize: "12px"
    })),
    state("void", style({
        opacity: 0
    })),
    transition("* => notselected", animate("200ms")),
    transition("* => selected", animate("400ms ease-in")),
    transition("void => *", animate("500ms"))
]);

Listing 28-11.Applying a Timing Function in the table.animations.ts File in the src/app/core Folder

指定初始延迟

可以向animate方法提供一个初始延迟,当有多个过渡同时执行时,该方法可用于错开动画。延迟被指定为传递给animate函数的参数中的第二个值,如清单 28-12 所示。

import { trigger, style, state, transition, animate } from "@angular/animations";

export const HighlightTrigger = trigger("rowHighlight", [
    state("selected", style({
        backgroundColor: "lightgreen",
        fontSize: "20px"
    })),
    state("notselected", style({
        backgroundColor: "lightsalmon",
        fontSize: "12px"
    })),
    state("void", style({
        opacity: 0
    })),
    transition("* => notselected", animate("200ms")),
    transition("* => selected", animate("400ms 200ms ease-in")),
    transition("void => *", animate("500ms"))
]);

Listing 28-12.Adding an Initial Delay in the table.animations.ts File in the src/app/core Folder

本例中的 200 毫秒延迟对应于元素转换到notselected状态时使用的动画的持续时间。其效果是,在selected元素被改变之前,改变选中的类别将显示返回到notselected状态的元素。

在过渡期间使用附加样式

animate函数可以接受一个样式组作为它的第二个参数,如清单 28-13 所示。在动画持续期间,这些样式会逐渐应用到主体元素。

import { trigger, style, state, transition, animate } from "@angular/animations";

export const HighlightTrigger = trigger("rowHighlight", [
    state("selected", style({
        backgroundColor: "lightgreen",
        fontSize: "20px"
    })),
    state("notselected", style({
        backgroundColor: "lightsalmon",
        fontSize: "12px"
    })),
    state("void", style({
        opacity: 0
    })),
    transition("* => notselected", animate("200ms")),
    transition("* => selected",
        animate("400ms 200ms ease-in",
            style({
                backgroundColor: "lightblue",
                fontSize: "25px"
            }))
    ),
    transition("void => *", animate("500ms"))
]);

Listing 28-13.Defining Transition Styles in the table.animations.ts File in the src/app/core Folder

这一改变的效果是,当一个元素转换到selected状态时,它的外观将被动画化,因此背景颜色将是lightblue,字体大小将是 25 像素。在动画结束时,由selected状态定义的样式将被一次应用,创建一个快照效果。

动画结束时外观的突然变化可能会令人不舒服。另一种方法是将transition函数的第二个参数改为动画数组。这定义了将按顺序应用于元素的多个动画,只要它没有定义样式组,最终的动画将用于过渡到由状态定义的样式。清单 28-14 使用这个特性向过渡添加两个动画,最后一个将应用由selected状态定义的样式。

import { trigger, style, state, transition, animate } from "@angular/animations";

export const HighlightTrigger = trigger("rowHighlight", [
    state("selected", style({
        backgroundColor: "lightgreen",
        fontSize: "20px"
    })),
    state("notselected", style({
        backgroundColor: "lightsalmon",
        fontSize: "12px"
    })),
    state("void", style({
        opacity: 0
    })),
    transition("* => notselected", animate("200ms")),
    transition("* => selected",
        [animate("400ms 200ms ease-in",
            style({
                backgroundColor: "lightblue",
                fontSize: "25px"
            })),
            animate("250ms", style({
                backgroundColor: "lightcoral",
                fontSize: "30px"
            })),
            animate("200ms")]
    ),
    transition("void => *", animate("500ms"))
]);

Listing 28-14.Using Multiple Animations in the table.animations.ts File in the src/app/core Folder

这个过渡中有三个动画,最后一个将应用由selected状态定义的样式。表 28-7 描述了动画的顺序。

表 28-7。

转换到选定状态时的动画序列

|

持续时间

|

样式属性和值

| | --- | --- | | 400 毫秒 | backgroundColor: lightblue; fontSize: 25px | | 250 毫秒 | backgroundColor: lightcoral; fontSize: 30px | | 200 毫秒 | backgroundColor: lightgreen; fontSize: 20px |

使用select元素选择一个类别来查看动画序列。图 28-7 显示了每个动画中的一帧。

img/421542_4_En_28_Fig7_HTML.jpg

图 28-7。

在过渡中使用多个动画

执行并行动画

Angular 能够同时执行动画,这意味着您可以在不同的时间段更改不同的 CSS 属性。并行动画被传递给group函数,如清单 28-15 所示。

import { trigger, style, state, transition, animate, group }
    from "@angular/animations";

export const HighlightTrigger = trigger("rowHighlight", [
    state("selected", style({
        backgroundColor: "lightgreen",
        fontSize: "20px"
    })),
    state("notselected", style({
        backgroundColor: "lightsalmon",
        fontSize: "12px"
    })),
    state("void", style({
        opacity: 0
    })),
    transition("* => notselected", animate("200ms")),
    transition("* => selected",
        [animate("400ms 200ms ease-in",
            style({
                backgroundColor: "lightblue",
                fontSize: "25px"
            })),
            group([
                animate("250ms", style({
                    backgroundColor: "lightcoral",
                })),
                animate("450ms", style({
                    fontSize: "30px"
                })),
            ]),
            animate("200ms")]
    ),
    transition("void => *", animate("500ms"))
]);

Listing 28-15.Performing Parallel Animations in the table.animations.ts File in the src/app/core Folder

该清单用一对并行动画替换了序列中的一个动画。属性backgroundColorfontSize的动画将同时开始,但持续时间不同。当组中的两个动画都完成时,Angular 将移动到最终动画,该动画将针对状态中定义的样式。

了解动画样式组

Angular 动画的结果是将元素置于新状态,并使用关联样式组中的属性和值来设置样式。在这一节中,我将解释一些使用样式组的不同方式。

Tip

并不是所有的 CSS 属性都可以被动画化,在那些可以被动画化的属性中,有些被浏览器处理得更好。根据经验,最好的结果是使用其值可以很容易地插值的属性来实现,这允许浏览器提供元素状态之间的平滑过渡。这意味着使用值为颜色或数值的属性,如背景、文本和字体颜色、不透明度、元素大小和边框,通常会获得良好的结果。参见https:// www.w3.org/TR/css3-transitions/#animatable-properties 了解可用于动画系统的完整属性列表。

在可重用组中定义通用样式

当您创建更复杂的动画并在整个应用中应用它们时,您会不可避免地发现需要在多个地方应用一些常见的 CSS 属性值。style函数可以接受一个对象数组,所有这些对象被组合在一起以创建组中的整体样式集。这意味着您可以通过定义包含公共样式的对象并在多个样式组中使用它们来减少重复,如清单 28-16 所示。(为了保持示例简单,我还删除了上一节中定义的样式序列。)

import { trigger, style, state, transition, animate, group } from "@angular/animations";

const commonStyles = {
    border: "black solid 4px",
    color: "white"
};

export const HighlightTrigger = trigger("rowHighlight", [
    state("selected", style([commonStyles, {
        backgroundColor: "lightgreen",
        fontSize: "20px"
    }])),
    state("notselected", style([commonStyles, {
        backgroundColor: "lightsalmon",
        fontSize: "12px",
        color: "black"
    }])),
    state("void", style({
        opacity: 0
    })),
    transition("* => notselected", animate("200ms")),
    transition("* => selected", animate("400ms 200ms ease-in")),
    transition("void => *", animate("500ms"))
]);

Listing 28-16.Defining Common Styles in the table.animations.ts File in the src/app/core Folder

commonStyles对象定义了bordercolor属性的值,并在一个数组中与常规样式对象一起传递给style函数。Angular 按顺序处理样式对象,这意味着您可以通过在后面的对象中重新定义样式值来覆盖样式值。例如,notselected州的第二个样式对象用一个自定义值覆盖了color属性的公共值。结果是两种动画状态的样式都包含了border属性的公共值,而selected状态的样式也使用了color属性的公共值,如图 28-8 所示。

img/421542_4_En_28_Fig8_HTML.jpg

图 28-8。

定义公共属性

使用元素转换

到目前为止,本章中的所有例子都有影响元素外观的动画属性,如背景色、字体大小或不透明度。动画还可以用于应用 CSS 元素变换效果,这些效果用于移动、调整大小、旋转或倾斜元素。这些效果是通过在样式组中定义一个transform属性来应用的,如清单 28-17 所示。

import { trigger, style, state, transition, animate, group }
    from "@angular/animations";

const commonStyles = {
    border: "black solid 4px",
    color: "white"
};

export const HighlightTrigger = trigger("rowHighlight", [
    state("selected", style([commonStyles, {
        backgroundColor: "lightgreen",
        fontSize: "20px"
    }])),
    state("notselected", style([commonStyles, {
        backgroundColor: "lightsalmon",
        fontSize: "12px",
        color: "black"
    }])),
    state("void", style({
        transform: "translateX(-50%)"
    })),
    transition("* => notselected", animate("200ms")),
    transition("* => selected", animate("400ms 200ms ease-in")),
    transition("void => *",  animate("500ms"))
]);

Listing 28-17.Using an Element Transformation in the table.animations.ts File in the src/app/core Folder

属性的值是 ??,它告诉 Angular 沿着 x 轴移动元素长度的 50%。transform属性已经被应用到void状态,这意味着当元素被添加到模板中时,它将被用在元素上。该动画包含从void状态到任何其他状态的转换,并告诉 Angular 在 500 毫秒内将这些变化制作成动画。结果是新元素最初会向左移动,然后在半秒钟内滑回到默认位置,如图 28-9 所示。

img/421542_4_En_28_Fig9_HTML.jpg

图 28-9。

变换元素

表 28-8 描述了可以应用于元素的一组转换。

表 28-8。

CSS 转换函数

|

功能

|

描述

| | --- | --- | | translateX(offset) | 这个函数沿着 x 轴移动元素。移动量可以指定为百分比或长度(以像素或其他 CSS 长度单位之一表示)。正值将元素向右平移,负值向左平移。 | | translateY(offset) | 这个函数沿着 y 轴移动元素。 | | translate(xOffset, yOffset) | 该函数沿两个轴移动元素。 | | scaleX(amount) | 这个函数沿着 x 轴缩放元素。缩放尺寸表示为元素常规尺寸的一部分,因此0.5将元素缩小到原始宽度的 50 %, 2.0 将使宽度加倍。 | | scaleY(amount) | 此函数沿 y 轴缩放元素。 | | scale(xAmount, yAmount) | 该函数沿两个轴缩放元素。 | | rotate(angle) | 这个函数顺时针旋转元素。旋转量用 Angular 表示,如90deg3.14rad。 | | skewX(angle) | 该函数将元素沿 x 轴倾斜一个指定的 Angular,表达方式与rotate函数相同。 | | skewY(angle) | 该函数将元素沿 y 轴倾斜一个指定的 Angular,表达方式与rotate函数相同。 | | skew(xAngle, yAngle) | 该函数沿两个轴倾斜元素。 |

Tip

通过用空格分隔,可以在一个transform属性中应用多个转换,就像这样:transform: "scale(1.1, 1.1) rotate(10deg)"

应用 CSS 框架样式

如果您正在使用类似 Bootstrap 的 CSS 框架,您可能希望将类应用于元素,而不是必须定义属性组。没有直接使用 CSS 类的内置支持,但是文档对象模型(DOM)和 CSS 对象模型(CSSOM)提供 API 访问来检查已加载的 CSS 样式表,并查看它们是否适用于 HTML 元素。为了获得由类定义的样式集,我在src/app/core文件夹中创建了一个名为animationUtils.ts的文件,并添加了清单 28-18 中所示的代码。

Caution

这种技术可能需要在使用大量复杂样式表的应用中进行大量处理,并且您可能需要调整代码以适应不同的浏览器和不同的 CSS 框架。

export function getStylesFromClasses(names: string | string[],
        elementType: string = "div") : { [key: string]: string | number } {

    let elem = document.createElement(elementType);
    (typeof names == "string" ? [names] : names).forEach(c => elem.classList.add(c));

    let result = {};

    for (let i = 0; i < document.styleSheets.length; i++) {
        let sheet = document.styleSheets[i] as CSSStyleSheet;
        let rules = sheet.rules || sheet.cssRules;
        for (let j = 0; j < rules.length; j++) {
            if (rules[j].type == CSSRule.STYLE_RULE) {
                let styleRule = rules[j] as CSSStyleRule;
                if (elem.matches(styleRule.selectorText)) {
                    for (let k = 0; k < styleRule.style.length; k++) {
                        result[styleRule.style[k]] =
                            styleRule.style[styleRule.style[k]];
                    }
                }
            }
        }
    }
    return result;
}

Listing 28-18.The Contents of the animationUtils.ts File in the src/app/core Folder

getStylesFromClass方法接受单个类名或类名数组以及它们应该应用的元素类型,默认为一个div元素。创建一个元素并将其分配给类,然后检查 CSS 样式表中定义的哪个 CSS 规则适用于它。每个匹配样式的样式属性被添加到一个对象中,该对象可用于创建 Angular 动画样式组,如清单 28-19 所示。

import { trigger, style, state, transition, animate, group }
    from "@angular/animations";
import { getStylesFromClasses } from "./animationUtils";

export const HighlightTrigger = trigger("rowHighlight", [
    state("selected", style(getStylesFromClasses(["bg-success", "h2"]))),
    state("notselected", style(getStylesFromClasses("bg-info"))),
    state("void", style({
        transform: "translateX(-50%)"
    })),
    transition("* => notselected", animate("200ms")),
    transition("* => selected", animate("400ms 200ms ease-in")),
    transition("void => *", animate("500ms"))
]);

Listing 28-19.Using Bootstrap Classes in the table.animations.ts File in the src/app/core Folder

selected状态使用 Bootstrap bg-successh2类中定义的样式,notselected状态使用 Bootstrap bg-info类定义的样式,产生如图 28-10 所示的结果。

img/421542_4_En_28_Fig10_HTML.jpg

图 28-10。

在 Angular 动画中使用 CSS 框架样式

摘要

我在本章中描述了 Angular 动画系统,并解释了它如何使用数据绑定来动画化应用状态的变化。在下一章,我将描述 Angular 提供的支持单元测试的特性。

二十九、Angular 单元测试

在这一章中,我描述了 Angular 为单元测试组件和指令提供的工具。一些有 Angular 的构建块,比如管道和服务,可以使用我在本章开始时设置的基本测试工具进行独立测试。组件(在较小程度上还有指令)与它们的宿主元素和模板内容有着复杂的交互,并且需要特殊的特性。表 29-1 将 Angular 单元测试放在上下文中。

表 29-1。

放置 Angular 单元测试上下文

|

问题

|

回答

| | --- | --- | | 这是什么? | Angular 组件和指令需要特殊的测试支持,以便它们与应用基础设施的其他部分的交互可以被隔离和检查。 | | 为什么有用? | 独立的单元测试能够评估实现组件或指令的类所提供的基本逻辑,但不能捕获与宿主元素、服务、模板和其他重要 Angular 特征的交互。 | | 如何使用? | Angular 提供了一个测试平台,允许创建一个真实的应用环境,然后用于执行单元测试。 | | 有什么陷阱或限制吗? | 像 Angular 的大部分内容一样,单元测试工具很复杂。可能需要花费一些时间和精力,才能轻松地编写和运行单元测试,并且确定已经隔离了应用中正确的测试部分。 | | 还有其他选择吗? | 如上所述,您不必对项目进行单元测试。但是如果你确实想进行单元测试,那么你将需要使用本章中描述的 Angular 特性。 |

Deciding Whether to Unit Test

单元测试是一个有争议的话题。本章假设你想做单元测试,并向你展示如何设置工具,并把它们应用到 Angular 组件和指令中。这不是对单元测试的介绍,我也没有努力说服持怀疑态度的读者单元测试是值得的。如果你想了解单元测试,这里有一篇很好的文章: https://en.wikipedia.org/wiki/Unit_testing

我喜欢单元测试,我也在自己的项目中使用它——但并不是所有的项目,也不像你所期望的那样始终如一。我倾向于专注于为我知道很难编写的特性和功能编写单元测试,这些特性和功能很可能是部署中的错误来源。在这些情况下,单元测试有助于我思考如何最好地实现我需要的东西。我发现仅仅考虑我需要测试什么就有助于产生关于潜在问题的想法,这是在我开始处理实际的错误和缺陷之前。

也就是说,单元测试是一种工具,而不是宗教,只有你自己知道你需要多少测试。如果你不觉得单元测试有用,或者如果你有更适合你的不同的方法论,那么不要仅仅因为它是时髦的就觉得你需要单元测试。(然而,如果你没有更好的方法论,你根本没有在测试,那么你很可能是在让用户发现你的 bug,这很少是理想的。)

表 29-2 总结了本章内容。

表 29-2。

章节总结

|

问题

|

解决办法

|

列表

| | --- | --- | --- | | 对组件执行基本测试 | 初始化测试模块并创建组件的实例。如果组件有外部模板,则必须执行额外的编译步骤。 | 1–9, 11–13 | | 测试组件的数据绑定 | 使用DebugElement类来查询组件的模板。 | Ten | | 测试组件对事件的响应 | 使用 debug 元素触发事件。 | 14–16 | | 测试组件的输出属性 | 订阅由组件创建的EventEmitter。 | 17, 18 | | 测试组件的输入属性 | 创建一个测试组件,它的模板应用被测组件。 | 19, 20 | | 执行依赖异步操作的测试 | 使用whenStable方法推迟测试,直到操作效果处理完毕。 | 21, 22 | | 测试指令 | 创建一个测试组件,其模板应用测试中的指令。 | 23, 24 |

准备示例项目

我继续使用前面章节中的 exampleApp 项目。我需要一个简单的目标来关注单元测试,所以清单 29-1 改变了路由配置,从而默认加载了ondemand特性模块。

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { NotFoundComponent } from "./core/notFound.component";
import { ProductCountComponent } from "./core/productCount.component";
import { CategoryCountComponent } from "./core/categoryCount.component";
import { ModelResolver } from "./model/model.resolver";
import { TermsGuard } from "./terms.guard";
import { UnsavedGuard } from "./core/unsaved.guard";
import { LoadGuard } from "./load.guard";

const routes: Routes = [
    {
        path: "ondemand",
        loadChildren: () => import("./ondemand/ondemand.module")
                                .then(m => m.OndemandModule)
    },
    { path: "", redirectTo: "/ondemand", pathMatch: "full" }
]

export const routing = RouterModule.forRoot(routes);

Listing 29-1.Changing the Routing Configuration in the app.routing.ts File in the src/app Folder

这个模块包含一些简单的组件,我将用它们来演示不同的单元测试特性。为了保持应用显示的内容简单,清单 29-2 整理了特性模块中顶层组件显示的模板。

<div class="container-fluid">
    <div class="row">
        <div class="col-12 p-2">
            <router-outlet></router-outlet>
        </div>
    </div>
    <div class="row">
        <div class="col-6 p-2">
            <router-outlet name="left"></router-outlet>
        </div>
        <div class="col-6 p-2">
            <router-outlet name="right"></router-outlet>
        </div>
    </div>
</div>
<button class="btn btn-secondary m-2" routerLink="/ondemand">Normal</button>
<button class="btn btn-secondary m-2" routerLink="/ondemand/swap">Swap</button>

Listing 29-2.Simplifying the ondemand.component.html File in the src/app/ondemand Folder

打开一个新的命令提示符,导航到exampleApp文件夹,运行以下命令启动提供 RESTful web 服务器的服务器:

npm run json

本章没有直接使用 RESTful web 服务,但是运行它可以防止错误。打开一个单独的命令提示符,导航到exampleApp文件夹,运行以下命令启动 Angular 开发工具:

ng serve

打开一个新的浏览器窗口并导航到http://localhost:4200以查看如图 29-1 所示的内容。

img/421542_4_En_29_Fig1_HTML.jpg

图 29-1。

运行示例应用

运行简单的单元测试

当使用ng new命令创建一个新项目时,单元测试所需的所有包和工具都会基于 Jasmine 测试框架进行安装。为了创建一个简单的单元测试来确认一切正常,我创建了src/app/tests文件夹,并在其中添加了一个名为app.component.spec.ts的文件,其内容如清单 29-3 所示。单元测试的命名约定使得测试应用于哪个文件变得显而易见。

describe("Jasmine Test Environment", () => {
    it("is working", () => expect(true).toBe(true));
});

Listing 29-3.Replacing the Contents of the app.component.spec.ts File in the src/app/tests Folder

我很快会解释使用 Jasmine API 的基础知识,您可以暂时忽略语法。使用新的命令提示符,导航到exampleApp文件夹,并运行以下命令:

ng test

这个命令启动 Karma test runner,它打开一个新的浏览器选项卡,内容如图 29-2 所示。

img/421542_4_En_29_Fig2_HTML.jpg

图 29-2。

启动 Karma 测试运行程序

浏览器窗口用于运行测试,但是重要的信息被写出到用于启动测试工具的命令提示符中,在那里您会看到如下消息:

Chrome 80.0.3987 (Windows 10.0.0): Executed 1 of 1 SUCCESS (0.118 secs / 0.005 secs)

这表明项目中的单个单元测试已经被成功地定位和执行。每当您对项目中的一个 JavaScript 文件进行更新时,单元测试就会被定位和执行,任何问题都会被写到命令提示符中。为了展示一个错误是什么样子,清单 29-4 改变了单元测试,使它失败。

describe("Jasmine Test Environment", () => {
    it("is working", () => expect(true).toBe(false));
});

Listing 29-4.Making a Unit Test Fail in the app.component.spec.ts File in the src/app/tests Folder

该测试将会失败,并会产生以下输出,该输出指出了失败的测试以及出错的原因:

Chrome 80.0.3987 (Windows 10.0.0) Jasmine Test Environment is working FAILED
        Error: Expected true to be false.
...
Chrome 80.0.3987 (Windows 10.0.0): Executed 1 of 1 (1 FAILED) ERROR
(0.125 secs / 0.118 secs)

和茉莉一起工作

Jasmine 提供的 API 将 JavaScript 方法链接在一起以定义单元测试。你可以在 http://jasmine.github.io 找到 Jasmine 的完整文档,但是表 29-3 描述了 Angular 测试最有用的函数。

表 29-3。

有用的茉莉花方法

|

名字

|

描述

| | --- | --- | | describe(description, function) | 此方法用于对一组相关的测试进行分组。 | | beforeEach(function) | 此方法用于指定在每个单元测试之前执行的任务。 | | afterEach(function) | 此方法用于指定在每个单元测试之后执行的测试。 | | it(description, function) | 此方法用于执行测试操作。 | | expect(value) | 该方法用于识别测试结果。 | | toBe(value) | 此方法指定测试的预期值。 |

你可以在清单 29-4 中看到如何使用表 29-3 中的方法来创建单元测试。

...
describe("Jasmine Test Environment", () => {
    it("is working", () => expect(true).toBe(false));
});
...

您还可以看到测试失败的原因,因为已经使用了expecttoBe方法来检查truefalse是否相等。因为情况并非如此,所以测试失败。

toBe方法不是评估单元测试结果的唯一方法。表 29-4 显示了 Angular 提供的其他评估方法。

表 29-4。

有用的 Jasmine 评估方法

|

名字

|

描述

| | --- | --- | | toBe(value) | 此方法断言结果与指定的值相同(但不必是同一个对象)。 | | toEqual(object) | 此方法断言结果是与指定值相同的对象。 | | toMatch(regexp) | 此方法断言结果匹配指定的正则表达式。 | | toBeDefined() | 这个方法断言结果已经被定义。 | | toBeUndefined() | 此方法断言结果尚未定义。 | | toBeNull() | 该方法断言结果为空。 | | toBeTruthy() | 该方法断言结果是真实的,如第十二章所述。 | | toBeFalsy() | 该方法断言结果为 falsy,如第十二章所述。 | | toContain(substring) | 此方法断言结果包含指定的子字符串。 | | toBeLessThan(value) | 此方法断言结果小于指定值。 | | toBeGreaterThan(value) | 此方法断言结果大于指定值。 |

清单 29-5 展示了如何在测试中使用这些评估方法,取代前一节中失败的测试。

describe("Jasmine Test Environment", () => {
    it("test numeric value", () => expect(12).toBeGreaterThan(10));
    it("test string value", () => expect("London").toMatch("^Lon"));
});

Listing 29-5.Replacing the Unit Test in the app.component.spec.ts File in the src/app/tests Folder

当您将更改保存到文件中时,测试将被执行,结果将显示在命令提示符中。

测试 Angular 组件

不能孤立地测试 Angular 应用的构建块,因为它们依赖于 Angular 和项目的其他部分提供的底层特性,包括它包含的服务、指令、模板和模块。因此,测试一个构建块(比如一个组件)意味着使用 Angular 提供的测试实用程序来重新创建足够的应用,让组件正常工作,以便可以对其执行测试。在这一节中,我将介绍在OnDemand特性模块中的FirstComponent类上执行单元测试的过程,该模块是在第二十七章中添加到项目中的。提醒一下,下面是组件的定义:

import { Component } from "@angular/core";

@Component({
    selector: "first",
    template: `<div class="bg-primary text-white p-2">First Component</div>`
})
export class FirstComponent { }

这个组件非常简单,它本身没有要测试的功能,但是它足以演示测试过程是如何应用的。

使用 TestBed 类

Angular 单元测试的核心是一个名为TestBed的类,它负责模拟 Angular 应用环境,以便可以执行测试。表 29-5 描述了TestBed方法提供的最有用的方法,所有这些方法都是静态的,如第六章所述。

表 29-5。

有用的试验台方法

|

名字

|

描述

| | --- | --- | | configureTestingModule | 该方法用于配置 Angular 测试模块。 | | createComponent | 此方法用于创建组件的实例。 | | compileComponents | 该方法用于编译组件,如“使用外部模板测试组件”一节所述。 |

configureTestingModule方法用于配置测试中使用的 Angular 模块,使用由@NgModel装饰器支持的相同属性。就像在真实的应用中一样,一个组件不能在单元测试中使用,除非它已经被添加到模块的declarations属性中。这意味着大多数单元测试的第一步是配置测试模块。为了演示,我在src/app/tests文件夹中添加了一个名为first.component.spec.ts的文件,其内容如清单 29-6 所示。

import { TestBed } from "@angular/core/testing";
import { FirstComponent } from "../ondemand/first.component";

describe("FirstComponent", () => {

    beforeEach(() => {
        TestBed.configureTestingModule({
            declarations: [FirstComponent]
        });
    });
});

Listing 29-6.The Contents of the first.component.spec.ts File in the src/app/tests Folder

@angular/core/testing模块中定义了TestBed类,configureTestingModule接受一个对象,该对象的declarations属性告诉测试模块将使用FirstComponent类。

Tip

注意在beforeEach函数中使用了TestBed类。如果你试图在这个函数之外使用TestBed,你会看到一个关于使用Promise s 的错误

下一步是创建组件的一个新实例,以便它可以在测试中使用。这是使用createComponent方法完成的,如清单 29-7 所示。

import { TestBed, ComponentFixture} from "@angular/core/testing";
import { FirstComponent } from "../ondemand/first.component";

describe("FirstComponent", () => {

    let fixture: ComponentFixture<FirstComponent>;
    let component: FirstComponent;

    beforeEach(() => {
        TestBed.configureTestingModule({
            declarations: [FirstComponent]
        });
        fixture = TestBed.createComponent(FirstComponent);
        component = fixture.componentInstance;
    });

    it("is defined", () => {
        expect(component).toBeDefined()
    });
});

Listing 29-7.Creating a Component in the first.component.spec.ts File in the src/app/tests Folder

createComponent方法的参数告诉测试床它应该实例化哪个组件类型,在本例中是FirstComponent。结果是一个ComponentFixture<FirstComponent>对象,它使用表 29-6 中描述的方法和属性提供了测试组件的特性。

表 29-6。

有用的组件夹具方法和属性

|

名字

|

描述

| | --- | --- | | componentInstance | 该属性返回组件对象。 | | debugElement | 此属性返回组件的测试宿主元素。 | | nativeElement | 该属性返回表示组件宿主元素的 DOM 对象。 | | detectChanges() | 该方法使测试床检测状态变化,并将它们反映在组件的模板中。 | | whenStable() | 这个方法返回一个Promise,当一个操作的效果被完全应用时,它被解析。有关详细信息,请参见“用异步操作进行测试”一节。 |

在清单中,我使用componentInstance属性获取测试平台已经创建的FirstComponent对象,并执行一个简单的测试,以确保它是通过使用expect方法选择component对象作为测试目标并使用toBeDefined方法执行测试而创建的。我将在接下来的小节中演示其他方法和属性。

为依赖关系配置测试平台

Angular 应用最重要的特性之一是依赖注入,它允许组件和其他构建块通过使用构造函数参数声明对它们的依赖来接收服务。清单 29-8 向FirstComponent类添加了对数据模型存储库服务的依赖。

import { Component } from "@angular/core";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";

@Component({
    selector: "first",
    template: `<div class="bg-primary p-a-1">
                There are
                    <span class="strong"> {{getProducts().length}} </span>
                products
               </div>`
})
export class FirstComponent {

    constructor(private repository: Model) {}

    category: string = "Soccer";

    getProducts(): Product[] {
        return this.repository.getProducts()
            .filter(p => p.category == this.category);
    }
}

Listing 29-8.Adding a Service Dependency in the first.component.ts File in the src/app/ondemand Folder

该组件使用存储库来提供一个经过过滤的Product对象集合,这些对象通过一个名为getProducts的方法公开,并使用一个category属性进行过滤。内联模板有一个相应的数据绑定,显示了getProducts方法返回的产品数量。

能够对组件进行单元测试意味着为它提供存储库服务。只要通过测试模块进行配置,Angular 测试平台将负责解决依赖性。有效的单元测试通常要求组件与应用的其余部分隔离,这意味着模拟或伪造的对象(也称为测试替身)被用作单元测试中真实服务的替代品。清单 29-9 配置测试平台,以便使用一个假的存储库来为组件提供服务。

import { TestBed, ComponentFixture} from "@angular/core/testing";
import { FirstComponent } from "../ondemand/first.component";
import { Product } from "..//model/product.model";
import { Model } from "../model/repository.model";

describe("FirstComponent", () => {

    let fixture: ComponentFixture<FirstComponent>;
    let component: FirstComponent;

    let mockRepository = {
        getProducts: function () {
            return [
                new Product(1, "test1", "Soccer", 100),
                new Product(2, "test2", "Chess", 100),
                new Product(3, "test3", "Soccer", 100),
            ]
        }
    }

    beforeEach(() => {
        TestBed.configureTestingModule({
            declarations: [FirstComponent],
            providers: [
                { provide: Model, useValue: mockRepository }
            ]
        });
        fixture = TestBed.createComponent(FirstComponent);
        component = fixture.componentInstance;
    });

    it("filters categories", () => {
        component.category = "Chess"
        expect(component.getProducts().length).toBe(1);
        component.category = "Soccer";
        expect(component.getProducts().length).toBe(2);
        component.category = "Running";
        expect(component.getProducts().length).toBe(0);
    });
});

Listing 29-9.Providing a Service in the first.component.spec.ts File in the src/app/tests Folder

变量mockRepository被赋予一个对象,该对象提供一个getProducts方法,该方法返回可用于测试已知结果的固定数据。为了给组件提供服务,传递给TestBed.configureTestingModule方法的对象的providers属性以与真实 Angular 模块相同的方式进行配置,使用值提供者通过mockRepository变量解析对Model类的依赖。测试调用组件的getProducts方法,并将结果与预期结果进行比较,改变category属性的值来检查不同的过滤器。

测试数据绑定

前面的例子展示了如何在单元测试中使用组件的属性和方法。这是一个好的开始,但是许多组件还会在包含在它们的模板中的数据绑定表达式中包含功能的小片段,这些也应该被测试。清单 29-10 检查组件模板中的数据绑定是否正确显示了模拟数据模型中的产品数量。

import { TestBed, ComponentFixture} from "@angular/core/testing";
import { FirstComponent } from "../ondemand/first.component";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { DebugElement } from "@angular/core";
import { By } from "@angular/platform-browser";

describe("FirstComponent", () => {

    let fixture: ComponentFixture<FirstComponent>;
    let component: FirstComponent;
    let debugElement: DebugElement;
    let bindingElement: HTMLSpanElement;

    let mockRepository = {
        getProducts: function () {
            return [
                new Product(1, "test1", "Soccer", 100),
                new Product(2, "test2", "Chess", 100),
                new Product(3, "test3", "Soccer", 100),
            ]
        }
    }

    beforeEach(() => {
        TestBed.configureTestingModule({
            declarations: [FirstComponent],
            providers: [
                { provide: Model, useValue: mockRepository }
            ]
        });
        fixture = TestBed.createComponent(FirstComponent);
        component = fixture.componentInstance;
        debugElement = fixture.debugElement;
        bindingElement = debugElement.query(By.css("span")).nativeElement;
    });

    it("filters categories", () => {
        component.category = "Chess"
        fixture.detectChanges();
        expect(component.getProducts().length).toBe(1);
        expect(bindingElement.textContent).toContain("1");

        component.category = "Soccer";
        fixture.detectChanges();
        expect(component.getProducts().length).toBe(2);
        expect(bindingElement.textContent).toContain("2");

        component.category = "Running";
        fixture.detectChanges();
        expect(component.getProducts().length).toBe(0);
        expect(bindingElement.textContent).toContain("0");
    });
});

Listing 29-10.Unit Testing a Data Binding in the first.component.spec.ts File in the src/app/tests Folder

ComponentFixture.debugElement属性返回一个代表组件模板根元素的DebugElement对象,表 29-7 列出了由DebugElement类描述的最有用的方法和属性。

表 29-7。

有用的调试属性和方法

|

名字

|

描述

| | --- | --- | | nativeElement | 此属性返回表示 DOM 中 HTML 元素的对象。 | | children | 这个属性返回一个代表这个元素的子元素的DebugElement对象数组。 | | query(selectorFunction) | 为组件模板中的每个 HTML 元素向selectorFunction传递一个DebugElement对象,该方法返回函数返回true的第一个DebugElement。 | | queryAll(selectorFunction) | 这类似于query方法,除了结果是函数返回true的所有DebugElement对象。 | | triggerEventHandler(name, event) | 此方法触发一个事件。有关详细信息,请参见“测试组件事件”一节。 |

定位元素是通过queryqueryAll方法完成的,这两个方法接受检查DebugElement对象的函数,如果它们应该包含在结果中,则返回true。在@angular/platform-browser模块中定义的By类使得通过表 29-8 中描述的静态方法定位组件模板中的元素变得更加容易。

表 29-8。

By 方法

|

名字

|

描述

| | --- | --- | | By.all() | 这个方法返回一个匹配任何元素的函数。 | | By.css(selector) | 该方法返回一个使用 CSS 选择器匹配元素的函数。 | | By.directive(type) | 该方法返回一个函数,该函数与应用了指定指令类的元素相匹配,如“测试输入属性”一节中所示。 |

在清单中,我使用By.css方法定位模板中的第一个span元素,并通过nativeElement属性访问表示它的 DOM 对象,这样我就可以在单元测试中检查textContent属性的值。

请注意,每次更改组件的category属性后,我都会调用ComponentFixture对象的detectChanges方法,如下所示:

...
component.category = "Soccer";
fixture.detectChanges();
expect(component.getProducts().length).toBe(2);
expect(bindingElement.textContent).toContain("2");
...

该方法告诉 Angular 测试环境处理任何更改,并评估模板中的数据绑定表达式。如果没有这个方法调用,对组件category值的更改将不会反映在模板中,测试将会失败。

使用外部模板测试组件

Angular 组件被编译成工厂类,要么在浏览器中,要么由我在第十章中演示的超前编译器编译。作为这个过程的一部分,Angular 处理任何外部模板,并将它们作为文本包含在 JavaScript 代码中,生成的代码类似于内联模板。当使用外部模板对组件进行单元测试时,必须显式执行编译步骤。在清单 29-11 中,我修改了应用于FirstComponent类的@Component装饰器,这样它就指定了一个外部模板。

import { Component } from "@angular/core";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";

@Component({
    selector: "first",
    templateUrl: "first.component.html"
})
export class FirstComponent {

    constructor(private repository: Model) {}

    category: string = "Soccer";

    getProducts(): Product[] {
        return this.repository.getProducts()
            .filter(p => p.category == this.category);
    }
}

Listing 29-11.Specifying a Template in the first.component.ts File in the src/app/ondemand Folder

为了提供模板,我在exampleApp/app/ondemand文件夹中创建了一个名为first.component.html的文件,并添加了清单 29-12 中所示的元素。

<div class="bg-primary text-white p-2">
    There are
        <span class="strong"> {{getProducts().length}} </span>
    products
</div>

Listing 29-12.The first.component.html File in the exampleApp/app/ondemand Folder

这与之前内联定义的内容相同。清单 29-13 更新了组件的单元测试,通过显式编译组件来处理外部模板。

import { TestBed, ComponentFixture, async } from "@angular/core/testing";
import { FirstComponent } from "../ondemand/first.component";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { DebugElement } from "@angular/core";
import { By } from "@angular/platform-browser";

describe("FirstComponent", () => {

    let fixture: ComponentFixture<FirstComponent>;
    let component: FirstComponent;
    let debugElement: DebugElement;
    let spanElement: HTMLSpanElement;

    let mockRepository = {
        getProducts: function () {
            return [
                new Product(1, "test1", "Soccer", 100),
                new Product(2, "test2", "Chess", 100),
                new Product(3, "test3", "Soccer", 100),
            ]
        }
    }

    beforeEach(async(() => {
        TestBed.configureTestingModule({
            declarations: [FirstComponent],
            providers: [
                { provide: Model, useValue: mockRepository }
            ]
        });
        TestBed.compileComponents().then(() => {
            fixture = TestBed.createComponent(FirstComponent);
            component = fixture.componentInstance;
            debugElement = fixture.debugElement;
            spanElement = debugElement.query(By.css("span")).nativeElement;
        });
    }));

    it("filters categories", () => {
        component.category = "Chess"
        fixture.detectChanges();
        expect(component.getProducts().length).toBe(1);
        expect(spanElement.textContent).toContain("1");
    });
});

Listing 29-13.Compiling a Component in the first.component.spec.ts File in the src/app/tests Folder

使用TestBed.compileComponents方法编译组件。编译过程是异步的,compileComponents方法返回一个Promise,当编译完成时,必须使用它来完成测试设置。为了在单元测试中更容易处理异步操作,@angular/core/testing模块包含一个名为async的函数,它与beforeEach方法一起使用。

测试组件事件

为了演示如何测试组件对事件的响应,我在FirstComponent类中定义了一个新属性,并添加了一个已经应用了@HostBinding装饰器的方法,如清单 29-14 所示。

import { Component, HostListener} from "@angular/core";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";

@Component({
    selector: "first",
    templateUrl: "first.component.html"
})
export class FirstComponent {

    constructor(private repository: Model) {}

    category: string = "Soccer";
    highlighted: boolean = false;

    getProducts(): Product[] {
        return this.repository.getProducts()
            .filter(p => p.category == this.category);
    }

    @HostListener("mouseenter", ["$event.type"])
    @HostListener("mouseleave", ["$event.type"])
    setHighlight(type: string) {
        this.highlighted = type == "mouseenter";
    }
}

Listing 29-14.Adding Event Handling in the first.component.ts File in the src/app/ondemand Folder

已经配置了setHighlight方法,这样当主机元素的mouseentermouseleave事件被触发时,它将被调用。清单 29-15 更新组件的模板,以便它在数据绑定中使用新的属性。

<div class="bg-primary text-white p-2" [class.bg-success]="highlighted">
    There are
    <span class="strong"> {{getProducts().length}} </span>
    products
</div>

Listing 29-15.Binding to a Property in the first.component.html File in the src/app/ondemand Folder

事件可以通过DebugElement类定义的triggerEventHandler方法在单元测试中触发,如清单 29-16 所示。

import { TestBed, ComponentFixture, async } from "@angular/core/testing";
import { FirstComponent } from "../ondemand/first.component";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { DebugElement } from "@angular/core";
import { By } from "@angular/platform-browser";

describe("FirstComponent", () => {

    let fixture: ComponentFixture<FirstComponent>;
    let component: FirstComponent;
    let debugElement: DebugElement;
    let divElement: HTMLDivElement;

    let mockRepository = {
        getProducts: function () {
            return [
                new Product(1, "test1", "Soccer", 100),
                new Product(2, "test2", "Chess", 100),
                new Product(3, "test3", "Soccer", 100),
            ]
        }
    }

    beforeEach(async(() => {
        TestBed.configureTestingModule({
            declarations: [FirstComponent],
            providers: [
                { provide: Model, useValue: mockRepository }
            ]
        });
        TestBed.compileComponents().then(() => {
            fixture = TestBed.createComponent(FirstComponent);
            component = fixture.componentInstance;
            debugElement = fixture.debugElement;
            divElement = debugElement.children[0].nativeElement;
        });
    }));

    it("handles mouse events", () => {
        expect(component.highlighted).toBeFalsy();
        expect(divElement.classList.contains("bg-success")).toBeFalsy();
        debugElement.triggerEventHandler("mouseenter", new Event("mouseenter"));
        fixture.detectChanges();
        expect(component.highlighted).toBeTruthy();
        expect(divElement.classList.contains("bg-success")).toBeTruthy();
        debugElement.triggerEventHandler("mouseleave", new Event("mouseleave"));
        fixture.detectChanges();
        expect(component.highlighted).toBeFalsy();
        expect(divElement.classList.contains("bg-success")).toBeFalsy();
    });
});

Listing 29-16.Triggering Events in the first.component.spec.ts File in the src/app/tests Folder

清单中的测试检查组件和模板的初始状态,然后触发mouseentermouseleave事件,检查每个事件的影响。

测试输出属性

测试输出属性是一个简单的过程,因为用来实现它们的EventEmitter对象是可以在单元测试中订阅的Observable对象。清单 29-17 向被测组件添加一个输出属性。

import { Component, HostListener, Output, EventEmitter} from "@angular/core";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";

@Component({
    selector: "first",
    templateUrl: "first.component.html"
})
export class FirstComponent {

    constructor(private repository: Model) {}

    category: string = "Soccer";
    highlighted: boolean = false;

    @Output("pa-highlight")
    change = new EventEmitter<boolean>();

    getProducts(): Product[] {
        return this.repository.getProducts()
            .filter(p => p.category == this.category);
    }

    @HostListener("mouseenter", ["$event.type"])
    @HostListener("mouseleave", ["$event.type"])
    setHighlight(type: string) {
        this.highlighted = type == "mouseenter";
        this.change.emit(this.highlighted);
    }
}

Listing 29-17.Adding an Output Property in the first.component.ts File in the src/app/ondemand Folder

该组件定义了一个名为change的输出属性,用于在调用setHighlight方法时发出一个事件。清单 29-18 显示了一个针对输出属性的单元测试。

import { TestBed, ComponentFixture, async } from "@angular/core/testing";
import { FirstComponent } from "../ondemand/first.component";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { DebugElement } from "@angular/core";
import { By } from "@angular/platform-browser";

describe("FirstComponent", () => {

    let fixture: ComponentFixture<FirstComponent>;
    let component: FirstComponent;
    let debugElement: DebugElement;

    let mockRepository = {
        getProducts: function () {
            return [
                new Product(1, "test1", "Soccer", 100),
                new Product(2, "test2", "Chess", 100),
                new Product(3, "test3", "Soccer", 100),
            ]
        }
    }

    beforeEach(async(() => {
        TestBed.configureTestingModule({
            declarations: [FirstComponent],
            providers: [
                { provide: Model, useValue: mockRepository }
            ]
        });
        TestBed.compileComponents().then(() => {
            fixture = TestBed.createComponent(FirstComponent);
            component = fixture.componentInstance;
            debugElement = fixture.debugElement;
        });
    }));

    it("implements output property", () => {
        let highlighted: boolean;
        component.change.subscribe(value => highlighted = value);
        debugElement.triggerEventHandler("mouseenter", new Event("mouseenter"));
        expect(highlighted).toBeTruthy();
        debugElement.triggerEventHandler("mouseleave", new Event("mouseleave"));
        expect(highlighted).toBeFalsy();
    });
});

Listing 29-18.Testing an Output Property in the first.component.spec.ts File in the src/app/tests Folder

我本可以在单元测试中直接调用组件的setHighlight方法,但是我选择了触发mouseentermouseleave事件,这将间接地激活输出属性。在触发事件之前,我使用subscribe方法从output属性接收事件,然后用它来检查预期的结果。

测试输入属性

测试输入属性的过程需要一些额外的工作。首先,我向用于接收数据模型存储库的FirstComponent类添加了一个输入属性,替换了构造函数接收的服务,如清单 29-19 所示。我还删除了主机事件绑定和输出属性,以保持示例简单。

import { Component, HostListener, Input } from "@angular/core";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";

@Component({
    selector: "first",
    templateUrl: "first.component.html"
})
export class FirstComponent {

    category: string = "Soccer";
    highlighted: boolean = false;

    getProducts(): Product[] {
        return this.model == null ? [] : this.model.getProducts()
            .filter(p => p.category == this.category);
    }

    @Input("pa-model")
    model: Model;
}

Listing 29-19.Adding an Input Property in the first.component.ts File in the src/app/ondemand Folder

使用名为pa-model的属性设置input属性,并在getProducts方法中使用。清单 29-20 展示了如何编写一个针对输入属性的单元测试。

import { TestBed, ComponentFixture, async } from "@angular/core/testing";
import { FirstComponent } from "../ondemand/first.component";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { DebugElement } from "@angular/core";
import { By } from "@angular/platform-browser";
import { Component, ViewChild } from "@angular/core";

@Component({
    template: `<first [pa-model]="model"></first>`
})
class TestComponent {

    constructor(public model: Model) { }

    @ViewChild(FirstComponent)
    firstComponent: FirstComponent;
}

describe("FirstComponent", () => {

    let fixture: ComponentFixture<TestComponent>;
    let component: FirstComponent;
    let debugElement: DebugElement;

    let mockRepository = {
        getProducts: function () {
            return [
                new Product(1, "test1", "Soccer", 100),
                new Product(2, "test2", "Chess", 100),
                new Product(3, "test3", "Soccer", 100),
            ]
        }
    }

    beforeEach(async(() => {
        TestBed.configureTestingModule({
            declarations: [FirstComponent, TestComponent],
            providers: [
                { provide: Model, useValue: mockRepository }
            ]
        });
        TestBed.compileComponents().then(() => {
            fixture = TestBed.createComponent(TestComponent);
            fixture.detectChanges();
            component = fixture.componentInstance.firstComponent;
            debugElement = fixture.debugElement.query(By.directive(FirstComponent));
        });
    }));

    it("receives the model through an input property", () => {
        component.category = "Chess";
        fixture.detectChanges();
        let products = mockRepository.getProducts()
            .filter(p => p.category == component.category);
        let componentProducts = component.getProducts();
        for (let i = 0; i < componentProducts.length; i++) {
            expect(componentProducts[i]).toEqual(products[i]);
        }
        expect(debugElement.query(By.css("span")).nativeElement.textContent)
            .toContain(products.length);
    });
});

Listing 29-20.Testing an Input Property in the first.component.spec.ts File in the src/app/tests Folder

这里的技巧是定义一个组件,它只需要设置测试,并且它的模板包含一个元素,该元素与您想要作为目标的组件的选择器相匹配。在这个例子中,我用在@Component装饰器中定义的内联模板定义了一个名为TestComponent的组件类,该模板包含一个具有pa-model属性的first元素,该元素对应于应用于FirstComponent类的@Input装饰器。

测试组件类被添加到测试模块的declarations数组中,并使用TestBed.createComponent方法创建一个实例。我在TestComponent类中使用了@ViewChild装饰器,这样我就可以获得测试所需的FirstComponent实例。为了获得FirstComponent根元素,我使用了DebugElement.query方法和By.directive方法。

结果是我能够访问组件及其测试的根元素,这将设置category属性,然后验证来自组件的结果以及通过其模板中的数据绑定得到的结果。

使用异步操作进行测试

另一个需要特殊措施的领域是处理异步操作。为了演示这是如何做到的,清单 29-21 修改了被测组件,以便它使用第二十四章中定义的RestDataSource类来获取数据。这不是一个打算在模型特性模块之外使用的类,但是它提供了一组有用的异步方法来返回Observable对象,所以我已经突破了应用的预期结构,以便我可以演示测试技术。

import { Component, HostListener, Input } from "@angular/core";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { RestDataSource } from "../model/rest.datasource";

@Component({
    selector: "first",
    templateUrl: "first.component.html"
})
export class FirstComponent {
    _category: string = "Soccer";
    _products: Product[] = [];
    highlighted: boolean = false;

    constructor(public datasource: RestDataSource) {}

    ngOnInit() {
        this.updateData();
    }

    getProducts(): Product[] {
        return this._products;
    }

    set category(newValue: string) {
        this._category;
        this.updateData();
    }

    updateData() {
        this.datasource.getData()
            .subscribe(data => this._products = data
                .filter(p => p.category == this._category));
    }
}

Listing 29-21.An Async Operation in the first.component.ts File in the src/app/ondemand Folder

组件通过数据源的getData方法获取数据,该方法返回一个Observable对象。组件订阅了Observable,并用数据对象更新了它的_product属性,这些数据对象通过getProducts方法暴露给模板。

清单 29-22 展示了如何使用 Angular 为单元测试中的异步操作提供的工具来测试这类组件。

import { TestBed, ComponentFixture, async, fakeAsync, tick } from "@angular/core/testing";
import { FirstComponent } from "../ondemand/first.component";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { DebugElement } from "@angular/core";
import { By } from "@angular/platform-browser";
import { Component, ViewChild } from "@angular/core";
import { RestDataSource } from "../model/rest.datasource";
import { Observable } from "rxjs";
import { Injectable } from "@angular/core";

@Injectable()
class MockDataSource {
    public data = [
        new Product(1, "test1", "Soccer", 100),
        new Product(2, "test2", "Chess", 100),
        new Product(3, "test3", "Soccer", 100),
    ];

    getData(): Observable<Product[]> {
        return new Observable<Product[]>(obs => {
            setTimeout(() => obs.next(this.data), 1000);
        })
    }
}

describe("FirstComponent", () => {

    let fixture: ComponentFixture<FirstComponent>;
    let component: FirstComponent;
    let dataSource = new MockDataSource();

    beforeEach(async(() => {
        TestBed.configureTestingModule({
            declarations: [FirstComponent],
            providers: [
                { provide: RestDataSource, useValue: dataSource }
            ]
        });
        TestBed.compileComponents().then(() => {
            fixture = TestBed.createComponent(FirstComponent);
            component = fixture.componentInstance;
        });
    }));

    it("performs async op", fakeAsync( () => {
        dataSource.data.push(new Product(100, "test100", "Soccer", 100));

        fixture.detectChanges();

        tick(1000);

        fixture.whenStable().then(() => {
            expect(component.getProducts().length).toBe(3);
        });
    }));
});

Listing 29-22.Testing an Async Operation in the first.component.spec.ts File in the src/app/tests Folder

本例中的模拟对象比我之前创建的对象更加完整,只是为了展示实现相同目标的不同方式。需要注意的重要一点是,它实现的getData方法在返回样本数据之前引入了一秒钟的延迟。

这个延迟很重要,因为这意味着在单元测试中调用detectChanges方法的效果不会立即影响组件。为了模拟时间的流逝,我使用了fakeAsynctick方法,并且为了处理异步变化,我调用了由ComponentFixture类定义的whenStable方法,该方法返回一个Promise,当所有的变化都被完全处理后,该方法将解析。这允许我推迟对测试结果的评估,直到模拟数据源返回的Observable将它的数据交付给组件。

测试 Angular 方向

测试指令的过程类似于测试输入属性所需的过程,因为测试组件和模板用于创建测试环境,在该环境中可以应用指令。为了测试一个指令,我在src/app/ondemand文件夹中添加了一个名为attr.directive.ts的文件,并添加了清单 29-23 中所示的代码。

Note

我在这个例子中展示了一个属性指令,但是本节中的技术同样可以用来测试结构化指令。

import {
    Directive, ElementRef, Attribute, Input, SimpleChange
} from "@angular/core";

@Directive({
    selector: "[pa-attr]"
})
export class PaAttrDirective {

    constructor(private element: ElementRef) { }

    @Input("pa-attr")
    bgClass: string;

    ngOnChanges(changes: { [property: string]: SimpleChange }) {
        let change = changes["bgClass"];
        let classList = this.element.nativeElement.classList;
        if (!change.isFirstChange() && classList.contains(change.previousValue)) {
            classList.remove(change.previousValue);
        }
        if (!classList.contains(change.currentValue)) {
            classList.add(change.currentValue);
        }
    }
}

Listing 29-23.The Contents of the attr.directive.ts File in the src/app/ondemand Folder

这是一个基于第十五章中一个例子的属性指令。为了创建一个以指令为目标的单元测试,我在src/app/tests文件夹中添加了一个名为attr.directive.spec.ts的文件,并添加了清单 29-24 中所示的代码。

import { TestBed, ComponentFixture } from "@angular/core/testing";
import { Component, DebugElement, ViewChild } from "@angular/core";
import { By } from "@angular/platform-browser";
import { PaAttrDirective } from "../ondemand/attr.directive";

@Component({
    template: `<div><span [pa-attr]="className">Test Content</span></div>`
})
class TestComponent {
    className = "initialClass"

    @ViewChild(PaAttrDirective)
    attrDirective: PaAttrDirective;
}

describe("PaAttrDirective", () => {

    let fixture: ComponentFixture<TestComponent>;
    let directive: PaAttrDirective;
    let spanElement: HTMLSpanElement;

    beforeEach(() => {
        TestBed.configureTestingModule({
            declarations: [TestComponent, PaAttrDirective],
        });
        fixture = TestBed.createComponent(TestComponent);
        fixture.detectChanges();
        directive = fixture.componentInstance.attrDirective;
        spanElement = fixture.debugElement.query(By.css("span")).nativeElement;
    });

    it("generates the correct number of elements", () => {
        fixture.detectChanges();
        expect(directive.bgClass).toBe("initialClass");
        expect(spanElement.className).toBe("initialClass");

        fixture.componentInstance.className = "nextClass";
        fixture.detectChanges();
        expect(directive.bgClass).toBe("nextClass");
        expect(spanElement.className).toBe("nextClass");
    });
});

Listing 29-24.The Contents of the attr.directive.spec.ts File in the src/app/tests Folder

文本组件有一个应用指令的内联模板和一个在数据绑定中引用的属性。@ViewChild decorator 提供了对 Angular 在处理模板时创建的 directive 对象的访问,单元测试能够检查更改数据绑定使用的值是否对 directive 对象和它所应用的元素有影响。

摘要

在这一章中,我演示了 Angular 组件和指令进行单元测试的不同方法。我解释了安装测试框架和工具的过程,以及如何创建测试床来应用测试。我演示了如何测试组件的不同方面,以及如何将相同的技术应用于指令。

这就是我要教你的关于 Angular 的一切。我首先创建了一个简单的应用,然后带您全面浏览了框架中的不同构建块,向您展示了如何创建、配置和应用它们来创建 web 应用。

我希望你在你的角项目中取得成功,我只能希望你喜欢读这本书,就像我喜欢写它一样。