第04课:创建、使用和测试组件

第04课:创建、使用和测试组件

在介绍完相关的背景知识之后,下面就开始真正的开发工作。在开发之前,有必要对本课程的实战实例应用进行一下基本的介绍。

实战实例

本课程的实例是一个新闻和趣闻的阅读工具。应用中有两个页面,分别显示新闻和趣闻。可以通过侧边栏菜单来进行切换。每个页面中包含的是新闻或趣闻的列表。列表中的每个条目都有标题、作者、日期和链接地址。用户除了阅读文章的内容之外,还可以把文章通过系统自带的工具分享到其社交网络中。

模型

在开发第一个组件之前,需要首先定义的是应用中的模型。模型是服务器端数据和组件之间的接口。明确了模型就规范了前端和后端之间的契约。从应用支持的使用场景来说,就围绕两个模型概念。第一个是新闻的条目,包含标题、作者、日期和链接地址等信息;第二个是新闻的列表,包含多个新闻条目。我们使用 TypeScript 的接口来定义模型。

下面是 models/item.ts 文件的内容。接口 Item 包含 5 个必要属性 id、title、date、author 和 url。ItemsList 的属性 items 是一个 Item 的数组。

export interface Item {
  id: string;
  title: string;
  date: string;
  author: string;
  url: string;
}

export interface ItemsList {
  items: Item[];
}

创建组件

在定义了模型之后,接着创建第一个显示新闻条目的组件。Ionic 命令行工具提供了生成组件基本代码的功能,只需要执行 ionic generate component <name> 就可以生成组件的代码。这里使用 ionic generate component item 来创建名为 item 的组件。生成的组件代码会被添加到 components 下的子目录中。子目录的名称与给定的组件名称相同,也是 item。在该目录下,生成了3个文件,分别是 item.html、item.ts 和 item.scss。这也是 Ionic 中组件代码的标准组织形式。item.ts 中创建的组件类名为 ItemComponent。Ionic 会在给定的名称后自动加上 Component 后缀。

除了组件本身的代码之外,Ionic 命令行工具还会在 components 目录下创建一个 components.module.ts 文件。该文件中定义了模块 ComponentsModule,用来包含所有组件声明。使用一个额外的模块并不是必须的,我们也可以把组件声明添加到 app/app.module.ts 中定义的根模块 AppModule 中。如果不希望创建该模块,在 ionic generate component 命令后添加参数 --no-module 即可。推荐的做法是不创建单独的模块。这是因为我们创建的模块中都会使用 Ionic 提供的组件,而自动生成的 ComponentsModule 中并没有包含导入 Ionic 模块的逻辑,因此会导致创建的组件无法使用。接着以同样的方式创建显示条目列表的组件,即调用命令 ionic generate component itemsList --no-module。组件名称 itemsList 所对应的目录名称是 items-list。新创建的组件应该在 AppModule 的 declarations 和 entryComponents 中分别声明。

条目组件 ItemComponent

对于展示列表和其中的条目,Ionic 提供了内置的组件 ion-list 和 ion-item ,可以直接使用。ion-list 和 ion-item 是通用的组件,可以用不同的方式来展示条目,包括纯文本、带图标或带缩略图。本实例中的条目只需要显示文字即可。

下面首先从条目组件 ItemComponent 开始介绍。该组件的代码非常简单,只包含一个输入属性 item ,类型为 Item 。这说明 ItemComponent 只是负责展示 Item。 ItemComponent 的 selector 属性的值为 quwen-item,其中的前缀 quwen- 是为了避免与其他组件的选择器产生冲突。

import { Component, Input } from '@angular/core';
import { Item } from '../../models/item';

@Component({
  selector: 'quwen-item',
  templateUrl: 'item.html'
})
export class ItemComponent {

  @Input() item: Item;

}

下面的代码是 ItemComponent 对应的模板 item.html 的内容。模板的内容也很简单易懂,用基本的 HTML 元素展示 Item 中的标题、作者、日期和链接地址。

