至此,一个具有完整输入、输出的组件便已经被我们骄傲的开发完毕了。但请考虑以下问题:
* ① 新增组件的输入功能后,是否对本组件的历史功能产生了影响
* ② 将新增组件应用于第三方组件中,是否对第三方组件的功能产生了影响
如果我们对其产生了影响那么你认为当前都产生了什么影响 ,产生的原因又是什么,同时又是计划如何修正的。
# 会说话的代码
如果你仅凭想像便给出了自己的答案,那么无论你的答案是什么,都将是苍白而无力的。在软件开发的领域里没有实践就没有发言权,在没有真实的实践以前,任何主观的错误预计都是耍流氓。而单元测试就不会说谎。在此,我们不防借助单元测试来回答一下前面的问题。
#### 测试本组件
找到klass/teacher-select/teacher-select.component.spec.ts并把`describe`修改为`fdescribe`,然后运行`ng test`来观察会发生什么。
![](https://img.kancloud.cn/64/e8/64e8dce5526bc82fe69aa24f1e95bf27_826x192.png)
```
LOG: 'data emit', Teacher{id: 1, name: '潘杰', username: 'panjie', email: undefined, sex: undefined}
Chrome 78.0.3904 (Mac OS X 10.13.6): Executed 0 of 13 SUCCESS (0 secs / 0 secs)
LOG: '潘杰'
Chrome 78.0.3904 (Mac OS X 10.13.6): Executed 1 of 13 SUCCESS (0 secs / 0.095 secs)
LOG: '张喜硕'
Chrome 78.0.3904 (Mac OS X 10.13.6): Executed 1 of 13 SUCCESS (0 secs / 0.095 secs)
Chrome 78.0.3904 (Mac OS X 10.13.6): Executed 2 of 13 (skipped 11) SUCCESS (0.15 secs / 0.121 secs)
TOTAL: 2 SUCCESS
TOTAL: 2 SUCCESS
```
这说明当前组件新功能的加入未对`组件弹出器`及`获取教师列表后选择教师`功能造成影响。随后我们打开[http://localhost:4200/klass/add](http://localhost:4200/klass/add)测试相关功能运行正常。
**测试完成后,将`fdescribe`恢复为`describe`**
#### 测试班级添加组件
我们再打开klass/add/add.component.spec.ts,并把`describe`修改为`fdescribe`,然后运行`ng test`来观察会发生什么:
![](https://img.kancloud.cn/c0/12/c012d2ce849df89b146ba8cb3a0ceaa9_482x252.png)
```
Chrome 78.0.3904 (Mac OS X 10.13.6): Executed 3 of 13 (3 FAILED) (skipped 10) ERROR (0.228 secs / 0.201 secs)
```
这说明:当前组件已被变更,而变更后无法满足历史的单元测试要求或未动该组件的变更进行测试。事实也的确如此,我们需要在班级新增、班级编辑组件中引入了选择教师组件,但却没有对引入教师组件后原组件的功能是否正常进行测试。
## 修正错误
当一个组件A依赖于其它组件B时,在进行测试的过程中也需要对B组件进行声明。打开klass/add/add.component.spec.ts
```
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [AddComponent, TeacherSelectComponent✚],
imports: [
FormsModule,
ReactiveFormsModule,
HttpClientTestingModule
]
})
.compileComponents();
}));
```
#### 测试
![](https://img.kancloud.cn/39/df/39dfe2b9fbdf74e3caac39ed4e194efb_673x94.png)
提示找有找到`Router`的`provider`,则加入`RouterTestingModule`
```
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [AddComponent, TeacherSelectComponent],
imports: [
FormsModule,
ReactiveFormsModule,
HttpClientTestingModule,
RouterTestingModule ✚
]
})
.compileComponents();
}));
```
再测试
![](https://img.kancloud.cn/ae/32/ae32b162213af4ad3a1143a2bce9a7b7_911x131.png)
此时基础的依赖错误提示已经完全消除,而上图得到的便是一个真真切切的错误了。此错误表示:对该组件进行变更(引入了选择教师组件)后,对原组件的正常功能产生了影响。
提示信息再说:我们预测应该得到2,但实际上却得到了null。我们此时可以访问[http://localhost:4200/klass/add](http://localhost:4200/klass/add)来验证单元测试抛出的错误信息是否是真真切切有帮助的。
![](https://img.kancloud.cn/97/f9/97f91c533b60a3d0f40805c20102c777_595x214.png)
通过点击测试我们发现当点击`保存`按钮时并没有进行数据提交,而是在控制台中报了以上错误。
### 错误
根据单元测试的提示,我们来到测试文件79行所在的测试方法:
```
it('测试V层向C层绑定', () => {
expect(component).toBeTruthy();
fixture.whenStable().then(() => {
const debugElement: DebugElement = fixture.debugElement;
const nameElement = debugElement.query(By.css('#name'));
const nameInput: HTMLInputElement = nameElement.nativeElement;
nameInput.value = 'test2';
nameInput.dispatchEvent(new Event('input'));
expect(component.name.value).toBe('test2');
const teacherIdElement = debugElement.query(By.css('#teacherId'));
const teacherIdInput: HTMLInputElement = teacherIdElement.nativeElement;
teacherIdInput.value = '2';
teacherIdInput.dispatchEvent(new Event('input'));
expect(component.teacherId.value).toBe(2);
});
});
```
该方法是通过设置input的值来达到改变teacherId的目的。但引入选择教师组件后,已经没有teacherId这个input了,所以后续的测试代码当然也就随着发生了问题了。
### 修正组件功能
此时我们来到klass/add/add.component.ts中,发现原来我们在前面引入选择教师组件后并没有增加相应的功能。这也就是难怪该单元测试会报错了。 下面我们对功能进行修正,并重新修正单元测试。
```
teacherId: FormControl; ✘
teacher: Teacher; ✚
...
ngOnInit() {
this.name = new FormControl('');
this.teacherId = new FormControl(); ✘
}
...
const klass = new Klass(undefined, this.name.value,
new Teacher(parseInt(this.teacherId.value, 10), undefined, undefined) ✘
this.teacher ✚
);
...
/**
* 当选择某个教师时触发
* @param {Teacher} teacher 教师
*/
onTeacherSelected(teacher: Teacher) {
console.log(teacher); ✘
this.teacher = teacher; ✚
}
```
### 修正单元测试
① 删除设置teacherId这个input的测试代码
② 测试当选择教师组件数据变更后,点击保存按钮触发了正确的HTTP请求
> 该部分代码重构有一定的难度,第一次阅读时可忽略。
部分代码如下:
```
/**
* 设置表单数据
* 点击按钮发起请求
* 断言:请求地址、请求方法、发送的数据
*/
it('保存按钮点击后,提交相应的http请求', () => {
httpTestingController = TestBed.get(HttpTestingController);
expect(component).toBeTruthy();
component.name.setValue('test3');
component.teacher = new Teacher(2, null, null, null); ✚
fixture.whenStable().then(() => {
const debugElement: DebugElement = fixture.debugElement;
const submitButtonElement = debugElement.query(By.css('button'));
const submitButton: HTMLButtonElement = submitButtonElement.nativeElement;
submitButton.click();
const req = httpTestingController.expectOne('http://localhost:8080/Klass');
expect(req.request.method).toEqual('POST');
const klass: Klass = req.request.body.valueOf();
expect(klass.name).toEqual('test3');
expect(klass.teacher.id).toEqual(2); ✚
req.flush(null, {status: 201, statusText: 'Accepted'});
});
```
整体代码如下:
```
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {AddComponent} from './add.component';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {DebugElement} from '@angular/core';
import {By} from '@angular/platform-browser';
import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing';
import {Klass} from '../../norm/entity/Klass';
import {TeacherSelectComponent} from '../teacher-select/teacher-select.component';
import {RouterTestingModule} from '@angular/router/testing';
import {Teacher} from '../../norm/entity/Teacher';
fdescribe('Klass/AddComponent', () => {
let component: AddComponent;
let fixture: ComponentFixture<AddComponent>;
let httpTestingController: HttpTestingController;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [AddComponent, TeacherSelectComponent],
imports: [
FormsModule,
ReactiveFormsModule,
HttpClientTestingModule,
RouterTestingModule
]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AddComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
/**
* 测试C层向V层数据绑定
* 在C层中使用setValue方法对表单项赋值
* 重新渲染V层后,使用CSS选择器来获取元素
* 获取元素的值并断言
*/
it('测试C层向V层数据绑定', () => {
expect(component).toBeTruthy();
component.name.setValue('test');
fixture.detectChanges();
fixture.whenStable().then(() => {
const debugElement: DebugElement = fixture.debugElement;
const nameElement = debugElement.query(By.css('#name'));
const nameInput: HTMLInputElement = nameElement.nativeElement;
expect(nameInput.value).toBe('test');
});
});
/**
* 测试V层向C层绑定
* 获取V层的元素,并设置元素的值
* 断言在C层中获取到了元素的值
*/
it('测试V层向C层绑定', () => {
expect(component).toBeTruthy();
const debugElement: DebugElement = fixture.debugElement;
const nameElement = debugElement.query(By.css('#name'));
const nameInput: HTMLInputElement = nameElement.nativeElement;
nameInput.value = 'test2';
nameInput.dispatchEvent(new Event('input'));
expect(component.name.value).toBe('test2');
});
/**
* 设置表单数据
* 点击按钮发起请求
* 断言:请求地址、请求方法、发送的数据
*/
it('保存按钮点击后,提交相应的http请求', () => {
httpTestingController = TestBed.get(HttpTestingController);
expect(component).toBeTruthy();
component.name.setValue('test3');
component.teacher = new Teacher(2, null, null, null);
fixture.whenStable().then(() => {
const debugElement: DebugElement = fixture.debugElement;
const submitButtonElement = debugElement.query(By.css('button'));
const submitButton: HTMLButtonElement = submitButtonElement.nativeElement;
submitButton.click();
const req = httpTestingController.expectOne('http://localhost:8080/Klass');
expect(req.request.method).toEqual('POST');
const klass: Klass = req.request.body.valueOf();
expect(klass.name).toEqual('test3');
expect(klass.teacher.id).toEqual(2);
req.flush(null, {status: 201, statusText: 'Accepted'});
});
});
});
```
## 测试编辑组件并进行修正
参考新增班级的代码,我们修正编辑班级代码如下:
klass/edit/edit.component.html
```
<h3>编辑班级</h3>
<form (ngSubmit)="onSubmit()" [formGroup]="formGroup">
<label for="name">名称:<input id="name" type="text" formControlName="name"/></label>
<label for="teacherId">教师:<app-teacher-select *ngIf="teacher" id="teacherId" [teacher]="teacher" (selected)="onSelected($event)"①></app-teacher-select></label>
<button>更新</button>
</form>
```
klass/edit/edit.component.ts
```
import {Component, OnInit} from '@angular/core';
import {FormControl, FormGroup} from '@angular/forms';
import {ActivatedRoute, Router} from '@angular/router';
import {HttpClient} from '@angular/common/http';
import {Klass} from '../../norm/entity/Klass';
import {Teacher} from '../../norm/entity/Teacher';
@Component({
selector: 'app-edit',
templateUrl: './edit.component.html',
styleUrls: ['./edit.component.sass']
})
export class EditComponent implements OnInit {
formGroup: FormGroup;
teacher: Teacher;
private url: string;
constructor(private route: ActivatedRoute,
private router: Router,
private httpClient: HttpClient) {
}
private getUrl(): string {
return this.url;
}
/**
* 加载要编辑的班级数据
*/
loadData(): void {
this.httpClient.get(this.getUrl())
.subscribe((klass: Klass) => {
this.formGroup.setValue({name: klass.name});
this.teacher = klass.teacher;
}, () => {
console.error(`${this.getUrl()}请求发生错误`);
});
}
ngOnInit() {
this.formGroup = new FormGroup({
name: new FormControl(),
});
this.route.params.subscribe((param: { id: number }) => {
this.setUrlById(param.id);
this.loadData();
});
}
/**
* 用户提交时执行的操作
*/
onSubmit(): void {
const data = {
name: this.formGroup.value.name,
teacher: this.teacher
};
this.httpClient.put(this.getUrl(), data)
.subscribe(() => {
this.router.navigateByUrl('/klass');
}, () => {
console.error(`在${this.getUrl()}上的PUT请求发生错误`);
});
}
/**
* 选中某个教师时
* @param teacher 教师
*/
onSelected(teacher: Teacher): void {
this.teacher = teacher;
}
private setUrlById(id: number): void {
this.url = `http://localhost:8080/Klass/${id}`;
}
}
```
#### 单元测试
修正单元测试如下(后面好要讲相关知识,第一遍可略过):
klass/edit/edit.component.spec.ts
```
import {async, ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';
import {EditComponent} from './edit.component';
import {ReactiveFormsModule} from '@angular/forms';
import {RouterTestingModule} from '@angular/router/testing';
import {ActivatedRoute, Router} from '@angular/router';
import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing';
import {ActivatedRouteStub} from './activated-route-stub';
import {Klass} from '../../norm/entity/Klass';
import {Teacher} from '../../norm/entity/Teacher';
import {FormTest} from '../../testing/FormTest';
import SpyObj = jasmine.SpyObj;
import {Test} from 'tslint';
import {TeacherSelectComponent} from '../teacher-select/teacher-select.component';
describe('klass EditComponent', () => {
let component: EditComponent;
let fixture: ComponentFixture<EditComponent>;
beforeEach(async(() => {
const routerSpy = jasmine.createSpyObj<Router>('Router', ['navigateByUrl']);
TestBed.configureTestingModule({
declarations: [EditComponent, TeacherSelectComponent],
imports: [
ReactiveFormsModule,
HttpClientTestingModule,
RouterTestingModule
],
providers: [
{provide: ActivatedRoute, useClass: ActivatedRouteStub},
{provide: Router, useValue: routerSpy}
]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(EditComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
/**
* 组件初始化
* 发送路由参数
* 断言发起了HTTP请求
* 断言请求的方法为PUT
*/
it('should create', () => {
expect(component).toBeTruthy();
let route: ActivatedRouteStub;
route = TestBed.get(ActivatedRoute);
route.subject.next({id: 1});
testGetHttp(1);
});
/**
* 测试组件发起的GET请求
* 断言请求地址及方法
* 返回数据后,断言input项成功绑定返回数据
* @param id 请求的班级ID
*/
const testGetHttp = (id: number) => {
const httpTestingController: HttpTestingController = TestBed.get(HttpTestingController);
const req = httpTestingController.expectOne(`http://localhost:8080/Klass/${id}`);
expect(req.request.method).toEqual('GET');
req.flush(new Klass(id, '测试编辑班级', new Teacher(1, null, null)));
fixture.whenStable().then(() => {
expect(FormTest.getInputValueByFixtureAndCss(fixture, '#name')).toEqual('测试编辑班级');
onSubmitTest(1);
});
};
/**
* 数据更新测试,步骤:
* 1. 设置路由参数
* 2. 输入input的值
* 3. 点击提交扭钮:断言向预期的地址以对应的方法提交了表单中的数据
* 4. 断言跳转到''路由地址
*/
const onSubmitTest = (id: number) => {
FormTest.setInputValue(fixture, '#name', '测试更新班级');
component.teacher = new Teacher(100, null, null, null);
fixture.whenStable().then(() => {
FormTest.clickButton(fixture, 'button');
const httpTestController: HttpTestingController = TestBed.get(HttpTestingController);
const req = httpTestController.expectOne(`http://localhost:8080/Klass/${id}`);
expect(req.request.method).toEqual('PUT');
const klass: Klass = req.request.body.valueOf();
expect(klass.name).toEqual('测试更新班级');
expect(klass.teacher.id).toEqual(100);
const routerSpy: SpyObj<Router> = TestBed.get(Router);
expect(routerSpy.navigateByUrl.calls.any()).toBe(false);
req.flush(null, {status: 204, statusText: 'No Content'});
expect(routerSpy.navigateByUrl.calls.any()).toBe(true);
httpTestController.verify();
});
};
});
```
# 总结
单元测试是会说话的代码,它能够自动判断在新增或修改一些功能后本组件是否按原预期正常运行。如果偏离了原预期,将会自动发出警告。单元测试是保障软件质量的重要手段,是软件开发中非常重要的一环。
# 参考文档
| 名称 | 链接 | 预计学习时长(分) |
| --- | --- | --- |
| 源码地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step3.5.6](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step3.5.6) | - |
| 带有输入输出参数组件的测试 | [https://www.angular.cn/guide/testing#component-with-inputs-and-outputs](https://www.angular.cn/guide/testing#component-with-inputs-and-outputs) | 15 |
| 位于测试宿主中的组件| [https://www.angular.cn/guide/testing#component-inside-a-test-host](https://www.angular.cn/guide/testing#component-inside-a-test-host) | 10 |
- 序言
- 第一章: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
- 总结
- 开发规范
- 备用