实践是检验真理的唯一标准。无论什么样的选手,单元测试做的再好也难免会有想不到的地方。此时便需要集成测试来补刀了。什么是集成测试呢?简单来说就是把几个小的模块组装到一起,或是把一些单元测试的小的粒度组合到一起进行测试。最简单最不可靠的集成测试的方法便是本教程中采用的:人为验证法。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) | - |