本节继续前一小节的单元测试,完成提交数据的单元测试部分。
# 初始化
在编写代码前先写清自己编写代码的功能以及实现功能的步骤是个非常好的开发习惯,这将使你在处理复杂的问题的时候显得得心应手:
klass/edit/edit.component.spec.ts
```
/**
* 数据更新测试,步骤:
* 1. 设置路由参数
* 2. 输入input的值
* 3. 点击提交扭钮:断言向预期的地址以对应的方法提交了表单中的数据
* 4. 断言跳转到''路由地址
*/
fit('更新数据测试', () => {
expect(component).toBeTruthy();
});
```
## 造轮子
测试方法需要设置input的值以值点击对应的提交按钮,由于此方法前面我们使用过,所以确定为重复的方法,故在testing/FormTest.ts中新造轮子如下:
```
/**
* 设置input的值
* @param fixture 夹具
* @param cssSelector CSS选择器
* @param value 要设置的值
* @return 成功true 失败false
*/
static setInputValue(fixture: ComponentFixture<any>, cssSelector: string, value: string): boolean {
const selectorElement = this.getSelectorElement(fixture, cssSelector);
if (isNull(selectorElement)) {
return false;
}
const htmlInputElement: HTMLInputElement = selectorElement.nativeElement;
htmlInputElement.value = value;
htmlInputElement.dispatchEvent(new Event('input'));
return true;
}
/**
* 获取button按钮,并点击
* @param fixture 夹具
* @param cssSelector CSS选择器
* @return 成功true 失败false
*/
static clickButton(fixture: ComponentFixture<any>, cssSelector: string): boolean {
const selectorElement = this.getSelectorElement(fixture, cssSelector);
if (isNull(selectorElement)) {
return false;
}
const htmlButtonElement: HTMLButtonElement = selectorElement.nativeElement;
htmlButtonElement.click();
return true;
}
/**
* 根据CSS选择器来获取夹具中Debug元素
* @param fixture 夹具
* @param cssSelector CSS选择器
* @return Debug元素
*/
static getSelectorElement(fixture: ComponentFixture<any>, cssSelector: string): DebugElement {
const debugElement: DebugElement = fixture.debugElement;
return debugElement.query(By.css(cssSelector));
}
```
在造上述轮子的时候我们发现两个轮子中也存在同样的方法,于是在轮子中又剥离中一个新轮子`getSelectorElement`。
## 完善测试
我们添加一个onSubmitTest方法用于测试更新操作,由于更新操作依赖于数据获取操作,所以该方法在数据成功获取后调用:
```
const testGetHttp = (id: number) => {
...
fixture.whenStable().then(() => {
...
onSubmitTest(1); ①
});
};
/**
* 数据更新测试,步骤:
* 1. 设置路由参数
* 2. 输入input的值
* 3. 点击提交扭钮:断言向预期的地址以对应的方法提交了表单中的数据
* 4. 断言跳转到''路由地址
*/
const onSubmitTest = (id: number) => {
FormTest.setInputValue(fixture, '#name', '测试更新班级');
FormTest.setInputValue(fixture, '#teacherId', '100');
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);
req.flush(null, {status: 204, statusText: 'No Content'});
httpTestController.verify(); ➊
});
};
```
* ① 获取要编辑的数据后,开始测试编辑数据
* ➊ httpTestController.verify(); 来保证所有的http请求都是符合我们的预期的。在执行此方法时如果还有其它的http请求,则会在控制台中打印异常信息。
由于一些异常仅仅会打印在控制台中而未反馈到测试界面上,所以我们在测试的过程中应该:**轻界面重控制台**
# 测试间谍SPY
我们看似完成了所有的操作,但来到klass/edit/edit.component.ts中却发现我们并不能够确认这行代码是否成功的被执行了:
klass/edit/edit.component.ts
```
/**
* 用户提交时执行的操作
*/
onSubmit(): void {
const data = {
name: this.formGroup.value.name,
teacher: {id: this.formGroup.value.teacherId}
};
this.httpClient.put(this.getUrl(), data)
.subscribe(() => {
this.router.navigateByUrl('', {relativeTo: this.route}); ①
}, () => {
console.error(`在${this.getUrl()}上的PUT请求发生错误`);
});
}
```
* ① 此行代码是否成功的触发我们并不清楚
要测试该方法是否成功的被执行了,我们使用上个小节刚刚讲过的方法。即:模拟一个和this.route一样具有`navigateByUrl`方法的服务,然后在`providers`中声明使用该模拟服务来替换原服务,最终我们去测试模拟服务中该方法是否被触发了。比如:
router-stub.ts
```
import {ActivatedRoute} from '@angular/router';
export class RouterStub {
path: string;
config: {relativeTo: ActivatedRoute};
navigateByUrl(path: string, config: {relativeTo: ActivatedRoute}): void {
this.path = path;
this.config = config;
}
}
```
然后我们使用`providers`来注入此`RouterStud`来替换原来的`Router`。最终我们来断言`RouterStud`中的`path`是否为`''`。为`''`则说明执行了此方法;仍然是`undefined`则说明未执行此方法。
klass/edit/edit.component.ts
```
providers: [
{provide: ActivatedRoute, useClass: ActivatedRouteStub},
{provide: Router, useClass: RouterStub}
]
...
req.flush(null, {status: 204, statusText: 'No Content'});
httpTestController.verify();
const router: RouterStub = TestBed.get(Router); ✚
expect(router.path).toEqual(''); ✚
});
```
除此以外angular来为我们提供了更为简单的测试方法 ---- 测试间谍
## 生成间谍
间谍的作用同我们刚刚建立的`stub`模拟服务一样,都是向测试的组件提供一个覆盖原服务的测试服务。但测试间谍的生成方法更加的简单,比如我们在使用`spy`方法来生成一个Router测试服务,则只需要:`const routerSpy = jasmine.createSpyObj<Router>('Router', ['navigateByUrl']);`即可。
它表示:创建一个拥有`navigateByUrl`方法的模拟`Router`的间谍服务。有了这个服务,我们直接将其添加到`providers`中即可:
klass/edit/edit.component.ts
```
beforeEach(async(() => {
const routerSpy = jasmine.createSpyObj<Router➋>('Router', ['navigateByUrl']);
TestBed.configureTestingModule({
declarations: [EditComponent],
imports: [
ReactiveFormsModule,
HttpClientTestingModule,
RouterTestingModule
],
providers: [
{provide: ActivatedRoute, useClass: ActivatedRouteStub},
{provide: Router, useValue➊: routerSpy}
]
})
.compileComponents();
}));
```
* ➊ 在使用RouterStub时,我们使用关键字`useClass`来表示:当组件需要具有Router功能的对象(服务)时,使用RouterStub`类`来实例化一个对象给它。
* ➊ 在使用routerSpy时,我们我们使用关键字`useValue`来表示 :当组件需要具有Router功能的对象(服务)时,将routerSpy的`值`给它。
* ➋ 使用`<Router>`表示该间谍的根据`Router`生成的并用于模拟`Router`功能的。
## 使用间谍断言
klass/edit/edit.component.ts
```
/**
* 数据更新测试,步骤:
* 1. 设置路由参数
* 2. 输入input的值
* 3. 点击提交扭钮:断言向预期的地址以对应的方法提交了表单中的数据
* 4. 断言跳转到''路由地址
*/
const onSubmitTest = (id: number) => {
FormTest.setInputValue(fixture, '#name', '测试更新班级');
FormTest.setInputValue(fixture, '#teacherId', '100');
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();
});
};
```
* ➊ 获取我们在providers中声明的spy对象。
* ➋ 断言:该间谍服务的navigateByUrl从未调用过。
* ➌ 断言:该间谍服务的navigateByUrl已经被调用过了。
至此,我们在间谍的帮助下快速的完成了编辑组件提交的测试工作。
# 参考文档
| 名称 | 链接 | 预计学习时长(分) |
| --- | --- | --- |
| 源码地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step3.4.4](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step3.4.4) | - |
| 使用间谍(spy)进行测试 | [https://www.angular.cn/guide/testing#testing-with-a-spy](https://www.angular.cn/guide/testing#testing-with-a-spy) |
| Spy | [https://jasmine.github.io/api/3.3/Spy.html](https://jasmine.github.io/api/3.3/Spy.html) | |
| Spy calls | [https://jasmine.github.io/api/3.3/Spy\_calls.html](https://jasmine.github.io/api/3.3/Spy_calls.html) |
| 依赖提供商 | [https://www.angular.cn/guide/dependency-injection-providers#dependency-providers](https://www.angular.cn/guide/dependency-injection-providers#dependency-providers) | 15 |
- 序言
- 第一章: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
- 总结
- 开发规范
- 备用