<h2><a href="{{item.url}}">{{ item.title }}</a></h2>
<p class="meta">
  <span class="author">{{ item.author }}</span>
  <span class="date">{{ item.date }}</span>
</p>

在上面的模板中也用到了一些样式,即下面的 item.scss 中的内容。

quwen-item {
  .meta {
    display: flex;
    justify-content: space-between;
  }
}

条目列表组件 ItemsListComponent

条目列表组件 ItemsListComponent 的实现也并不复杂。其中同样只包含一个输入属性 itemsList,类型为 ItemsList。

import { Component, Input } from '@angular/core';
import { ItemsList } from '../../models/item';

@Component({
  selector: 'quwen-items-list',
  templateUrl: 'items-list.html'
})
export class ItemsListComponent {

  @Input() itemsList: ItemsList;

}

对应的模板内容如下所示,其中 ion-list 是列表组件,对于 ItemsList 的 items 中的每一个 Item 对象,使用 ngFor 来生成一个对应的 ion-item 组件,每个 ion-item 都包含一个 ItemComponent。

<ion-list>
  <ion-item *ngFor="let item of itemsList.items">
    <quwen-item [item]="item"></quwen-item>
  </ion-item>
</ion-list>

这里需要对 <quwen-item [item]="item"></quwen-item> 进行一下说明。最外面的 <quwen-item> 元素是组件 ItemComponent 对应的选择器,用来创建 ItemComponent 组件。[item]="item" 的含义是把来自 ngFor 循环中的变量 item 绑定到 ItemComponent 的属性 item 上。

在完成了 ItemsListComponent 和 ItemComponent 两个组件之后,就可以在页面中使用它们了。不过更好的做法是首先为它们添加相应的单元测试。

组件单元测试

虽然两个组件 ItemsListComponent 和 ItemComponent 的功能非常简单,代码实现也是一目了然,但是添加自动化单元测试对于任何项目都是至关重要的。Ionic 应用的单元测试本质上是 Angular 应用的单元测试,不过也有其自身的特性。

配置单元测试环境

由于生成的骨架代码中并没有对单元测试的环境进行配置,首先要完成相关的准备工作。具体的做法是把 Ionic 应用转换成一个 Angular CLI 应用,再使用 Angular CLI 的相关命令来运行测试。除了单元测试之外,端到端测试也会用到 Angular CLI 的相关功能。

首先安装 Angular CLI 并配置其使用 Yarn。

$ yarn global add @angular/cli
$ ng set --global packageManager=yarn

Ionic 应用的 package.json 文件也需要更新,添加所需的开发依赖。完整的 devDependencies 如下所示。

"devDependencies": {
  "@angular/cli": "1.6.2",
  "@angular/language-service": "^5.0.0",
  "@ionic/app-scripts": "3.1.7",
  "@types/jasmine": "~2.5.53",
  "@types/jasminewd2": "~2.0.2",
  "@types/node": "~6.0.60",
  "codelyzer": "^4.0.1",
  "jasmine-core": "~2.6.2",
  "jasmine-spec-reporter": "~4.1.0",
  "karma": "~1.7.0",
  "karma-chrome-launcher": "~2.1.1",
  "karma-cli": "~1.0.1",
  "karma-coverage-istanbul-reporter": "^1.2.1",
  "karma-jasmine": "~1.1.0",
  "karma-jasmine-html-reporter": "^0.2.2",
  "protractor": "^5.2.2",
  "ts-node": "~3.2.0",
  "tslint": "5.8.0",
  "typescript": "2.4.2"
},

接着在根目录下添加 .angular-cli.json 作为 Angular CLI 的配置文件。该配置文件的内容比较多,其中 apps 中定义了一个应用。应用的相关配置都指向 Ionic 应用中的特定目录或文件,使得 Angular CLI 可以把我们的 Ionic 应用识别为一个受其管理的 Angular 应用。在 test 中定义了 Karma 的配置文件是 karma.conf.js。

