本节由M层到C层完成功能性代码的书写。 ## M层 ``` panjiedeMac-Pro:service panjie$ ng g s course CREATE src/app/service/course.service.spec.ts (333 bytes) CREATE src/app/service/course.service.ts (135 bytes) ``` 功能性代码: service/course.service.ts ```typescript import {Injectable} from '@angular/core'; import {Course} from '../norm/entity/course'; import {Observable} from 'rxjs'; import {HttpClient} from '@angular/common/http'; @Injectable({ providedIn: 'root' }) export class CourseService { private url = 'http://localhost:8080/Course'; constructor(private httpClient: HttpClient) { } /** * 保存课程 * @param course 课程 */ save(course: Course): Observable<Course> { return this.httpClient.post<Course>(this.url, course); } } ``` ### 单元测试 service/course.service.spec.ts ```javascript import {TestBed} from '@angular/core/testing'; import {CourseService} from './course.service'; import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing'; import {Course} from '../norm/entity/course'; describe('CourseService', () => { beforeEach(() => TestBed.configureTestingModule({ imports: [ HttpClientTestingModule ] })); it('should be created', () => { const service: CourseService = TestBed.get(CourseService); expect(service).toBeTruthy(); }); fit('save', () => { const service: CourseService = TestBed.get(CourseService); const testController = TestBed.get(HttpTestingController) as HttpTestingController; // 调用save方法被接收返回数据 const course = new Course(); let result: Course; service.save(course).subscribe((data) => { result = data; }); // 断言请求符合预期 const request = testController.expectOne('http://localhost:8080/Course'); expect(request.request.method).toEqual('POST'); expect(request.request.body).toEqual(course); // 数据返回符合预期 const returnCourse = new Course(); request.flush(returnCourse); expect(returnCourse).toBe(result); }); }); ``` ### 准备Stub替身 ``` panjiedeMac-Pro:service panjie$ ng g s CourseStub --skip-tests CREATE src/app/service/course-stub.service.ts (139 bytes) ``` service/course-stub.service.ts ```typescript import {Course} from '../norm/entity/course'; import {Observable} from 'rxjs'; export class CourseStubService { constructor() { } save(course: Course): Observable<Course> { return null; } } ``` ## 组件 在前面的章节中,完成了嵌套组件TeacherSelect的测试。除TeacherSelect组件外,课程新增组件中还嵌套了KlassMultiple组件。功能性代码如下: ### V层 加入班级多选组件 course/add/add.component.html ```html <app-klass-multiple-select (changed)="onKlassesChange($event)"></app-klass-multiple-select> <label><input type="checkbox"> 班级1</label> ✘ <label><input type="checkbox"> 班级2</label> ✘ ``` ### C层 实体中加入klasses字段。 norm/entity/course.ts ```typescript export class Course { ... klasses: Klass[]; constructor(data?: { id?: number, name?: string, teacher?: Teacher, klasses?: Klass[]✚}) { if (this.teacher) { ✘ ➊ if (data.teacher) { ✚ this.teacher = data.teacher; } if (data.klasses) { this.klasses = data.klasses; } } } ``` * ➊ 修正一处前面的书写错误。该错误是由于没有对Course进行充分的单元测试造成的 加入班级多选组件对应的onKlassesChange方法。 course/add/add.component.ts ```typescript export class AddComponent implements OnInit { ... klasses: Klass[]; ... onKlassesChange($event: Klass[]) { this.course.klasses = $event; } } ``` ## 嵌套班级多选组件测试 参考上一节中对多选组件的测试方案,在对嵌套班级多选组件测试前先进行一些准备。 ### 新建对应测试模块 ``` panjiedeMac-Pro:course panjie$ ng g m courseTesting CREATE src/app/course/course-testing/course-testing.module.ts (199 bytes) ``` ### 建立测试用控制器 >[info] 这里的控制器只是个后缀名称而已,表明此测试模块中的测试信息可以通过该类获取到。如果你喜欢其它的名称,也可以起成其它的名称。教程中只所以这样命名,完全是参考的angular官方库。 ``` panjiedeMac-Pro:course-testing panjie$ ng g class CourseTestingController --skip-tests CREATE src/app/course/course-testing/course-testing-controller.ts (41 bytes) ``` 参考CoreTestingController的代码,完善功能如下: course/course-testing/course-testing-controller.ts ```typescript export class CourseTestingController { /** * 存储组件、指令或管道 */ private units = new Array<any>(); constructor() { } /** * 添加单元(组件、指令或管道) * @param unit 单元 */ addUnit(unit: any): void { this.units.push(unit); } /** * 获取单元(组件、指令或管道) * @param clazz 类型 */ get(clazz: Clazz): any { let result: any = null; this.units.forEach((value) => { if (value.constructor.name === clazz.name) { result = value; } }); return result; } } /** * 定义一个Clazz类型,用于参数中接收 类、接口等 */ type Clazz = new(...args: any[]) => any; ``` ### 建立班级多选组件替身 ``` panjiedeMac-Pro:course-testing panjie$ ng g c KlassMultipleSelect --skip-tests -s➊ -t➋ CREATE src/app/course/course-testing/klass-multiple-select/klass-multiple-select.component.ts (299 bytes) UPDATE src/app/course/course-testing/course-testing.module.ts (331 bytes) ``` * ➊ 不单独生成sass样式(style)文件 * ➋ 不单独生成html模块(template)文件 在初始化中将组件本身添加到测试控制器中,以便在单元测试中被获取; 添加与原组件相同的input与output用与模块原组件的交互功能。 course/course-testing/klass-multiple-select/klass-multiple-select.component.ts ```typescript import {Component, OnInit} from '@angular/core'; import {CourseTestingController} from '../course-testing-controller'; @Component({ selector: 'app-klass-multiple-select', template: ` <p> klass-multiple-select works! </p> `, styles: [] }) export class KlassMultipleSelectComponent implements OnInit { @Output() changed = new EventEmitter<Klass[]>(); constructor(private controller: CourseTestingController) { this.controller.addUnit(this); } ngOnInit() { } } ``` ### 定制测试模块 若使KlassMultipleSelectComponent被其它模块使用,还需要将其添加到exports中。若使CourseTestingController被其它模块获取到,则需要将其添加到providers中。 course/course-testing/course-testing.module.ts ```typescript @NgModule({ declarations: [KlassMultipleSelectComponent], imports: [ CommonModule ], exports: [ KlassMultipleSelectComponent ], providers: [ CourseTestingController ] }) export class CourseTestingModule { } ``` ### 测试代码 course/add/add.component.spec.ts ```typescript imports: [ ReactiveFormsModule, TestModule, CourseTestingModule ✚ ] providers: [ {provide: CourseService, useClass: CourseStubService} ✚ ] ... fit('嵌入KlassMultipleSelect组件测试', () => { }); ``` 完成测试功能: course/add/add.component.spec.ts ```typescript fit('嵌入KlassMultipleSelect组件测试', () => { const courseTestController: CourseTestingController = TestBed.get(CourseTestingController); const klassMultipleSelectComponent: KlassMultipleSelectComponent = courseTestController.get(KlassMultipleSelectComponent); spyOn(component, 'onKlassesChange'); const klasses = [new Klass(null, null, null)]; klassMultipleSelectComponent.changed.emit(klasses); expect(component.onKlassesChange).toHaveBeenCalledWith(klasses); }); ``` ## 补充其它功能单元测试 更新onSubmit方法 course/add/add.component.ts ```typescript constructor(private formBuilder: FormBuilder, private courseService: CourseService✚) { } onSubmit() { this.courseService.save(this.course).subscribe((course) => { console.log(course); }); } ``` ### 单元测试 course/add/add.component.spec.ts ```typescript fit('ngOnInit', () => { }); fit('onTeacherSelect', () => { }); fit('onKlassesChange', () => { }); fit('onSubmit', () => { }); ``` 补充功能代码后如下: course/add/add.component.spec.ts ```typescript /** * 在beforeEach的组件初始化代码中。 * 当fixture.detectChanges();被首次执行时,会自动执行一次ngOnInit方法 */ fit('ngOnInit', () => { expect(component.formGroup).toBeDefined(); expect(component.course).toBeDefined(); }); fit('onTeacherSelect', () => { const teacher = new Teacher(null, null, null); component.onTeacherSelect(teacher); expect(component.course.teacher).toBe(teacher); }); fit('onKlassesChange', () => { const klasses = [new Klass(null, null, null)]; component.onKlassesChange(klasses); expect(component.course.klasses).toBe(klasses); }); fit('onSubmit', () => { const course = new Course(); component.course = course; const courseService: CourseService = TestBed.get(CourseService); const returnCourse = new Course(); spyOn(courseService, 'save').and.returnValue(of(returnCourse)); spyOn(console, 'log'); component.onSubmit(); expect(courseService.save).toHaveBeenCalledWith(course); expect(console.log).toHaveBeenCalledWith(returnCourse); }); ``` 至此,使用已学习过的知识完成了新增课程的前台基本功能。 # 单元测试 将所有的`fit`变成`it`,所有的`fdescribe`变成`describe`后对项目整体运行单元测试,以保障整个项目均是符合预期的。 ![](https://img.kancloud.cn/06/ab/06abcbb47d64392b0d475100142e4ebd_796x78.png) 提示在班级选择组件中找不到TeacherSelectService的提供者,这是由于对TeacherSelect的替身组件进行了升级:在该组件中装入了一对一的服务造成的。解决的方法有两种:1. 直接使用provide的方法在测试中提供该一对一服务。 2. 引入该一对一服务所在的模块。 经过查找最终发现该测试的位置竟然位于测试模块下: ![](https://img.kancloud.cn/8f/db/8fdb1d4706b52cdd0cefc941b4604836_501x305.png) 这是由于在生成替身组件时没有跳过测试文件造成的,为此将其删除即可。删除后重新执行单元测试: ![](https://img.kancloud.cn/c4/29/c429c73836e9cead8729a3b3012a9d91_1177x125.png) # 总结 本节完成的功能性代码主要有三点:1.完成了M层的开发。 2.在C层中嵌套班级多选组件。 3.完成了课程新增组件的功能开发。大部分的精力与时间都在组织单元测试上。这初步看起来好像浪费了很长的时间,其实不尽然。在生产环境中,不可能整个项目都是简简单单的CURD,也不可能完全是由你一个人完成的。单元测试可以保障在前后台分离的项目中,不依赖于后台接口的完成度而进行独立的开发;单元测试也可以保障在前台其它所依赖的模块未完成时进行本模块的开发。以当前课程新增功能为例:后台尚未启动,但前台已经完成了功能性的开发,这证明了前台是可以脱离后的支持而独力开发的。 其实还有一种开发思路。仍以当前课程新增功能为例。当前功能依赖于班级多选组件,在开发流程上,我们习惯性的先开发了班级多选组件,而后在此基础上完成了当前课程新增功能。其实在单元测试的支持下,完全不必先开发班级多选组件,而只需要规定班级多选组件的接口(input\output),然后按此接口初始化班级多选组件的替身即可。这点可以使用删除当前已完成的班级多选组件来验证:班级多选组件被删除后,我们仅需要于CourseModule中删除对其的声明,如果在单元测试中不小心引用了该组件,仅需要将引用改为该组件的替身。班级多选组件被删除后,当前课程新增的所有功能同样可以通过单元测试。 # 参考文档 | 名称 | 链接 | 预计学习时长(分) | | --- | --- | --- | | 源码地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step6.1.5](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step6.1.5) | - |