C层的功能相较于前面的单元较多,我们本次单元测试粒度最小化原则分别就:table列表、选择班级组件、条件查询以及分页展开开发及单元测试。 # table列表 本组件中table列表将在以下几种情况下更新:①组件初始化时 ②用户点击查询按钮时 ③用户点击分页时。因此应该为table列表更新单独建立一个方法,以满足多条件下调用。 student/index/index.component.ts ``` export class IndexComponent implements OnInit { ... /** * 加载数据 */ loadData() { const queryParams = { // ① page: this.params.page, size: this.params.size, klassId: this.params.klass.id, ➊ name: this.params.name.value, sno: this.params.sno.value }; this.studentService.page(queryParams) .subscribe((response: { totalPages: number, content: Array<Student> }) => { this.pageStudent = response; }); } ngOnInit() { this.loadData(); } ``` * ① 构造适用于StudentService.page方法的类型 * ➊ 此处有错误,会触发一个IDE的错语提醒 ![](https://img.kancloud.cn/27/25/2725f88c227f17ce9c074501f88c56fa_668x95.png) ## `:`与`=` 在ts中`:`与`=`均可对变理进行定义。更确切的讲`:`用来定义变量的类型;而`=`则是进行赋值,在赋值的过程中按赋的值的类型来确认变量的类型。 比如: ``` let a: number; // ① console.log(a); // ② a = 3; console.log(a); // 3 ``` * ① 定义a的类型为number,值为undefined * ② 打印undefined 以上代码等价于: ``` let a = 3; // ① ``` * ① 由于3的类型是number,所以TS可以推断中a的类型为number。 如果使用以下语句则会报错: ![](https://img.kancloud.cn/5f/59/5f598907bbded2351e323b82792765e5_189x76.png) 将某个变量的赋值或声明为对象也是同样的道理。但由于`{}`在ts中的特殊性,在`:`与`=`分别代表不同的含意。比如: `a = {}`中的`{}`代表a的值是一个`空对象`,而`a: {}`中的`{}`代表a的类型是一个`对象类型` 所以当我们使用以下代码时: ``` /* 查询参数 */ params = { page: 0, size: 2, klass: Klass, name: new FormControl(), sno: new FormControl() }; ``` 由于使用是`params =`,那么此时`{}`代表一个对象,对`{}`中的值分别代表`属性名`及`属性值`。则上述代码解读为:params的值是一个对象,这个对象的page属性的值为0,size属性的值为2,klass属性的**值**为Klass。 而这便是发生错误的源泉,我们使用`klass: Klass`的本意是:klass属性的**类型**为Klass,而此时被ts解读的含意与我们的本意并不相同。 假设我们使用`:`来替换`=`定义params的类型,则会发生以下错误: ![](https://img.kancloud.cn/63/30/633071fb565a1cbbeda668ce761b5b14_401x241.png) 这是由于当使用`params :`时,`{}`代表一个对象类型,而这个对象类型中的每一个,均代表`属性名`及`属性类型`,故上述代码应该被解读为:params的类型是一个对象,这个对象中的page属性的类型是`0`, size的属性是`2`,klass属性是`Klass`,**name的属性是`new FormControl()`**。由于`new FormControl()`并不能做为属性类型出现,所以便发生了上图的错误提示。我们可以将其改为: ``` params: { page: 0, size: 2, klass: Klass, name: FormControl, sno: FormControl }; ``` 此时错误提示消息。但由于`page: 0`被解读为**page字段的类型为`0`**,所以当我们对其进行赋值时也会提示错语: ![](https://img.kancloud.cn/a5/fb/a5fb01ecf74ce5e44ca8435d15ffb52e_645x136.png) 上述代码将3赋予page属性,但3的类型为number,而page属性声明类型为`0`。由于`number !== 0`,所以类型并不相符,进而发生了如上错误。这也是在初期使用ts进行变量的初始化时常常出现的问题,避免这个问题发生则需要记往以下规则:**赋值时使用常用的`=`,声明类型时使用`:`** 则params初始化的代码应该修正为: ``` /* 查询参数 */ params = { page: 0, size: 2, klass: Klass, ✘ klass: new Klass(null , null, null), ✚ name: new FormControl(), sno: new FormControl() }; ``` ## 单元测试 在3.4.4小节中我们已经使用spy及Stub分别进行了组件依赖于服务的单元测试。spy的优点在于可以更加轻松的构造测试服务;而Stub在于可以进行复用。所以如果在测试过程中我们的对某个服务仅依赖一次那么应该使用更为简单的spy,如果我们不确定对该服务的依赖次数,那么推荐使用Stub。在此,我们使用Stub的方法来生成测试用的StudentService。使用终端来到app/service文件夹,并使用`ng g service studentStub --skip-tests`命令来生成一个供测试用的StudentStubService. ``` panjiedeMac-Pro:service panjie$ ng g service studentStub --skip-tests➊ CREATE src/app/service/student-stub.service.ts (140 bytes) panjiedeMac-Pro:service panjie$ ``` * ➊ 在生成时忽略生成单元测试文件 该服务仅用于单元测试,所以删除`Injectable`注解后保留最原始的部分即可: ``` /** * 学生服务测试桩 */ export class StudentStubService { constructor() { } } ``` ### 增加测试方法page ``` /** * 学生服务测试桩 */ import {Observable, of★} from 'rxjs'; import {Student} from '../norm/entity/student'; import {Klass} from '../norm/entity/Klass'; export class StudentStubService { constructor() { } /* 传入参数缓存 */ pageParamsCache: { sno?: string, name?: string, klassId?: number, page?: number, size?: number }; ① /** * page模拟方法 * @param params 查询参数 */ page(params: { sno?: string, name?: string, klassId?: number, page?: number, size?: number }) : Observable<{ totalPages: number, content: Array<Student> }> { this.pageParamsCache = params; ① const mockResult = { ② totalPages: 100, content: new Array<Student>( new Student({id: 1, name: 'testStudent', sno: 'testStudentSno', klass: new Klass(1, 'testKlass', null)}), new Student({id: 2, name: 'testStudent1', sno: 'testStudentSno1', klass: new Klass(2, 'testKlass1', null)})) }; return of(mockResult)➊; } } ``` * ① 参数缓存,用于断言调用page方法时的传入的参数是否符合预期 * ② 构造模拟返回结果 * ➊ 使用of()方法模拟返回观察者对象 ### 使用Stub服务 有了模拟的学生服务后,我们在列表组件的测试方法中使用该模拟服务替换真实的服务: student/index/index.component.spec.ts ``` describe('Student -> IndexComponent', () => { ... beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [IndexComponent, KlassSelectComponent], imports: [ ReactiveFormsModule, FormsModule, CoreModule, HttpClientTestingModule], providers: [ {provide: StudentService, useClass: StudentStubService} ➊ ] }) .compileComponents(); })); ``` * ➊ 当测试中需要提供StudentService时,使用StudentStubService进行替换 为了确认上述替换服务的代码是生效的,我们在组件的构造函数中打印该服务: student/index/index.component.spec.ts ``` constructor(private studentService: StudentService) { console.log(studentService); } ``` ![](https://img.kancloud.cn/32/5f/325faa334f0d311e09da5386b5304d6b_357x121.png) ## 测试TABLE初始化 Table初始化成功应该有以下标志:①使用预期的参数请求了StudentService(Stub) ② 正确地渲染了V层。下面我们按测试粒度最小化的原则,分别对上述两点进行测试。 ### 发起请求测试 student/index/index.component.spec.ts ``` describe('Student -> IndexComponent', () => { ... fit('组件初始化发起请求测试', () => { /* 获取请求参数 */ const studentService: StudentStubService = TestBed.get(StudentService); const queryParam = studentService.pageParamsCache; /* 断言传入的参数值与组件中的参数值相同 */ expect(queryParam.name).toEqual(component.params.name.value); expect(queryParam.sno).toEqual(component.params.sno.value); expect(queryParam.klassId).toEqual(component.params.klass.id); expect(queryParam.page).toEqual(component.params.page); expect(queryParam.size).toEqual(component.params.size); }); }); ``` ### 正确地渲染了V层 student/index/index.component.spec.ts ``` describe('Student -> IndexComponent', () => { ... fit('组件初始化V层渲染', () => { /* 获取table元素 */ const tableElement = fixture.debugElement.query(By.css('table')); const table: HTMLTableElement = tableElement.nativeElement; /* 断言总行数及第一行的内容绑定符合预期 */ const row = 1; let col = 0; expect(table.rows.length).toBe(3); expect(table.rows.item(row).cells.length).toBe(6); expect(table.rows.item(row).cells.item(col++).innerText).toBe(''); expect(table.rows.item(row).cells.item(col++).innerText).toBe('1'); expect(table.rows.item(row).cells.item(col++).innerText).toBe('testStudent'); expect(table.rows.item(row).cells.item(col++).innerText).toBe('testStudentSno'); expect(table.rows.item(row).cells.item(col++).innerText).toBe('testKlass'); expect(table.rows.item(row).cells.item(col++).innerText).toBe(''); }); }); ``` # 选择班级组件 选择班级组件依赖于core/select中的选择组件,该组件在初始化时会像特定的URL进行相应的数据请求。所以如果需要在测试时使选择班级组件生效,则需要对此URL请求设定模似返回值。 > **注意:** 受于篇幅的限制,引处采用了一种非常LOW的测试模式,将到在后面的教程中对其进行修正。 student/index/index.component.spec.ts ``` describe('Student -> IndexComponent', () => { ... fit('选择班级组件', () => { /* 获取请求 */ const httpTestingController = TestBed.get(HttpTestingController); const req: TestRequest = httpTestingController.expectOne('http://localhost:8080/Klass?name='); expect(req.request.method).toEqual('GET'); /* 模拟返回值 */ const mockResult = new Array<Klass>( new Klass(1, 'testKlass', null), new Klass(2, 'testKlass2', null) ); req.flush(mockResult); fixture.detectChanges(); ★ /* 获取select元素 */ const debugElement = fixture.debugElement.query(By.css('select')); const select: HTMLSelectElement = debugElement.nativeElement; /* 选中首个选项 */ select.value = select.options[0].value; select.dispatchEvent(new Event('change')); fixture.detectChanges(); ★ /* 断言选中的值传给了C层 */ expect(component.params.klass).toEqual(mockResult[0]); }); }); ``` * ★ 数据发生变更后调用detectChanges()重新渲染界面 测试结果: ``` Error: Expected $.id = null to equal 1. Expected $.name = null to equal 'testKlass'. ``` 表明当用户选择完班级后并未将数据绑定到C层的查询参数中。 ## 修正列表组件 在组件初始化的过程中并没有编写班级选择组件的功能性代码,遂导致了单元测试代码未通过,修正如下: student/index/index.component.html ``` <label>班级:<app-klass-select [klass]="params.klass" (selected)="onSelectKlass($event)"✚></app-klass-select></label> ``` student/index/index.component.ts ``` export class IndexComponent implements OnInit { ... /* 选择班级 */ onSelectKlass(klass: Klass) { this.params.klass = klass; } } ``` 单元测试通过。说明选择班级后对应将选择的班级绑定到了C层。 # 条件查询 在条件查询的测试中,注意项为:① 姓名、学号两个input是否成功的绑定到了C层 ② 点击查询按钮后,是否成功地向M层发起了请求。 ## input绑定测试 student/index/index.component.spec.ts ``` describe('Student -> IndexComponent', () => { ... fit('姓名、学号input输入测试', () => { /* 利用前期抽向出的表单测试类,对表单进行测试 */ const formTest = new FormTest(fixture); ① expect(formTest.setInputValue('input[name="name"]', 'studentName')).toBe(true);② expect(formTest.setInputValue('input[name="sno"]', 'studentSno')).toBe(true); fixture.detectChanges(); /* 断言选中的值传给了C层 */ expect(component.params.name.value).toEqual('studentName'); ③ expect(component.params.sno.value).toEqual('studentSno'); ③ }); }); ``` * ① 在构造函数中将当前夹具传入 * ② 成功的对input赋值,将返回true * ③ 断言对input设置的值,成功的传递给了C层 测试结果: ``` Error: Expected false to be true. ``` 显示的该测试报告并不友好,我们仅知道在执行formTest.setInputValue时未如期的返回正确的结果,却无法直接的得到错误提示信息。 我们找到testing/FormTest.ts中对应的代码段,发现只所以返回了false,是由于没有根据css选择器找到对应的html元素造成的。 testing/FormTest.ts ``` export class FormTest<T> { ... /** * 设置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; } ``` * ★ 当未找到相关元素时,返回了false。 ### Error 在typescript中,使用`throw new Error(String message)`来抛出异常信息: ``` export class FormTest<T> { ... static setInputValue(fixture: ComponentFixture<any>, cssSelector: string, value: string): boolean { ... if (isNull(selectorElement)) { return false; ✘ throw new Error(`未找到css选器${cssSelector}对应的html元素`); ✚ } } ``` 此时当在单元测试中发生未找到相关元素的情况时,便可以直接在单元测试的界面上查看到上述信息了。 ![](https://img.kancloud.cn/a8/e6/a8e6e649bed4af86c00823d455f70b36_401x82.png) 当然了,此时setInputValue方法描述也可由`执行成功返回true,失败返回false`同步变更为`执行成功返回void,不成功抛出异常`了(暂时不变更)。 ### 修正组件 打开V层文件为input表单增加name属性,这样更加规范也更有利于测试。 student/index/index.component.html ``` <label>姓名:<input name="name"★ [formControl]="params.name" type="text" /></label> <label>学号:<input name="sno"★ [formControl]="params.sno" type="text" /></label> ``` * ★ 添加name属性 ## 查询按钮测试 用户点击查询按钮后,应该测试以下两点:① 查询参数是否传入M层 ② M层的返回值是否被组件接收 student/index/index.component.spec.ts ``` describe('Student -> IndexComponent', () => { fit('查询按钮点击测试', () => { /* 组件参数赋值 */ component.params.name.setValue('studentName'); component.params.sno.setValue('sno'); component.params.klass = new Klass(1, null, null); component.params.page = 3; component.params.size = 20; /* 点击按钮 */ const formTest = new FormTest(fixture); formTest.clickButton('button'); /* 获取传入参数 */ const studentService: StudentStubService = TestBed.get(StudentService); const queryParam = studentService.pageParamsCache; /* ①查询参数是否传入M层 */ expect(queryParam.name).toEqual(component.params.name.value); expect(queryParam.sno).toEqual(component.params.sno.value); expect(queryParam.klassId).toEqual(component.params.klass.id); expect(queryParam.page).toEqual(component.params.page); expect(queryParam.size).toEqual(component.params.size); /* ②M层的返回值是否被组件接收 */ }); }); ``` 测试结果: ``` Error: Expected null to equal 'studentName'. ``` ### 完善功能 该结果说明name查询参数没有并成功的传入到StudentService,依此我们对应修正该组件代码。 student/index/index.component.ts ``` export class IndexComponent implements OnInit { /* 查询 */ onQuery() { this.loadData(); ① } ``` * ① 直接调用数据加载函数 # spyOn 代码写到这突然地发现如果继续这么写测试代码的话,则会与组件初始化的代码高度的相同。原因如下: ``` ngOnInit() { this.loadData(); } /* 查询 */ onQuery() { this.loadData(); } ``` 在初始化及查询的代码中,我们均调用了`this.loadData()`方法。也就是说我们完成可以换一种思路进行测试:① 测试this.loadData()符合预期 ② 测试`ngOnInit()`方法成功的调用了`loadData()`方法 ③ 测试`onQuery()`方法成功的调用了`loadData()`方法。 ① 测试this.loadData()符合预期 我们已经在前面测试组件初始化时测试过了。那么②③这种方法的调用又该如何测试呢?为了解决这个问题,angular为我们提供了spyOn()方法。 ## 在方法上添加间谍 清空测试用例`查询按钮点击测试`的代码后,重新使用spyOn来进行组织。 student/index/index.component.spec.ts ``` describe('Student -> IndexComponent', () => { fit('查询按钮点击测试', () => { spyOn(component, 'onQuery'); ➊ /* 点击按钮 */ const formTest = new FormTest(fixture); formTest.clickButton('button'); expect(component.onQuery).toHaveBeenCalled(); /* 由于原onQuery()已经失效。所以点击查询按钮虽然成功的触发了onQuery()方法。但此方法却是一个间谍,该间谍并没有进行数据转发➋ */ // expect(component.loadData).toHaveBeenCalled(); // 执行此方法将得到一个异常,因为数据已被间谍拦截,该间谍并未调用loadData方法 }); }); ``` * ➊ 在组件的onQuery方法上设置间谍。当调用组件的onQuery()方法时,将由间谍提供服务。以此同时原onQuery()将失效 * ➋ 这像极了谍战片:我们在敌人情报网的关键位置安插了一个间谍,此间谍得到情报后,选择将情报就地销毁而非向原组织的上级报告 ### 测试onQuery方法 具体onQuery是否调用了loadData,则可以新建另一个测试用例来完成测试。 student/index/index.component.spec.ts ``` describe('Student -> IndexComponent', () => { ... fit('onQuery', () => { spyOn(component, 'loadData'); component.onQuery(); expect(component.loadData).toHaveBeenCalled(); ① }); }); ``` * ① 断言此loadData间谍被调用了1次 # 分页 分页是一款应用的必备功能,一个完善的分页功能相对复杂,在此我们的目的完成一个**经典**款。和前面的单元测试思想相同,在测试中我们争取把测试的粒度控制到最小。 ![](https://img.kancloud.cn/35/7e/357e5f986bfa78ccf6c4e273764e8e29_371x33.png) 依原型对①当前页 ②总页数 ③每页大小 ④首页 ⑤上一页 ⑥页码 ⑦ 下一页 ⑧尾页 分别建立测试用例。 ## 引入bootstrap分页样式 在本章的第一节项目已经引入了流行的样式bootstrap,分页组件是bootstrap下标准的组件之一。在正式的开发分页以前,我们找到bootstrap分页组件的示例代码,并尝试将其引入到当前组件中: ``` </table> <div *ngIf="pageStudent">第{{params.page}}/{{pageStudent.totalPages}}页 每页{{params.size}}条 首页 上一页 1 2 3 下一页 尾页</div>✘ <div *ngIf="pageStudent" class="row"> ✚ <div class="col-4"> 第{{params.page}}/{{pageStudent.totalPages}}页 每页{{params.size}}条 </div> <div class="col-8"> <nav> <ul class="pagination"> <li class="page-item disabled"> <span class="page-link">Previous</span> </li> <li class="page-item"><a class="page-link" href="#">1</a></li> <li class="page-item active"> <span class="page-link"> 2 <span class="sr-only">(current)</span> </span> </li> <li class="page-item"><a class="page-link" href="#">3</a></li> <li class="page-item"> <a class="page-link" href="#">Next</a> </li> </ul> </nav> </div> </div> ✚ ``` 效果如下: ![](https://img.kancloud.cn/05/f7/05f7adba4f870c7e23480c8f8ccd6ecf_768x172.png) ## 当前页、总页数、每页大小 在单元测试中如果想到某个元素进行测试并断言,前提是可能精确的在页面中的找到该元素。为此,我们为分页信息增加id属性: student/index/index.component.html ``` <div class="col-4" id="pageInfo★"> 第{{params.page}}/{{pageStudent.totalPages}}页 每页{{params.size}}条 </div> ``` * ★ 定义id ### 单元测试 student/index/index.component.spec.ts ``` describe('Student -> IndexComponent', () => { ... fit('当前页、总页数、每页大小', () => { /* 获取分页信息 */ const debugElement = fixture.debugElement.query(By.css('#pageInfo')); const pageInfoDiv: HTMLDivElement = debugElement.nativeElement; const text = pageInfoDiv.textContent; ➊ console.log(text); ① /* 断言绑定了C层的分页值 */ expect(text).toContain(`第${component.params.page}/${component.pageStudent.totalPages}页`); ➋ expect(text).toContain(`每页${component.params.size}条`); }); }); ``` * ➊ 获取div元素中的文本内容 * ① 首次使用在控制台打印其信息,以更好的掌握该数据值 * ➋ 断言获取的字符串中包含了预期的值 ## 首页 首页主要考虑几个功能点:①当前页如果就是首页,则该按钮应该被禁用 ②当击首页按钮成功设置C导层params.page = 0 ③ 点击后重新发起数据请求。首次进行类似功能的开发,开发步骤为:先开发功能代码再按测试代码进行功能修正。 ### 功能代码 student/index/index.component.html ``` ... <ul class="pagination"> <li class="page-item" [ngClass]="{'disabled': params.page === 0}"➊ (click)="onPage(0)"①> <span class="page-link">Previous</span> ✘ <span class="page-link">首页</span> ✚ </li> ... ``` * ➊ 动态设置宿主元素(li)的样式值,当params.page的值为0时,添加disabled样式 * ① 点击该元素时,向C层传值 对应的C层代码: student/index/index.component.ts ``` export class IndexComponent implements OnInit { ... /** * 点击分页按钮 * @param page 要请求的页码 */ onPage(page: number) { this.params.page = page; this.loadData(); } } ``` ### 样式测试 student/index/index.component.spec.ts ``` describe('Student -> IndexComponent', () => { ... fit('分页 -> 首页样式测试', () => { /* 获取首页按钮 */ const debugElement = fixture.debugElement.query(By.css('ul.pagination > li:first-child')); const htmlliElement: HTMLLIElement➊ = debugElement.nativeElement; console.log(htmlliElement);① /* 当前页为首页,则添加禁用样式 */ component.params.page = 0; fixture.detectChanges(); ★ expect(htmlliElement.classList.contains('disabled')).toBe(true); ➋ /* 当前页非首页,则移除禁用样式 */ component.params.page = 1; fixture.detectChanges(); ★ expect(htmlliElement.classList.contains('disabled')).toBe(false); ➌ }); }); ``` * ➊ Li元素 * ① 首次或不太熟悉时,打印元素到控制台以查看详情 * ➋ 断言样式中包括disabled * ➌ 断言样式中不包括disabled ### 点击测试 student/index/index.component.spec.ts ``` describe('Student -> IndexComponent', () => { ... fit('分页 -> 点击首页测试', () => { spyOn(component, 'onPage'); ① /* 获取首页按钮并点击 */ const formTest = new FormTest(fixture); formTest.clickButton('ul.pagination > li:first-child'); expect(component.onPage).toHaveBeenCalledWith(0);➊ }); ``` * ① 建立间谍 * ➊ 断言onPage方法被调用,而且被调用时传入的参数值为0 ### onPage功能测试 student/index/index.component.spec.ts ``` describe('Student -> IndexComponent', () => { ... fit('onPage 功能测试', () => { spyOn(component, 'loadData'); ① component.params.page = 4; ② component.onPage(3); ③ expect(component.params.page).toEqual(3); ④ expect(component.loadData).toHaveBeenCalled(); ⑤ }); ``` * ① 建立间谍 * ② 初始化page * ③ 调用 * ④ 对结果进行断言 * ⑤ 对调用loadData方法进行断言 ## 上一页 功能点:①当前页为首页时,禁用 ②调用onPage方法,传入值为当前页 -1 ### 功能开发 student/index/index.component.ts ``` <nav> <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 - 1)"①> <span class="page-link">上一页</span> </li> ``` * ① 将当前页-1后传入 ### 样式测试 由于前面已经测试onPage方法是否可能的调用了loadData方法,所以此处我们只需要测试onPage方法的传入值是否正确即可。 student/index/index.component.spec.ts ``` describe('Student -> IndexComponent', () => { ... fit('上一页 样式测试', () => { /* 获取首页按钮 */ const debugElement = fixture.debugElement.query(By.css('ul.pagination > li:nth-child(2)')); ➊ const htmlliElement: HTMLLIElement = debugElement.nativeElement; console.log(htmlliElement); /* 当前页为首页,则添加禁用样式 */ component.params.page = 0; fixture.detectChanges(); expect(htmlliElement.classList.contains('disabled')).toBe(true); /* 当前页非首页,则移除禁用样式 */ component.params.page = 1; fixture.detectChanges(); expect(htmlliElement.classList.contains('disabled')).toBe(false); }); }); ``` * ➊ nth-child(n)表示选中第n个元素,但与我们习惯的0基不同,该选择器是1基的。如果我们选择第2个li元素则直接输入2即可,无需进行减1处理。 ### 点击测试 student/index/index.component.spec.ts ``` describe('Student -> IndexComponent', () => { ... fit('上一页 点击测试', () => { spyOn(component, 'onPage'); component.params.page = 3; ① fixture.detectChanges(); /* 获取上一页按钮并点击 */ const formTest = new FormTest(fixture); formTest.clickButton('ul.pagination > li:nth-child(2)'); ② expect(component.onPage).toHaveBeenCalledWith(2); }); }); ``` * ① 当前页为3,则上一页按钮可点击 * ② 点击上一页 * ③ 断言传入的参数值为2(第3页的上一页为第2页) 测试结果我们得到了如下异常: ``` Error: Expected spy onPage to have been called with [ 2 ] but actual calls were [ NaN ]. ``` 通过检查V层的代码我们发现误把`params.page - 1`写成了`params`。 修正如下: student/index/index.component.ts ``` <li class="page-item" [ngClass]="{'disabled': params.page === 0}" (click)="onPage(params - 1)"> ✘ <li class="page-item" [ngClass]="{'disabled': params.page === 0}" (click)="onPage(params.page - 1)"> ✚ <span class="page-link">上一页</span> </li> ``` 修正后单元测试通过。 ## 页码C层 在实现页码的功能时,我们首先想到的是使用类似于`for(let i = 0; i < totalPages; i++)`的形式在前台来循环输出各个页码,但angular的ngFor并不支持这样做,所以我们换一种思路:在C层中生成页码的数组,比如我们需要1,2,3,4,5页,则在C层中生成Array(1,2,3,4,5);然后在前台使用ngFor来循环输出该数组。 页码所需要考虑的问题较多,在开始之前需要简单的想一下我们所需要处理的问题 -- Head First, Coding Second。 * 输出的页码总数最多不超过N(暂时设定为5)个。总页数不超过5页时,全部输出,总页数大于5页时,则最多输出5页。比如共20页,当前页为第10页,则输出为: 8 9 10 11 12 * 当前页禁止点击,其它页可点击,比如:[2] [3] 4 [5] [6] * 当前页起始2页时,比如当前页为2,则显示为:[1] 2 [3] [4] [5] * 当前页为终了2页时,比如共10页,当前页为最后1页,则显示为:[6] [7] [8] [9] 10 为此,拟制流程图如下: ![](https://img.kancloud.cn/a7/60/a760ae63ae17a053b0dc57a5bcd39962_642x704.png) 该流程图主要有几个判断语句及一个生成X到Y的数组的方法组成,依此接下来按流程分步进行开发。 ### 生成由X到Y的数组 student/index/index.component.ts ``` export class IndexComponent implements OnInit { ... /** * 生成页码 * @param begin 开始页码 * @param end 结束页码 */ makePages(begin: number, end: number): Array<number> { const result = new Array<number>(); for (; begin <= end; begin++) { result.push(begin); } return result; } } ``` 接下来对此功能进行测试: student/index/index.component.spec.ts ``` describe('Student -> IndexComponent', () => { ... fit('makePages', () => { /* 更好的做法是使用两个随机的数字进行测试 */ const result = component.makePages(0, 4); expect(result.length).toEqual(5); /* 断言起始为0 */ expect(result[0]).toEqual(0); /* 断言后一个元素比前一个元素大1 */ for (let i = 0; i < 4; i++) { expect(result[i] + 1).toEqual(result[i + 1]); } }); }); ``` ### 按总页数生成分页数据 有了按起始值生成分页数组的方法后,按流程图继承新建根据当前页及总页数的不同值来生成分页数组。 student/index/index.component.ts ``` export class IndexComponent implements OnInit { /* 分页数据 */ pages: Array<number>; ... /** * 生成分页数据 * @param currentPage 当前页 * @param totalPages 总页数 */ makePagesByTotalPages(currentPage: number, totalPages: number): Array<number> { if (totalPages > 0) { /* 总页数小于5 */ if (totalPages <= 5) { return this.makePages(0, totalPages); } /* 首2页 */ if (currentPage < 2) { return this.makePages(0, 5); } /* 尾2页 */ if (currentPage > totalPages - 3) { return this.makePages(totalPages - 5, totalPages - 1); } /* 总页数大于5,且为中间页码*/ return this.makePages(currentPage - 2, currentPage + 2); } return new Array(); } ``` 此方法有多种条件,我们力求在单元测试中使用不同的测试数据调用该方法而使得该方法中所有的IF条件中的代码均被执行一次,达到测试覆盖率为100%的目标。 student/index/index.component.spec.ts ``` describe('Student -> IndexComponent', () => { ... fit('makePagesByTotalPages', () => { /* 总页数为0 */ expect(component.makePagesByTotalPages(0, 0).length).toEqual(0); /* 总页数小于等于5 */ expect(component.makePagesByTotalPages(2, 5).length).toEqual(5); expect(component.makePagesByTotalPages(2, 5)[0]).toEqual(0); /* 总页数大于5,首2页 */ expect(component.makePagesByTotalPages(1, 10).length).toEqual(5); expect(component.makePagesByTotalPages(1, 10)[4]).toEqual(4); /* 总页数大于5,尾2页 */ expect(component.makePagesByTotalPages(8, 10).length).toEqual(5); expect(component.makePagesByTotalPages(8, 10)[4]).toEqual(9); /* 总页数大于5, 中间页 */ expect(component.makePagesByTotalPages(5, 10).length).toEqual(5); expect(component.makePagesByTotalPages(5, 10)[0]).toEqual(3); }); }); ``` 保存文件后单元测试自动运行,同时反馈给了我们的一个错误: ![](https://img.kancloud.cn/01/e1/01e1e06dbd40582f5aa120956f8fc2d4_997x105.png) 该错误提示:测试代码中预期的值为5,但实际的返回是6。该错误位于:index.component.spec.ts文件夹的第234行(由于你的学习的代码与教程中的不可能完全一样,所以你本地提示的行数可能是其它值,这是正常的): student/index/index.component.spec.ts ``` describe('Student -> IndexComponent', () => { ... ... /* 总页数小于等于5 */ expect(component.makePagesByTotalPages(2, 5).length).toEqual(5); ★ expect(component.makePagesByTotalPages(2, 5)[0]).toEqual(0); ``` 为了更清晰的得知调用component.makePagesByTotalPages(2, 5)的返回值,在此行上加入console.log来进行数据打印: student/index/index.component.spec.ts ``` describe('Student -> IndexComponent', () => { ... ... /* 总页数小于等于5 */ console.log(component.makePagesByTotalPages(2, 5)); ✚ expect(component.makePagesByTotalPages(2, 5).length).toEqual(5); ★ expect(component.makePagesByTotalPages(2, 5)[0]).toEqual(0); ``` 打印的值如下: ``` LOG: [0, 1, 2, 3, 4, 5] ``` 当调用`component.makePagesByTotalPages(2, 5)`时表示当前页为第3页,总页数为5页。则期待打印的数组值应该:`[0,1, 2, 3, 4]`。故此得知在相应的代码进行数据输出时,终止的值应该在原值的基础上做-1处理。回来功能代码进行修正: student/index/index.component.ts ``` export class IndexComponent implements OnInit { ... /* 总页数小于5 */ if (totalPages <= 5) { return this.makePages(0, totalPages); ✘ return this.makePages(0, totalPages - 1); ✚ } ``` 再次运行单元测试,发现仍有错误报出: ![](https://img.kancloud.cn/c3/bb/c3bb6b6db7a17603bfce5828f01576fa_926x87.png) 依照刚才的方法,再次进行修正: student/index/index.component.ts ``` export class IndexComponent implements OnInit { ... /* 首2页 */ if (currentPage < 2) { return this.makePages(0, 5); ✘ return this.makePages(0, 4); ✚ } ``` 最终测试通过。表明在C层中编写的功能性代码是符合预期的,这正是单元测试的魅力所在:在代码编写阶段发现并及时的修正错误。 ### V层绑定 最后,我们在每次数据加载完成后调用此页码生成方法,并将其返回值绑定给V层: student/index/index.component.ts ``` export class IndexComponent implements OnInit { /* 分页数据 */ pages: Array<number>; ① ... loadData() { ... this.studentService.page(queryParams) .subscribe((response: { totalPages: number, content: Array<Student> }) => { this.pageStudent = response; this.pages = this.makePagesByTotalPages(this.params.page, response.totalPages); ② }); } ``` * ① 使用`:`来定义数据类型 * ② 每次数据重新加载后,重新生成分页信息 student/index/index.component.spec.ts ``` describe('Student -> IndexComponent', () => { ... fit('loadData', () => { const mockResult = new Array<number>(); ① spyOn(component, 'makePagesByTotalPages').and.returnValue(mockResult); ② component.loadData(); expect(component.makePagesByTotalPages).toHaveBeenCalled(); expect(component.pages).toBe(mockResult); ③ }); ``` * ① 初始化模拟返回值 * ② 建立间谍并设置该间谍的返回值(当该间谍被调用时,以此值返回给调用者) * ③ `toBe` = `就是`。断言当前组件的分页信息就是刚刚间谍设置的返回值 ## 页码V层 C层的功能完成后,继续来完成V层。V层的主要功能是根据C层的pages值及当前页进行渲染。功能点主要有:①渲染的个数要与C层的个数相同 ②程序员使用的页码虽然为0基,但用户习惯于1基 ③点击对页的应码时应该触发C层onPage方法 ④当前页的样式应该区别于非当前页。下面,按上述功能点分别开发: ### 渲染个数 TDD测试驱开发,先尝试写写单元测试代码: student/index/index.component.spec.ts ``` describe('Student -> IndexComponent', () => { ... fit('页码渲染个数', () => { component.pages = new Array<number>(3, 4, 5, 6, 7); fixture.detectChanges(); /* 获取首页按钮 */ const debugElement = fixture.debugElement.query(By.css('ul.pagination')); ① const ulElement: HTMLUListElement = debugElement.nativeElement; /* 断言分页个数 */ console.log(ulElement.getElementsByTagName('li')); ② expect(ulElement.childNodes.length).toEqual(9); ③ }); }); ``` * ① 通过css选择器获取分页的ul元素 * ② 获取ul元素下的所有li元素。首次使用控制台打印信息 * ③ `首页 上一页 3 4 5 6 7 下页 尾页` 共9个分页信息。 功能代码中先补充下一页及尾页: student/index/index.component.html ``` <li class="page-item"> <a class="page-link" href="#">Next</a> ✘ <a class="page-link" href="#">下一页</a> ✚ </li> <li class="page-item"> ✚ <a class="page-link" href="#">尾页</a> </li> ✚ </ul> ``` 再进行循环输出: 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> <li *ngFor="let page of pages"> <a class="page-link" href="#">3</a> </li> <li class="page-item"> <a class="page-link" href="#">下一页</a> </li> ``` ![](https://img.kancloud.cn/bf/92/bf92503c2c81562d2f9491a3674aba22_530x69.png) 单元测试通过,说明生成的页码数量符合预期。 ### 页码号 C层给V层的页码号为0,1,2,3...,在输出时应该转换为1,2,3 student/index/index.component.html ``` <li *ngFor="let page of pages"> <a class="page-link" href="#">3</a> ✘ <a class="page-link" href="#">{{page + 1}}</a> ✚ </li> ``` ![](https://img.kancloud.cn/f7/b6/f7b696e241def337af764d378cfe21d7_491x53.png) 效果有了,再进行单元测试以保证本功能在以后项目的更新过程中也是可用的。页码号与页码渲染个数两个单元测试基于相同的前提:设置C层的页码,渲染V层最终获取UL元素。本着**不造重复的轮子**的原则将其公用的代码抽离如下: student/index/index.component.spec.ts ``` describe('Student -> IndexComponent', () => { ... /** * V层分页测试BEFORE */ const viewPageBefore = (): HTMLUListElement => { component.pages = new Array<number>(3, 4, 5, 6, 7); fixture.detectChanges(); /* 获取分页 */ const debugElement = fixture.debugElement.query(By.css('ul.pagination')); return HTMLUListElement = debugElement.nativeElement; }; fit('页码渲染个数', () => { const ulElement: HTMLUListElement = viewPageBefore(); ① /* 断言分页个数 */ console.log(ulElement.getElementsByTagName('li')); expect(ulElement.getElementsByTagName('li').length).toEqual(9); }); ``` * ① 在此处调用抽离的方法 测试页码号用例: student/index/index.component.spec.ts ``` describe('Student -> IndexComponent', () => { ... fit('测试页码号', () => { const ulElement: HTMLUListElement = viewPageBefore(); const liElements: HTMLCollection = ulElement.getElementsByTagName('li'); ➊ /* 依次获取第3 4 5 6 7页,断言对应的页码为4,5,6,7,8 */ for (let i = 2; i < 7; i++) { console.log(liElements[i].textContent); ① expect(liElements[i].textContent).toContain((i + 2).toString()); ② } }); ``` * ➊ 通过getElementsByTagName方法获取到的返回值类型为:HTMLCollection * ① 首次使用在控制台中进行打印 * ② 使用contains方法适用更为宽泛,比如后面即将使用的当前面`<span class="page-link">2<span class="sr-only">(current)</span></span>`。在不考虑当前页的情况下此处使用toEqual亦可。 ### 点击触发onPage方法 单元测试中依次点击几个页码,并依次断言以页码值调用C层的onPage()方法,测试用例如下: student/index/index.component.spec.ts ``` describe('Student -> IndexComponent', () => { ... fit('页码点击测试', () => { const ulElement: HTMLUListElement = viewPageBefore(); const liElements: HTMLCollection = ulElement.getElementsByTagName('li'); spyOn(component, 'onPage'); for (let i = 2; i < 7; i++) { const htmlLiElement = liElements[i] as HTMLLIElement; ➊ htmlLiElement.click(); expect(component.onPage).toHaveBeenCalledWith(i + 1); ➋ } }); }); ``` * ➊ 使用as进行数据类型的强制转换。与JAVA不同,此处即使是类型不相符也不会报错(但可能后续的功能会失效【了解即可】) * ➋ 依次点击3,4,5,6,7页,传给onPage的值也是3,4,5,6,7 功能代码相对简单: student/index/index.component.html ``` <li *ngFor="let page of pages" (click)="onPage(page)"✚> <a class="page-link" href="#">{{page + 1}}</a> </li> ``` ### 选中当前页 第一次完成某效果时,参考官方的文档是最简单有效的方式。正式动手写之前先浏览下bootstrap的示例代码: ![](https://img.kancloud.cn/3c/d9/3cd9c8c1ead2c0a3d3e1c69533cff1f6_769x450.png) 有了示例代码,功能性的代码也就不难了。 student/index/index.component.html ``` <li class="page-item"★ [ngClass]="{'active': params.page === page}"① *ngFor="let page of pages" (click)="onPage(page)"> <a class="page-link" href="#" *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> ``` * ① 页码为当前页时,增加active样式 * ② 非当前页时,显示可点击的分页 * ③ 当前页时,显示不可点击的分页 对应的单元测试代码如下: student/index/index.component.spec.ts ``` describe('Student -> IndexComponent', () => { ... fit('选中当前页测试', () => { component.params.page = 4; ① const ulElement: HTMLUListElement = viewPageBefore(); }); }); ``` * ① 模拟第4页为当前页,单元测试查看效果: ![](https://img.kancloud.cn/9e/28/9e28289c96dfd40452786a3ff373d617_517x60.png) 确认效果后,继续使用单元测试对此效果进行确认: student/index/index.component.spec.ts ``` describe('Student -> IndexComponent', () => { ... fit('选中当前页测试', () => { component.params.page = 4; const ulElement: HTMLUListElement = viewPageBefore(); const liElements: HTMLCollection = ulElement.getElementsByTagName('li'); /* 断言只有ul元素下只有一个active子元素,且该子元素的位置符合预期 */ expect(ulElement.getElementsByClassName('active').length).toBe(1); ① const htmlLiElement = liElements[3] as HTMLLIElement; ② expect(htmlLiElement.classList.contains('active')).toBe(true); ② /* 断言该li元素中存在class为sr-only的元素 */ const elements = htmlLiElement.getElementsByClassName('sr-only'); console.log(elements); expect(elements.length).toEqual(1); expect(elements[0].textContent).toContain('(current)'); ③ }); }); ``` * ① ul下仅有一个class=active的li元素 * ② 该元素对页的page值为4 * ③ 当前页中存在sr-only样式的元素,元素内容为(current) 至此,页码开发基本完毕。 ## 下一页 此方法与上一页类似,将params.page替换为pageStudent.totalPages即可,请自行完成。 提示:CSS选择器 匹配同类型中的倒数第 2 个同级兄弟元素为`:nth-last-child(n)`,`n`同样为1基。 ## 尾页 此方法与首页类似,请自行完成。 提示:CSS选择器 最后一个元素为`:last-of-type` # 总结 本小节我们大量的应用了粒度最小的化的测试方法,将每个功能点尽力的拆分一个个独立可测试的小的功能。在相对复杂的一些功能中,汇制了流程图来帮助我们梳理功能的逻辑处理。后依流程图对功能点进行拆分,进而对每个小的功能点独立进行开发测试。虽然有些单元测试的语法我们第一次使用,但由于每个测试用例中的代码量均不大,所以在学习及阅读过程中并不址分费力。而且更重要的是:单元测试代码随时保障了功能代码的可用性。 # 参考文档 | 名称 | 链接 | 预计学习时长(分) | | --- | --- | --- | | 源码地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.6.8](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.6.8) | - | | bootstrap grid | [https://getbootstrap.net/docs/layout/grid/](https://getbootstrap.net/docs/layout/grid/) | 5 | | bootstrap pagination | [https://getbootstrap.net/docs/components/pagination/](https://getbootstrap.net/docs/components/pagination/) | 5 | | spyOn | [https://jasmine.github.io/api/3.3/global.html#spyOn](https://jasmine.github.io/api/3.3/global.html#spyOn) | 5 | | HTMLDivElement | [https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLDivElement](https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLDivElement) | 5 | | Node.textContent | [https://developer.mozilla.org/zh-CN/docs/Web/API/Node/textContent](https://developer.mozilla.org/zh-CN/docs/Web/API/Node/textContent) | 5 | | HTMLLIElement | [https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLLIElement](https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLLIElement) | 5 | | Element.classList | [https://developer.mozilla.org/zh-CN/docs/Web/API/Element/classList](https://developer.mozilla.org/zh-CN/docs/Web/API/Element/classList) | 5 | | CSS 选择器 | [https://www.runoob.com/cssref/css-selectors.html](https://www.runoob.com/cssref/css-selectors.html) | - |