{
  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
  "project": {
    "name": "quwen"
  },
  "apps": [
    {
      "root": "src",
      "outDir": "dist",
      "assets": [
        "assets",
        "favicon.ico"
      ],
      "index": "index.html",
      "main": "app/main.ts",
      "polyfills": "polyfills.ts",
      "test": "test.ts",
      "tsconfig": "tsconfig.app.json",
      "testTsconfig": "tsconfig.spec.json",
      "prefix": "app",
      "styles": [],
      "scripts": [],
      "environmentSource": "environments/environment.ts",
      "environments": {
        "dev": "environments/environment.ts",
        "prod": "environments/environment.prod.ts"
      }
    }
  ],
  "e2e": {
    "protractor": {
      "config": "./protractor.conf.js"
    }
  },
  "lint": [
    {
      "project": "src/tsconfig.app.json",
      "exclude": "**/node_modules/**"
    },
    {
      "project": "src/tsconfig.spec.json",
      "exclude": "**/node_modules/**"
    },
    {
      "project": "e2e/tsconfig.e2e.json",
      "exclude": "**/node_modules/**"
    }
  ],
  "test": {
    "karma": {
      "config": "./karma.conf.js"
    }
  },
  "defaults": {
    "styleExt": "css",
    "component": {}
  }
}

karma.conf.js 的内容如下所示,其中配置了 Karma 会启动 Chrome 浏览器来执行测试(即 browsers: ['Chrome'] ),并且当代码变化时,Karma 会自动重新运行测试。Karma 的详细配置可以参考官方文档

module.exports = function (config) {
  config.set({
    basePath: '',
    frameworks: ['jasmine', '@angular/cli'],
    plugins: [
      require('karma-jasmine'),
      require('karma-chrome-launcher'),
      require('karma-jasmine-html-reporter'),
      require('karma-coverage-istanbul-reporter'),
      require('@angular/cli/plugins/karma')
    ],
    client:{
      clearContext: false // leave Jasmine Spec Runner output visible in browser
    },
    coverageIstanbulReporter: {
      reports: [ 'html', 'lcovonly' ],
      fixWebpackSourcePaths: true
    },
    angularCli: {
      environment: 'dev'
    },
    reporters: ['progress', 'kjhtml'],
    port: 9876,
    colors: true,
    logLevel: config.LOG_INFO,
    autoWatch: true,
    browsers: ['Chrome'],
    singleRun: false
  });
};

在 .angular-cli.json 文件中,我们定义了单元测试的入口文件是 test.ts,如下所示。该文件的主要作用是首先初始化 Angular 的测试环境,接着找到并加载所有测试套件文件(.spec.ts 文件),最后启动 Karma 来运行测试。

// This file is required by karma.conf.js and loads recursively all the .spec and framework files

import 'zone.js/dist/long-stack-trace-zone';
import 'zone.js/dist/proxy.js';
import 'zone.js/dist/sync-test';
import 'zone.js/dist/jasmine-patch';
import 'zone.js/dist/async-test';
import 'zone.js/dist/fake-async-test';
import { getTestBed } from '@angular/core/testing';
import {
  BrowserDynamicTestingModule,
  platformBrowserDynamicTesting
} from '@angular/platform-browser-dynamic/testing';

// Unfortunately there's no typing for the `__karma__` variable. Just declare it as any.
declare const __karma__: any;
declare const require: any;

// Prevent Karma from running prematurely.
__karma__.loaded = function () {};

// First, initialize the Angular testing environment.
getTestBed().initTestEnvironment(
  BrowserDynamicTestingModule,
  platformBrowserDynamicTesting()
);
// Then we find all the tests.
const context = require.context('./', true, /\.spec\.ts$/);
// And load the modules.
context.keys().map(context);
// Finally, start Karma to run the tests.
__karma__.start();

运行Karma使用的是 ng test。不过推荐的做法是在 package.json 中添加脚本命令,这样就可以使用 yarn run test 来运行测试。-sm=false 的作用是禁用Angular的源代码映射功能(source mapping),这样更容易查看错误信息。

"scripts": {
  "test": "ng test -sm=false"
},

