实践是检验真理的唯一标准。无论什么样的选手,单元测试做的再好也难免会有想不到的地方。此时便需要集成测试来补刀了。什么是集成测试呢?简单来说就是把几个小的模块组装到一起,或是把一些单元测试的小的粒度组合到一起进行测试。最简单最不可靠的集成测试的方法便是本教程中采用的:人为验证法。angular其实为我们提供了强大的集成测试工具,在angular中又被称为`端对端的测试`,即` End to end `,由于英文的`2(two)`与`to`同音,所以又被称为`e2e`。在angular的根目录中为我们内置了e2e的测试样例,我们可以通过`ng e2e`或`ng e`来启动它们。
> 你可能由于网络原因导该启动不成功,具体可参考:[https://segmentfault.com/a/1190000021216402](https://segmentfault.com/a/1190000021216402)解决。
e2e测试和单元测试不同,单元测试需要关注代码的执行情况,需要对变量的值,是否按我们预期的参数进行调用,函数的返回值等分别进行断言。而e2e测试仅停留在界面上,通过模拟打开某个地址,模拟人为的操作最后断言界面会产生什么样的效果。受篇幅、教程难度设计及笔者水平的限制,在本教程中只求带领大家接触了解下更自动化的e2e测试,力求起到抛砖引玉的效果。
# 了解e2e测试
首次启动e2e时,其将为我们下载最新的驱动,此过程的耗时取决于我们的网络速度。e2e测试正式启动后,将查找项目根目录下的e2e文件夹下的src文件夹下的以`.e2e-sppec.ts`结尾的文件,比如angular在初始化时为我们自动生成的`app.e2e-spec.ts`
![](https://img.kancloud.cn/b2/41/b241c440e3935cb02ab8bc8f44e050b4_408x257.png)
在angular为我们生成的示例中`app.po.ts`及`app.e2e-spec.ts`两个文件相互配合使用:`app.po.ts`用于获取(设置)应用的值,而`app.e2e-spec.ts`则负责断言。
e2e/src/app.po.ts
```
import { browser, by, element } from 'protractor';
export class AppPage { ①
navigateTo() { ②
return browser.get(browser.baseUrl) as Promise<any>; ③
}
getTitleText() {
return element(by.css('app-root .content span')).getText() as Promise<string>; ④
}
}
```
* ① 定义类名并export(否则其它文件无法调用它,也就失去了配合`app.e2e-spec.ts`的作用
* ② 定义方法
* ③ 打开浏览器首页
* ④ 通过css选择器,找到`app-root .content span`的`text 文本值`
e2e/src/app.e2e-spec.ts
```
import { AppPage } from './app.po';
import { browser, logging } from 'protractor';
describe('workspace-project App①', () => {
let page: AppPage;
beforeEach(() => { ②
page = new AppPage();
});
it('should display welcome message', () => { ③
page.navigateTo(); ④
expect(page.getTitleText()).toEqual('web-app app is running!'); ⑤
});
afterEach(async➋ () => { ➊
// Assert that there are no errors emitted from the browser
const logs = await browser.manage().logs().get(logging.Type.BROWSER); ➌
expect(logs).not.toContain(jasmine.objectContaining({ ➍
level: logging.Level.SEVERE,
} as logging.Entry));
});
});
```
* ① 与单元测试一样,为此测试启个好记的名字
* ② 每次执行测试用例前,执行1次本方法
* ③ 测试用例
* ④ 调用辅助类中的方法,打开应用首页
* ⑤ 断言辅助类获取的页面元素内容为`web-app app is running!`
* ➊ 在每个测试用例执行完毕后执行
* ➋ 声明该测试用异步测试
* ➌ 获取浏览器报的错误
* ➍ 断言错误列表中的错误类型没有`logging.Level.SEVERE`
由于我们的项目早已不是angular生成的原始项目,所以在运行此测试时会发生如下错误:
![](https://img.kancloud.cn/55/37/5537d85dea35319835724ff348369f71_1249x151.png)
它在说通过相应的CSS选择器未找到任何的元素,我们对应修正为:
e2e/src/app.po.ts
```
getTitleText() {
return element(by.css('app-root .content span')).getText() as Promise<string>; ✘
return element(by.css('app-welcome h1')).getText() as Promise<string>; ✚
}
```
e2e/src/app.e2e-spec.ts
```
import {browser, logging} from 'protractor';
...
it('should display welcome message', () => {
browser.sleep(1000); ➊
page.navigateTo();
browser.sleep(2000); ➊
expect(page.getTitleText()).toEqual('web-app app is running!'); ✘
expect(page.getTitleText()).toEqual('欢迎使用河北工业大学教务管理系统'); ✚
browser.sleep(2000); ➊
});
```
* ➊ 为了更清晰的观察测试执行的过程,每执行1步我们让浏览器小睡一会
最后打开终端进入项目文件夹,执行`ng e2e`来启动集成测试,网络畅通的情况下我们将看到此测试自动打开chrome执行相应的测试程序,测试完成后主动的半闭浏览器以结束测试。
![](https://img.kancloud.cn/2e/8d/2e8d9b7a30ca8dd2588097f8f2e9b204_499x211.png)
了解过程到此结束,如果你感觉到意犹未尽,可以来到protractortest的[官网](https://www.protractortest.org/#/)进一步地学习。
# 添加路由
与添加其它的路由相同,打开student模块的路由文件student-routing.module.ts,并添加如下路由:
```
const routes: Routes = [
{
path: 'add',
component: AddComponent
},
{ ✚
path: '',
component: IndexComponent
}
];
```
# 添加菜单项
继续打开nav组件,在菜单中添加'学生管理'菜单项:
nav/nav.component.ts
```
ngOnInit() {
this.title = '教务管理系统';
this.menus.push({url: 'teacher', name: '教师管理'});
this.menus.push({url: 'klass', name: '班级管理'});
this.menus.push({url: 'student', name: '学生管理'}); ✚
}
```
使用`ng serve --open`启动应用,启动浏览器控制台,点击`学生管理`菜单并查看报错信息:
![](https://img.kancloud.cn/47/0f/470fa47ceaea3c251ede48059ea7167e_1042x116.png)
# 添加依赖
按错误提示增加student模拟的依赖:
student/student.module.ts
```
@NgModule({
declarations: [AddComponent, KlassSelectComponent, IndexComponent],
imports: [
CommonModule,
StudentRoutingModule,
ReactiveFormsModule,
FormsModule, ✚
CoreModule
]
})
export class StudentModule {
}
```
点击测试:
![](https://img.kancloud.cn/fb/70/fb70cd838287aaf654f4edc093394fc5_910x259.png)
错误类型为网络错误,此时便可以启动后台来进一步进行其它功能的验证了。
# 启动后台
使用你最喜欢的方式来启动后台。
# 测试并修正其它内容
在分模块开发的情况下,若想保障各个模块间的有效联通是比较困难的事情。由于在开发过程中每个模块将分配给不同的团队成员开发,所以学生列表组件与新增学生组件可能是同步开发的。而各个模块间的正常跳转的前提则是:预跳转的模块是存在的。
## index组件 -> 新增组件
student/index/index.component.html
```
...
</form>
<div class="row">
<div class="col text-right">
<a class="btn btn-primary" routerLink="./add">新增学生</a>
</div>
</div>
<table>
...
```
接下来按 新增教师 -> 新增班级 -> 新增学生的顺序测试添加学生功能:
![](https://img.kancloud.cn/8c/0c/8c0c53c0084b3008d50c71992d718537_772x346.gif)
测试过程中我们发现以下问题:
* 标题应该由 编辑教师 修正为 新增学生
* 新增学生完成后,点击保存按钮,界面未跳转
* 学生管理列表组件的 table 没有添加bootstrap样式
## 修正标题
请自行将新装学生组件中 编辑老师 修正为 新增学生
## 新增组件 -> index组件
新增组件中点击保存按钮后,应该成功跳转到index界面,请自动完成
## 增加bootstrap样式
为index组件的table增加bootstrap样式,请自行完成
# 功能测试
完成了基础样式修正后,开发进行功能测试。
## 综合查询
综合查询主要对姓名、学号、班级进行查询,要使测试正常进行,则需要准备不同姓名、不同学号、不同班级的学生。
![](https://img.kancloud.cn/04/cd/04cd1f73d62f3fb205e356f3cb6d0e75_1138x219.png)
测试如下:
![](https://img.kancloud.cn/dd/d4/ddd41bfbf1a3949e42cd481f56baeeb3_950x297.gif)
## 全选、单选
![](https://img.kancloud.cn/f9/03/f90338944145048d86570b1caddc62fb_878x193.gif)
## 分页
成功的测试分页,则需要不少于5页的学生数据,在开始测试前先新增多条测试学生:
![](https://img.kancloud.cn/5d/47/5d47ed6582714f3a7c69c4eb88685bc2_876x122.png)
当前共7页数据,开始进行测试
![](https://img.kancloud.cn/45/57/4557db11d810cb7169d7fb1b0a2e34f9_878x193.gif)
发现两个问题:
* 页码应该为1基,实际却为0基
* 点击其它页码时却跳转到了首页
第一个问题修正相对简单,请自行完成。
第二个问题是由于我们在页码中使用了`a`标签,然后`a`标签中定义了`href="#"`引起的。在一般的WEB应用中,我们习惯性的使用`href="#"`来表示当点击该a标签时不进行任何跳转。但在`single page web application(SPA) 单页面WEB应用`中就不一样了。在单页面WEB应用中,虽然浏览器的导航栏也会按照用户的点击进行变更,但却并没有重新发起页面加载请求。这种变更是通过调用浏览器相关的API来实现的,而非用户点击了需要跳转的a标签。在学生管理中,虽然浏览器显示的地址为`localhost/student`但angular很清晰的明了:当前系统的实际请求地址为:`localhost`,遇到`href="#"`实际对应的地址应该为:`localhost/#`。而我们所期待的却是`localhost/student/#`。猜出了原因,那么解决方案也就随着而来了。
即然不能使用`href="#"`,那么我们将其删除好了:
student/index/index.component.html
```
<ul class="pagination">
<li class="page-item" [ngClass]="{'disabled': params.page === 0}" (click)="onPage(0)">
<span class="page-link">首页</span>
</li>
<li class="page-item" [ngClass]="{'disabled': params.page === 0}" (click)="onPage(params.page - 1)">
<span class="page-link">上一页</span>
</li>
<li class="page-item" [ngClass]="{'active': params.page === page}" *ngFor="let page of pages" (click)="onPage(page)">
<a class="page-link" *ngIf="page !== params.page">{{page + 1}}</a>
<span class="page-link" *ngIf="page === params.page">{{page + 1}}<span class="sr-only">(current)</span></span>
</li>
<li class="page-item" [ngClass]="{'disabled': params.page === pageStudent.totalPages - 1}" (click)="onPage(params.page + 1)">
<a class="page-link">下一页</a>
</li>
<li class="page-item" [ngClass]="{'disabled': params.page === pageStudent.totalPages - 1}" (click)="onPage(pageStudent.totalPages - 1)">
<a class="page-link">尾页</a>
</li>
</ul>
```
![](https://img.kancloud.cn/db/d5/dbd5a56d6bee8482a6ca47065c72ef76_878x193.gif)
删除`href="#"`后的确修正了前面的跳转首页问题,但分页表现却并不完美。三个新问题又被暴露了出来:
* 首页 上一页 的样式是我们期望的,但其它页码的样式却不行。
* 以前将鼠标移到分页按钮上的时候,会有个 小手 出现,现在没有了。
* 当前页为第1页时,点击上一页仍生效
* 当前页为最后1页时,点击下一页仍生效
下面分别对上述问题进行修正:
### 样式问题
其它页码的样式不同于首页、上一页是由于我们在首页、上一页中使用为`span`标签,也在页码中使用的`a`标签。使用`a`标签有个默认的好处:当`a`标签存在`href`属性时,鼠标移上去将自动变成 小手 的样子。为了使格式统一,首先将`a`标签全部换成`span`标签。
student/index/index.component.html
```
<ul class="pagination">
<li class="page-item" [ngClass]="{'disabled': params.page === 0}" (click)="onPage(0)">
<span class="page-link">首页</span>
</li>
<li class="page-item" [ngClass]="{'disabled': params.page === 0}" (click)="onPage(params.page - 1)">
<span class="page-link">上一页</span>
</li>
<li class="page-item" [ngClass]="{'active': params.page === page}" *ngFor="let page of pages" (click)="onPage(page)">
<span class="page-link" *ngIf="page !== params.page">{{page + 1}}</span>
<span class="page-link" *ngIf="page === params.page">{{page + 1}}<span class="sr-only">(current)</span></span>
</li>
<li class="page-item" [ngClass]="{'disabled': params.page === pageStudent.totalPages - 1}" (click)="onPage(params.page + 1)">
<span class="page-link">下一页</span>
</li>
<li class="page-item" [ngClass]="{'disabled': params.page === pageStudent.totalPages - 1}" (click)="onPage(pageStudent.totalPages - 1)">
<span class="page-link">尾页</span>
</li>
</ul>
```
![](https://img.kancloud.cn/fc/eb/fceb9d8c559b47fb42a5bdf88f800732_505x67.png)
浏览器默认为有`href`属性的`a`标签的`hover`违类上添加了`cursor: pointer`属性,以使得鼠标移动到元素上变成 小手 的样子。如果想用`span`标签添加此属性,则为其`hover`违类添加对应的样式即可:
student/index/index.component.sass
```
ul.pagination > li > span:hover
cursor: pointer
```
![](https://img.kancloud.cn/a0/78/a07882beed563c2e4c9eca9bc2c5f695_878x193.gif)
pointer常用于跳转的链接,分页功能中我们更习惯于使用default( 默认光标(通常是一个箭头))。
student/index/index.component.sass
```
ul.pagination > li > span:hover
cursor: default
```
### 上一页、下一页
此时我有了一个疑问,明明下一页、上一页显示为灰色,却为什么还能点击呢?这时候就需要看看angular为我们生成的页面源码了:
![](https://img.kancloud.cn/23/10/2310ea027a670c65670b57a4e2bfeed0_855x50.png)
在代码中使用`[ngClass]="{'disabled': params.page === 0}"`来控制`disabled`时,其实angular是为该元素在`params.page === 0`时添加了一个`disabled`样式,使其看起来是不能够点击的。要使一个元素真正的不能够被点击,前提是该元素具有天然的`disabled`属性,比如`button`元素就具有这个属性。实验如下:
student/index/index.component.html
```
<li class="page-item" [ngClass]="{'disabled': params.page === 0}" (click)="onPage(params.page - 1)">
<span class="page-link">上一页</span>
</li>
<button [disabled]="params.page === 0" (click)="onPage(params.page - 1)">上一页</button>
```
![](https://img.kancloud.cn/fa/75/fa75d2503394fb3a49a7f4e2b8c879ef_878x77.gif)
实验得出:天然有`disabled`属性的button,可以设置其`disabled`属性,能起到禁止点击的作用。而天然并没有`disabled`属性的li,则只能是看起来`disabled`不能被点击了,而实际上点用户点击该元素时,仍然能够触发其绑定的onPage方法。对于C层的onPage方法而言,如果按正常的逻辑,是不应该接收到小于0或是大于等于总页数的值的,但程序的魅力就是如此:它总能找点小乐子,让本来应该的事情变得那么的不应该。既然V层解决不了(在V层中也可以考虑将li变为button标签,但这将破坏了原bootstrap结构而使得该分页样式变得难以维护),那就在C层的onPage方法上下功夫吧。
student/index/index.component.ts
```
/**
* 点击分页按钮
* @param page 要请求的页码
*/
onPage(page: number) {
if (page < 0 || page >= this.pageStudent.totalPages) { ✚
return;
}
this.params.page = page;
this.loadData();
}
```
对应修正单元测试:
student/index/index.component.spec.ts
```
fit('onPage 功能测试', () => {
spyOn(component, 'loadData');
component.params.page = 4;
component.onPage(3);
expect(component.params.page).toEqual(3);
expect(component.loadData).toHaveBeenCalled();
/* 越界测试:期望不改变当前页码值,loadData仅被前面的代码调用了1次(本次未调用)*/
component.onPage(-1);
expect(component.params.page).toEqual(3);
expect(component.loadData).toHaveBeenCalledTimes(1);
/* 越界测试:期望不改变当前页码值,loadData仅被前面的代码调用了1次(本次未调用)*/
component.pageStudent.totalPages = 5;
component.onPage(5);
expect(component.params.page).toEqual(3);
expect(component.loadData).toHaveBeenCalledTimes(1);
});
```
此时当页码为首、尾页时,再次点击上一页下一页时便不会发生越界的情况了,至此集成测试完毕。
# 总结
基于**人是必然会犯错误的**的理论,在开发时引入单元测试来对自己的代码进行功能性验证,从而降低犯错误的概率。同样基于该理论,当各个模块分离完成后进行组合的测试来进一步降低犯错的概率。所以的准备工作,都是为了最终减少生产环境中可能面临的用户各种无厘头以及**非常正常**的操作。我们梦想并努力着:在应用上线的那一天,我们可以关上手机心无旁骛地躺在床上休息。这应该就是软件工程的初心吧。
# 参考文档
| 名称 | 链接 | 预计学习时长(分) |
| --- | --- | --- |
| 源码地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.6.10](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.6.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
- 总结
- 开发规范
- 备用