本节由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) | - |
- 序言
- 第一章:Hello World
- 第一节:Angular准备工作
- 1 Node.js
- 2 npm
- 3 WebStorm
- 第二节:Hello Angular
- 第三节:Spring Boot准备工作
- 1 JDK
- 2 MAVEN
- 3 IDEA
- 第四节:Hello Spring Boot
- 1 Spring Initializr
- 2 Hello Spring Boot!
- 3 maven国内源配置
- 4 package与import
- 第五节:Hello Spring Boot + Angular
- 1 依赖注入【前】
- 2 HttpClient获取数据【前】
- 3 数据绑定【前】
- 4 回调函数【选学】
- 第二章 教师管理
- 第一节 数据库初始化
- 第二节 CRUD之R查数据
- 1 原型初始化【前】
- 2 连接数据库【后】
- 3 使用JDBC读取数据【后】
- 4 前后台对接
- 5 ng-if【前】
- 6 日期管道【前】
- 第三节 CRUD之C增数据
- 1 新建组件并映射路由【前】
- 2 模板驱动表单【前】
- 3 httpClient post请求【前】
- 4 保存数据【后】
- 5 组件间调用【前】
- 第四节 CRUD之U改数据
- 1 路由参数【前】
- 2 请求映射【后】
- 3 前后台对接【前】
- 4 更新数据【前】
- 5 更新某个教师【后】
- 6 路由器链接【前】
- 7 观察者模式【前】
- 第五节 CRUD之D删数据
- 1 绑定到用户输入事件【前】
- 2 删除某个教师【后】
- 第六节 代码重构
- 1 文件夹化【前】
- 2 优化交互体验【前】
- 3 相对与绝对地址【前】
- 第三章 班级管理
- 第一节 JPA初始化数据表
- 第二节 班级列表
- 1 新建模块【前】
- 2 初识单元测试【前】
- 3 初始化原型【前】
- 4 面向对象【前】
- 5 测试HTTP请求【前】
- 6 测试INPUT【前】
- 7 测试BUTTON【前】
- 8 @RequestParam【后】
- 9 Repository【后】
- 10 前后台对接【前】
- 第三节 新增班级
- 1 初始化【前】
- 2 响应式表单【前】
- 3 测试POST请求【前】
- 4 JPA插入数据【后】
- 5 单元测试【后】
- 6 惰性加载【前】
- 7 对接【前】
- 第四节 编辑班级
- 1 FormGroup【前】
- 2 x、[x]、{{x}}与(x)【前】
- 3 模拟路由服务【前】
- 4 测试间谍spy【前】
- 5 使用JPA更新数据【后】
- 6 分层开发【后】
- 7 前后台对接
- 8 深入imports【前】
- 9 深入exports【前】
- 第五节 选择教师组件
- 1 初始化【前】
- 2 动态数据绑定【前】
- 3 初识泛型
- 4 @Output()【前】
- 5 @Input()【前】
- 6 再识单元测试【前】
- 7 其它问题
- 第六节 删除班级
- 1 TDD【前】
- 2 TDD【后】
- 3 前后台对接
- 第四章 学生管理
- 第一节 引入Bootstrap【前】
- 第二节 NAV导航组件【前】
- 1 初始化
- 2 Bootstrap格式化
- 3 RouterLinkActive
- 第三节 footer组件【前】
- 第四节 欢迎界面【前】
- 第五节 新增学生
- 1 初始化【前】
- 2 选择班级组件【前】
- 3 复用选择组件【前】
- 4 完善功能【前】
- 5 MVC【前】
- 6 非NULL校验【后】
- 7 唯一性校验【后】
- 8 @PrePersist【后】
- 9 CM层开发【后】
- 10 集成测试
- 第六节 学生列表
- 1 分页【后】
- 2 HashMap与LinkedHashMap
- 3 初识综合查询【后】
- 4 综合查询进阶【后】
- 5 小试综合查询【后】
- 6 初始化【前】
- 7 M层【前】
- 8 单元测试与分页【前】
- 9 单选与多选【前】
- 10 集成测试
- 第七节 编辑学生
- 1 初始化【前】
- 2 嵌套组件测试【前】
- 3 功能开发【前】
- 4 JsonPath【后】
- 5 spyOn【后】
- 6 集成测试
- 7 @Input 异步传值【前】
- 8 值传递与引入传递
- 9 @PreUpdate【后】
- 10 表单验证【前】
- 第八节 删除学生
- 1 CSS选择器【前】
- 2 confirm【前】
- 3 功能开发与测试【后】
- 4 集成测试
- 5 定制提示框【前】
- 6 引入图标库【前】
- 第九节 集成测试
- 第五章 登录与注销
- 第一节:普通登录
- 1 原型【前】
- 2 功能设计【前】
- 3 功能设计【后】
- 4 应用登录组件【前】
- 5 注销【前】
- 6 保留登录状态【前】
- 第二节:你是谁
- 1 过滤器【后】
- 2 令牌机制【后】
- 3 装饰器模式【后】
- 4 拦截器【前】
- 5 RxJS操作符【前】
- 6 用户登录与注销【后】
- 7 个人中心【前】
- 8 拦截器【后】
- 9 集成测试
- 10 单例模式
- 第六章 课程管理
- 第一节 新增课程
- 1 初始化【前】
- 2 嵌套组件测试【前】
- 3 async管道【前】
- 4 优雅的测试【前】
- 5 功能开发【前】
- 6 实体监听器【后】
- 7 @ManyToMany【后】
- 8 集成测试【前】
- 9 异步验证器【前】
- 10 详解CORS【前】
- 第二节 课程列表
- 第三节 果断
- 1 初始化【前】
- 2 分页组件【前】
- 2 分页组件【前】
- 3 综合查询【前】
- 4 综合查询【后】
- 4 综合查询【后】
- 第节 班级列表
- 第节 教师列表
- 第节 编辑课程
- TODO返回机制【前】
- 4 弹出框组件【前】
- 5 多路由出口【前】
- 第节 删除课程
- 第七章 权限管理
- 第一节 AOP
- 总结
- 开发规范
- 备用