按由后到前的顺序初始化如下: ## service service/course.service.ts ```typescript /** * 分页 * @param params name课程名称 klassId 班级 teacherId 教师 */ page(params?: {name?: string, klassId?: number, teacherId?: number}): Observable<Page➊> { return null; } ``` * ➊ 分页信息属于常用数据结构,自定Page类以便复用。 ## page Page接口 ``` panjiedeMac-Pro:norm panjie$ ng g class Page CREATE src/app/norm/page.spec.ts (146 bytes) CREATE src/app/norm/page.ts (22 bytes) panjiedeMac-Pro:norm panjie$ ``` 参考学生管理中后台返回的分页数据初始化Page类如下: norm/page.ts ```typescript /** * 分页数据 */ export class Page<T➊> { /* 内容 */ content: Array<T>➋; /* 总页数 */ totalPages: number; constructor(params?: { content?: T, totalPages?: number }) { if (params) { if (params.content) { this.content = params.content; } if (params.totalPages) { this.totalPages = params.totalPages; } } } } ``` * ➊ 声明Page具有容器的性质,可以装入不同的对象 * ➋ 内部容器,该类型与Page类型的泛型相同。 新建Page类后在service/course.service.ts中引用Page并设置泛型。 service/course.service.ts ```typescript import {Page} from '../norm/page'; ➊ page(params?: {name?: string, klassId?: number, teacherId?: number}): Observable<Page<Course➋>> { return null; } ``` * ➊ 引入Page类型 * ➋ 声明Page里装入的对象类型为Course ## ServiceStub 对应在service的替身中增加page方法 service/course-stub.service.ts ```typescript page(params?: {name?: string, klassId?: number, teacherId?: number}): Observable<Page<Course>> { return null; } ``` ## 组件 在course中新建index组件: ``` panjiedeMac-Pro:course panjie$ ng g c index CREATE src/app/course/index/index.component.sass (0 bytes) CREATE src/app/course/index/index.component.html (20 bytes) CREATE src/app/course/index/index.component.spec.ts (621 bytes) CREATE src/app/course/index/index.component.ts (266 bytes) UPDATE src/app/course/course.module.ts (761 bytes) panjiedeMac-Pro:course panjie$ ``` ### V层初始化 course/index/index.component.html ```html <form (ngSubmit)="onQuery()"> <label>课程名称:<input name="name" [formControl]="params.name" type="text"/></label> <label>教师: <app-teacher-select (selected)="onSelectTeacher($event)"></app-teacher-select> </label> <label>班级: <app-klass-select (selected)="onSelectKlass($event)"></app-klass-select> </label> <button type="submit">查询</button> </form> <div class="row"> <div class="col text-right"> <a class="btn btn-primary" routerLink="./add"><span class="oi oi-plus"></span>新增课程</a> </div> </div> <table class="table"> <tr> <th>序号</th> <th>名称</th> <th>任课教师</th> <th>班级</th> <th>操作</th> </tr> <tr *ngFor="let course of coursePage.content; index as index"> <td>{{index + 1}}</td> <td>{{course.name}}</td> <td>{{course.teacher.name}}</td> <td>{{course.klass.name}}</td> <td> <a routerLink="./edit/{{course.id}}" class="btn btn-sm btn-info"><span class="oi oi-pencil"></span>编辑</a> <button (click)="onDelete(course)" class="btn btn-sm btn-danger"><span class="oi oi-trash"></span>删除</button> </td> </tr> </table> ``` 以下是分页信息,暂时省略 ### C层初始化 course/index/index.component.ts ```typescript import {Component, OnInit} from '@angular/core'; import {Course} from '../../norm/entity/course'; import {Page} from '../../norm/page'; import {FormControl} from '@angular/forms'; import {Klass} from '../../norm/entity/Klass'; import {Teacher} from '../../norm/entity/Teacher'; @Component({ selector: 'app-index', templateUrl: './index.component.html', styleUrls: ['./index.component.sass'] }) export class IndexComponent implements OnInit { params = { name: new FormControl('') }; coursePage: Page<Course>; constructor() { } ngOnInit() { } onQuery() { } onSelectTeacher($event: Teacher) { } onSelectKlass($event: Klass) { } onDelete(course: Course) { } } ``` ### 单元测试初始化 course/index/index.component.spec.ts ```typescript beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [ IndexComponent ], imports: [ ReactiveFormsModule, ➊ FormsModule,➋ TestModule ➌ ] }) .compileComponents(); })); ``` * ➊ formControl指令 * ➋ (ngSubmit)方法 * ➌ app-teacher-select ![](https://img.kancloud.cn/31/0e/310edc660f97b23a7250816bff616e63_672x116.png) 单元测试提示不能解析app-klass-select。app-klass-select位于student模块中的KlassSelect组件中。但由于历史原因尚未生成KlassSelect组件对应的测试替身。 ## 迁移公共组件 我们之所以要将项目分成多个模块,有一个重要的原因是为了:惰性加载。将项目分离成多个模块可以将一个大型的项目分成多次被用户加载。这样可以使得一个较大的项目能够有着比较良好的使用体验。而这一切的前提是:各个模块互相独立。如若在当前Course模块中使用位于Student模块中的KlassSelect组件,则Course模块产生了对Student模块的依赖,也就无法独立于Student模块存在了。在实际的生产项目中往往把一些公用的组件放到一个单独的模块中。为此将 Student模块中的KlassSelect组件迁移到Core模块中。 剪切 ![](https://img.kancloud.cn/fd/a8/fda8eb7c0e28c391eb3852e989b636c0_591x349.png) 粘贴 ![](https://img.kancloud.cn/76/e3/76e3578a0ff3b750af32a28c9845cc85_608x229.png) 然后将此组件添加到Core模块的declarations中以及exports中,同时删除原Student模块declarations中对此组件的声明。 core/core.modult.ts ```typescript @NgModule({ declarations: [SelectComponent, MultipleSelectComponent, KlassSelectComponent✚], ... exports: [ SelectComponent, MultipleSelectComponent, KlassSelectComponent✚ ] ``` student/student.module.ts ```typescript import { KlassSelectComponent } from '../core/klass-select/klass-select.component'; ✘ @NgModule({ declarations: [AddComponent, KlassSelectComponent✘, IndexComponent, EditComponent], export class StudentModule { } ``` ### 新建组件测试替身 迁移完成后为其建立测试替身以便其被其它模块使用时能够优雅的进行单元测试。 ``` panjiedeMac-Pro:core-testing panjie$ ng g c klassSelect -s -t --skip-tests CREATE src/app/core/core-testing/klass-select/klass-select.component.ts (273 bytes) UPDATE src/app/core/core-testing/core-testing.module.ts (562 bytes) panjiedeMac-Pro:core-testing panjie$ ``` 初始化输入、输出并加入到测试控制器中: ```typescript import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core'; import {Klass} from '../../../norm/entity/Klass'; import {CoreTestingController} from '../core-testing-controller'; @Component({ selector: 'app-klass-select', template: ` <p> klass-select works! </p> `, styles: [] }) export class KlassSelectComponent implements OnInit { @Output() selected = new EventEmitter<Klass>(); ➊ @Input() klass: Klass; ➋ constructor(private controller: CoreTestingController) { this.controller.addUnit(this); ➌ } ngOnInit() { } } ``` * ➊ 对应原组件的输出 * ➋ 对应原组件的输入 * ➌ 添加到测试控制器中以便在单元测试中由测试控制器中获取该组件 在测试模块中将此组件替身输出: core/core-testing/core-testing.module.ts ```typescript exports: [ MultipleSelectComponent, KlassSelectComponent ✚ ], ... export class CoreTestingModule { } ``` ## 单元测试 准备好测试组件替身后将CoreTestingModule开入到课程列表组件测试文件中: course/index/index.component.spec.ts ```typescript imports: [ ReactiveFormsModule, TestModule, CoreTestingModule ✚ ] ``` ![](https://img.kancloud.cn/bd/50/bd50b0a562e978978e1c64b8071eac47_541x73.png) ```typescript TestModule, CoreTestingModule, RouterTestingModule✚ ] ``` ## V层中的? 引用了多个模块后再次执行单元测试,错误如下: ![](https://img.kancloud.cn/80/45/8045007178a23a3fdc29a17c0ab3bb4c_414x72.png) 此错误在说:在undefined上读取content时发生了错误。此错误产生的原因是由于在组件中使用了`xxxx.content`尝试获取数据,但在执行该语句时`xxxx`的值为undefined。 在组件的C层中并没有读取content属性,所以错误并不是在C层发生了。在V层中以content进行搜索发现有以下语句:`<tr *ngFor="let course of coursePage.content; index as index">`。该错误便是由此发生了。 这是由于:C层在初始化时只是声明了coursePage的类型,并未对其进行初始化,所以coursePage当前的值为undefined。在V层中尝试执行`coursePage.content`时便发生了`TypeError: Cannot read property 'content' of undefined`的错误。解决该问题的方法最少有两种:由于V层的渲染操作发生在C层的ngOnInit方法以后,所以第一种方法可以在ngOnInit中对coursePage进行初始化,此后在进行V层的渲染时coursePage的值并不为undefined,当然也就不会发生此错误了;除此以外angular也充分地考虑到了此问题,可以在V层中使用`?`来标注某个变量以表示:当此值为undefined时暂停渲染,当此值不为undefined时继续完成渲染。 course/index/index.component.html ```html <tr *ngFor="let course of coursePage?➊.content; index as index"> ``` * ➊ 在可能为undefined的变量后添加?以防止undefined错误。 在渲染V层时,除要规避在undefined上读取某个属性发生错误以外,还要规避类似的在null上读取某个属性发生错误。而以下代码则可能引发在null读取某个属性的错误: ```html <tr *ngFor="let course of coursePage.content; index as index"> <td>{{index + 1}}</td> <td>{{course.name}}</td> <td>{{course.teacher.name}}</td> ➊ <td>{{course.klass.name}}</td> <td> ``` * ➊ 在进行数据表的设计时,课程是可以不设置任课教师的,所以course.teacher的值可能为null。当获取的某个course没有设置任课教师时,则会发生在null读取name属性的错误。 修正如下: ```html <td>{{course.teacher?.name}}</td> ``` 最终单元测试通过: ![](https://img.kancloud.cn/ed/85/ed858e36b48fb9a46e2635bde87eead1_359x126.png) # 修正其它测试 由于变更了KlassSelectComponent的位置以及所属模块所以必然会引起其它的单元测试错误。逐个解决如下: ## 某组件同时存在于多个模块中 ``` student/AddComponent > should create Failed: Type KlassSelectComponent is part of the declarations of 2 modules: CoreModule and DynamicTestModule! Please consider moving KlassSelectComponent to a higher module that imports CoreModule and DynamicTestModule. You can also create a new NgModule that exports and includes KlassSelectComponent then import that NgModule in CoreModule and DynamicTestModule. ``` 在angular中某个组件不能同时存在于两个模块中(除非这两个模块不知道对方的存在)。这时由于在测试模块(文件)中将KlassSelectComponent声明到了declarations中。修正如下: student/add/add.component.spec.ts ```typescript declarations: [AddComponent, KlassSelectComponent✘], ``` student/index/index.component.spec.ts ```typescript declarations: [AddComponent, KlassSelectComponent✘], ``` core/klass-select/klass-select.component.spec.ts ```typescript beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [KlassSelectComponent, SelectComponent✚➊], imports: [CoreModule✘➋, HttpClientTestingModule] }) ``` * ➊ KlassSelectComponent依赖于原CoreModule的SelectComponent,此时两个组件位于同一个模块下,直接引用即可 * ➋ KlassSelectComponent当前已经属于CoreModule,不能再进行引用,否则将发生组件位于多模块的错误 最后加入其它依赖: ```typescript imports: [HttpClientTestingModule, ReactiveFormsModule✚] }) ``` 单元测试整体通过。 # 参考文档 | 名称 | 链接 | 预计学习时长(分) | | --- | --- | --- | | 源码地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step6.2.1](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step6.2.1) | - | | 安全导航运算符( ? )和空属性路径 | [https://www.angular.cn/guide/template-syntax#the-safe-navigation-operator----and-null-property-paths](https://www.angular.cn/guide/template-syntax#the-safe-navigation-operator----and-null-property-paths) | 10 |