还需要修改根目录下的 tsconfig.json 文件中的 exclude 属性,把其中忽略 .spec.ts 文件的部分去掉。这部分的逻辑已经移到了 Angular CLI 使用的 tsconfig.app.json 文件中。

编写单元测试

下面我们开始编写第一个组件 ItemComponent 的单元测试。组件的单元测试文件的名称与其对应的组件的文件名称相同,只不过后缀是 .spec.ts。ItemComponent 的单元测试文件的名称是 item.spec.ts。由于这是第一个单元测试,其中包含很多新的概念需要介绍。

首先该测试套件是一个标准的 Jasmine 测试套件,其中的 describe、 beforeEach 和 it 等函数都来自 Jasmine。该测试套件的场景是测试 ItemComponent 组件能够正确地显示条目的相关信息。在 beforeEach 中,首先对测试环境进行配置,添加对 ItemComponent 的声明。这样的配置与 AppModule 中 @NgModule 声明的作用是一样的。compileComponents 用来异步地编译所有组件。当组件的模板来自外部文件,即使用 templateUrl 时,需要使用该方法来编译。由于 compileComponents 方法是异步的,需要使用 async 来进行封装。在编译之后,使用 TestBed.createComponent(ItemComponent) 创建一个 ItemComponent 组件的实例。fixture 和 component 分别表示待测试组件对应的测试装置和实际的组件对象。

在实际的测试规格中,由于组件对象有输入属性 item ,可以直接进行赋值。完成赋值之后,使用 fixture.detectChanges() 触发 Angular 的变化检测机制来更新 DOM。接着就可以使用 fixture.debugElement 来查询 DOM 中的相应节点并验证其状态。比如,fixture.debugElement.query(By.css('h2')) 的作用是查询组件 DOM 中的第一个 h2 元素,也就是显示条目标题的元素。查询结果的 nativeElement 属性表示的原生 DOM 对象。可以使用 textContent 来获取 DOM 对象的文本内容,再使用 expect 进行验证。该测试规格也使用了 async 进行封装。在本测试规格中并不是必须的,因为规格代码中没有异步执行的逻辑。这里只是为了展示 async 的用法。

import { TestBed, async, ComponentFixture } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ItemComponent } from './item';

describe('ItemComponent', () => {
  let fixture: ComponentFixture<ItemComponent>;
  let component: ItemComponent;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ ItemComponent ],
    }).compileComponents();
    fixture = TestBed.createComponent(ItemComponent);
    component = fixture.componentInstance;
  }));

  it('should display item info', async(() => {
    component.item = {
      id: '001',
      title: 'Test title',
      author: 'Test author',
      url: 'http://example.com/001',
      date: '2017-12-27',
    };
    fixture.detectChanges();
    let title = fixture.debugElement.query(By.css('h2')).nativeElement.textContent;
    expect(title).toEqual('Test title');
    let author = fixture.debugElement.query(By.css('span.author')).nativeElement.textContent;
    expect(author).toEqual('Test author');
    let date = fixture.debugElement.query(By.css('span.date')).nativeElement.textContent;
    expect(date).toEqual('2017-12-27');
  }));
});

在编写完测试套件之后,就可以使用 yarn run test 来运行。Karma 会自动启动 Chrome 来运行测试,可以在浏览器和控制台都看到测试结果。Chrome 的运行效果如下图所示。

Karma运行效果

测试 ItemsListComponent

对 ItemsListComponent 的测试就没有 ItemComponent 那么简单了,这是因为 ItemsListComponent 使用了 Ionic 提供的组件 ion-list 和 ion-item,这就要求配置 Angular 测试环境来导入 Ionic 提供的模块。文件 test-utils.ts 中的 TestUtils 提供了进行配置的方法。

第一个方法 beforeEachCompiler 用在 Jasmine 的 beforeEach 方法中。它接受表示组件的 components 数组和表示服务提供者的 providers 数组。该方法调用 configureIonicTestingModule 来配置测试模块并编译,然后创建出组件对象。返回值是一个 Promise 对象,包含了创建出来的测试装置和组件对象。

方法 configureIonicTestingModule 的作用是配置测试环境来导入 Ionic 提供的模块,除了在 beforeEachCompiler 调用时的服务提供者之外,还提供了 Ionic 内部的很多服务提供者,这样可以保证 Ionic 组件正常工作。DomControllerMock 和 PlatformMock 作为 Ionic 内部服务的 mock 实现,用来模拟 Ionic 的一些行为。

import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {TestBed} from '@angular/core/testing';
import {
  App, Config, DomController, Form, GestureController, IonicModule, Keyboard, MenuController, NavController,
  Platform
} from 'ionic-angular';

class DomControllerMock extends DomController {
  public read(): any {}
  public write(): any {}
}

class PlatformMock extends Platform {
  public registerListener(): any {}

  win(): Window {
    return window;
  }
}

export class TestUtils {

  public static beforeEachCompiler(components: Array<any>, providers: Array<any> = []): Promise<{fixture: any, component: any}> {
    return TestUtils.configureIonicTestingModule(components, providers)
      .compileComponents().then(() => {
        let fixture: any = TestBed.createComponent(components[0]);
        return {
          fixture,
          component: fixture.componentInstance,
        };
      });
  }

  public static configureIonicTestingModule(components: Array<any>, providers: Array<any> = []): typeof TestBed {
    return TestBed.configureTestingModule({
      declarations: [
        ...components,
      ],
      providers: [
        App,
        Config,
        { provide: DomController, useClass: DomControllerMock },
        Form,
        Keyboard,
        MenuController,
        NavController,
        GestureController,
        { provide: Platform, useClass: PlatformMock },
        ...providers,
      ],
      imports: [
        FormsModule,
        IonicModule,
        ReactiveFormsModule,
      ],
    });
  }
}

在有了 TestUtils 之后,ItemsListComponent 的测试就变得简单了。在下面代码中,createItems 方法用来创建给定数量的 Item。测试套件的 beforeEach 中使用了 TestUtils.beforeEachCompiler 方法,并提供了两个组件声明 ItemsListComponent 和 ItemComponent。ItemsListComponent 必须出现在第一个,因为 TestUtils.beforeEachCompiler 创建的是第一个组件声明的对象。在测试规格的实现中,首先创建包含 10 个条目的 ItemsList,再赋值给 ItemsListComponent 的输入属性 itemsList。在进行验证时,确保组件 DOM 中包含 10 个 h2 元素,并且第一个 h2 元素的文本值是 Item 0。

import {async, ComponentFixture} from "@angular/core/testing";
import {By} from '@angular/platform-browser';
import {ItemsListComponent} from './items-list';
import {Item} from "../../models/item";
import {ItemComponent} from "../item/item";
import {TestUtils} from "../../test-utils";

function createItems(n: number): Item[] {
  const items = [];
  for (let i = 0; i < n; i++) {
    items.push({
      id : `item-${i}`,
      title: `Item ${i}`,
      author: `Author ${i}`,
      url: `http://example.com/${i}`,
      date: '2017-12-27',
    });
  }
  return items;
}

describe('ItemsListComponent', () => {
  let fixture: ComponentFixture<ItemsListComponent>;
  let component: ItemsListComponent;

  beforeEach(async(() => TestUtils.beforeEachCompiler([ItemsListComponent, ItemComponent]).then((result => {
    fixture = result.fixture;
    component = result.component;
  }))));

  it('should display items', async(() => {
    component.itemsList = {
      items: createItems(10),
    };
    fixture.detectChanges();
    let items = fixture.debugElement.queryAll(By.css('h2'));
    expect(items.length).toEqual(10);
    let title = items[0].nativeElement.textContent;
    expect(title).toEqual('Item 0');
  }));
});

对于 ItemComponent 的测试套件也进行修改来使用 TestUtils.beforeEachCompiler 方法。

经过单元测试的组件是进一步开发的良好基础。在下一篇中,我们将介绍如何创建页面和使用服务。

上一篇
下一篇
目录