# 初始单元测试 原型永远是第一位的!本节中我们快速的对登录组件进行初始化,并尝试使用代码来测试登录按钮的绑定状态。 ## 初始化 打开shell并进行`src/app`文件夹,使用`ng g c login`初始化登录组件: ```bash panjiedeMacBook-Pro:app panjie$ ng g c login CREATE src/app/login/login.component.css (0 bytes) CREATE src/app/login/login.component.html (20 bytes) CREATE src/app/login/login.component.spec.ts (619 bytes) CREATE src/app/login/login.component.ts (271 bytes) UPDATE src/app/app.module.ts (806 bytes) ``` 然后,我们参考[bootstrap示例登录界面](https://getbootstrap.com/docs/5.0/forms/overview/)对原型初始化如下: ```html <form class="container-sm"> <div class="mb-3"> <label for="username" class="form-label">用户名</label> <input type="text" class="form-control" id="username" aria-describedby="usernameHelp"> <div id="usernameHelp" class="form-text">我们不会分享你的登录信息</div> </div> <div class="mb-3"> <label for="exampleInputPassword1" class="form-label">密码</label> <input type="password" class="form-control" id="exampleInputPassword1"> </div> <button type="submit" class="btn btn-primary">登录</button> </form> ``` ## 属性与方法 ```typescript +++ b/first-app/src/app/login/login.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import {Component, OnInit} from '@angular/core'; @Component({ selector: 'app-login', @@ -6,10 +6,18 @@ import { Component, OnInit } from '@angular/core'; styleUrls: ['./login.component.css'] }) export class LoginComponent implements OnInit { + teacher = {} as { + username: string, + password: string + }; - constructor() { } + constructor() { + } ngOnInit(): void { } + onSubmit(): void { + console.log('点击了登录按钮'); + } } ``` V层绑定: ```html +++ b/first-app/src/app/login/login.component.html @@ -1,12 +1,14 @@ -<form class="container-sm"> +<form class="container-sm" (ngSubmit)="onSubmit()"> <div class="mb-3"> <label for="username" class="form-label">用户名</label> - <input type="text" class="form-control" id="username" aria-describedby="usernameHelp"> + <input type="text" class="form-control" id="username" aria-describedby="usernameHelp" + [(ngModel)]="teacher.username" name="username"> <div id="usernameHelp" class="form-text">我们不会分享你的登录信息</div> </div> <div class="mb-3"> <label for="exampleInputPassword1" class="form-label">密码</label> - <input type="password" class="form-control" id="exampleInputPassword1"> + <input type="password" class="form-control" id="exampleInputPassword1" + [(ngModel)]="teacher.password" name="password"> </div> <button type="submit" class="btn btn-primary">登录</button> </form> ``` ## 测试 软件工程相比于交通、土木工程等其它实体工程有着先天的优势 ---- 几乎可以忽略不计的测试成本。所以我们在开发中,要摒弃**我认为**、**应该**等字眼,当不太清楚自己的代码是否正确运行时,最简单的方法就是测试一下。 加入测试代码: ```html +++ b/first-app/src/app/login/login.component.html @@ -1,3 +1,4 @@ +{{teacher | json}} <form class="container-sm" (ngSubmit)="onSubmit()"> <div class="mb-3"> <label for="username" class="form-label">用户名</label> ``` 在单元测试中: 1. 加入FormModule以支持`[(ngModel)]` 2. 启用自动检测变更以便捷观察数据的时实变更情况 ```typescript +++ b/first-app/src/app/login/login.component.spec.ts @@ -1,6 +1,7 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; import {LoginComponent} from './login.component'; +import {FormsModule} from '@angular/forms'; fdescribe('LoginComponent', () => { let component: LoginComponent; @@ -8,7 +9,10 @@ fdescribe('LoginComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [LoginComponent] + declarations: [LoginComponent], + imports: [ + FormsModule + ] }) .compileComponents(); }); @@ -21,5 +25,6 @@ fdescribe('LoginComponent', () => { it('should create', () => { expect(component).toBeTruthy(); + fixture.autoDetectChanges(); }); }); ``` 使用`ng t `快速启动组件: ![image-20210303081750151](https://img.kancloud.cn/fd/1e/fd1e172d6eb549ff6eda09185197f3a9_1624x782.png) - 测试一:数据绑定成功 - 测试二:按钮绑定生效 ## 单元测试 在一般的项目中,用人眼来对代码进行测试是不可靠的。它的不可靠主要体现在两个方面: * 随着组件功能的增多,人眼同时检测多种测试信息,免不了顾此失彼。开发了一个新功能同时,也可能破坏了一个原有的正常的功能。 * 由于**应该看什么**并没有形成文档。和合作开发中,张三在接手了李四的组件后,完全不知道应该看什么,哪是对的,哪又是错的。 鉴于此,我们可以采用使用代码来测试代码的方法,由于这种方法是针对功能点的某个小的功能单元进行测试,所以又被称为**单元测试**,英文关键字为**Unit Test**。 在此,我们简单介绍下如何使用单元测试来验证登录按钮与C层的`onSubmit`方法是否绑定成功。 ### 流程 此测试在思想上大概分为以下几步: 1. 获取V层中的登录按钮 2. 使用代码来点击这个按钮 3. 查看C层中的方法是否被触发 接下来,我们分别介绍上述步骤的实现方法。 ### 获取V层的登录按钮 在单元测试中,我们可以使用`fixture`来获取组件V层相关的数据,比如可以使用如下代码来获取当前V层对应的`dom`节点: ```typescript +++ b/first-app/src/app/login/login.component.spec.ts @@ -26,5 +26,7 @@ fdescribe('LoginComponent', () => { it('should create', () => { expect(component).toBeTruthy(); fixture.autoDetectChanges(); + + console.log(fixture.elementRef.nativeElement); }); }); ``` 来到控制台点击对应的日志内容,对应当前组件的`dom`。 ![image-20210303083131752](https://img.kancloud.cn/0b/13/0b137c31425cbba5a85a4298e598ad1c_2566x904.png) HTML中的每个元素都对应一个对象,该对象被称为**文档对象模型(Document Object Model)**。我们使用JS来操作HTML页面的便是通过操作这个**文档对象模型**。这样一来,相较于传统的直接编写html代码,网页的生成便又多了一种方法:javascript 操作 dom。 获取到组件dom后,我们在根据`dom`知识来获取当前`dom`下的子`dom` ---- 登录按钮。 ```typescript +++ b/first-app/src/app/login/login.component.spec.ts @@ -2,6 +2,7 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; import {LoginComponent} from './login.component'; import {FormsModule} from '@angular/forms'; +import {root} from 'rxjs/internal-compatibility'; fdescribe('LoginComponent', () => { let component: LoginComponent; @@ -28,5 +29,8 @@ fdescribe('LoginComponent', () => { fixture.autoDetectChanges(); console.log(fixture.elementRef.nativeElement); + const rootDivElement = fixture.elementRef.nativeElement as HTMLDivElement; 👈 + const submitButtonElement = rootDivElement.querySelector('button') as HTMLButtonElement; 👈 + console.log(submitButtonElement); }); }); ``` - 根据实际情况,使用`as`来为变量指定一个类型。 👈 ## 使用代码点击登录按钮 使用代码对按钮进行点击非常简单,仅仅需要调用该对象的`click()`方法即可: ```typescript +++ b/first-app/src/app/login/login.component.spec.ts @@ -32,5 +32,7 @@ fdescribe('LoginComponent', () => { const rootDivElement = fixture.elementRef.nativeElement as HTMLDivElement; const submitButtonElement = rootDivElement.querySelector('button') as HTMLButtonElement; console.log(submitButtonElement); + + submitButtonElement.click(); }); }); ``` 此时在控制台中成功的打印了相关日志,说明C层的`onSubmit`方法被成功的触发了,从而证明了绑定是成功的。 ![image-20210303084535974](https://img.kancloud.cn/c1/1f/c11f9c292a8c75d8323a60d695312a93_998x204.png) 上述过程中,我们成功的实现了:使用代码来点击**登录按钮**,但在最后的验证环节仍然是使用人眼进行观察的,这仍然没有消除人眼观察的**不可靠性**。 ### 验证C层方法被触发 遗憾的是,除了观察,我们是没有办法直接验证某个C层的方法是否被成功的调用的。为了实现这种验证,我们采用:建造模拟C层的方法来间接达到这个目的。 所谓的模拟C层,就是在根据当前的C层,建立一个外表看起来一模一样的C层。原C层有什么方法,我们的模拟C层就会有什么方法;原C层的方法中有什么样的参数,我们的模拟C层的方法中也会有什么参数。 如果我们仅仅是为了验证某一个方法,则还可以在这个方法上安排一个间谍。这像极了我们在电视剧中看到的谍战片。为了获取一手的情况,我们在在敌方的情报部门安排一个间谍。此时,敌方在我方间谍发送信息时,实际的信息却被我方获取了。 Jasmie提供的`spyOn`方法提供了这种放置间谍的功能: ```typescript +++ b/first-app/src/app/login/login.component.spec.ts @@ -32,7 +32,8 @@ fdescribe('LoginComponent', () => { const rootDivElement = fixture.elementRef.nativeElement as HTMLDivElement; const submitButtonElement = rootDivElement.querySelector('button') as HTMLButtonElement; console.log(submitButtonElement); - + + spyOn(component, 'onSubmit'); submitButtonElement.click(); }); }); ``` 此时再次运行单元测试代码,发现控制台的日志不见了: ![image-20210303085859717](https://img.kancloud.cn/47/a7/47a7dd291a2285d71b538b60990ecbad_1024x220.png) 为了近一步确认的确是间谍方法被调用了,我们还可以补充下`spyOn`方法: ```typescript +++ b/first-app/src/app/login/login.component.spec.ts @@ -33,7 +33,7 @@ fdescribe('LoginComponent', () => { const submitButtonElement = rootDivElement.querySelector('button') as HTMLButtonElement; console.log(submitButtonElement); - spyOn(component, 'onSubmit'); + spyOn(component, 'onSubmit').and.callFake(() => console.log('间谍方法被调用')); submitButtonElement.click(); }); }); ``` ![image-20210303090642492](https://img.kancloud.cn/a1/d1/a1d1cc75da6ae3d8da4bc421096a2492_1012x186.png) 最后,我们加入以下验证代码,来间谍来替我们验证`onSubmit`方法的确是被调用了。 ```typescript +++ b/first-app/src/app/login/login.component.spec.ts @@ -34,6 +34,10 @@ fdescribe('LoginComponent', () => { console.log(submitButtonElement); spyOn(component, 'onSubmit').and.callFake(() => console.log('间谍方法被调用')); + // 点击按钮以前,onSubmit方法应该被调用了0次。 + expect👈(component.onSubmit).toHaveBeenCalledTimes(0); submitButtonElement.click(); + // 点击按钮以后,onSubmit方法应该被调用了1次。 + expect👈(component.onSubmit).toHaveBeenCalledTimes(1); }); }); ``` **expect**👈代表期望,就是说我们预计组件的方法是被调用了0次或是1次,如果实际的情况与我们预计的相同,则该代码将正确运行;如果实际情况与我们预计的不同,则该处代码将会触发异常。 ![image-20210303091229639](https://img.kancloud.cn/0c/c1/0cc147be04505a559a30b4e7716566c8_1244x344.png) 此时,如果我们删除V层中关于触发C层的代码,则会得到如下异常。 ![image-20210303091548090](https://img.kancloud.cn/7a/71/7a716ba791bb73fcb318b05a1a771510_1032x152.png) 该异常提示我们:点击了V层的登录按钮后,并没有调用C层的`onSubmit`方法。而这种情况是不正确的。 ## 本节作业 尝试删除V层中关于触发C层的代码来触发异常。 | 名称 | 地址 | 备注 | | ----------------------- | ------------------------------------------------------------ | --------------------------- | | html dom | [https://www.runoob.com/htmldom/htmldom-tutorial.html](https://www.runoob.com/htmldom/htmldom-tutorial.html) | | | HtmlDivElement | [https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLElement](https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLElement) | 建议将语言切换为English查看 | | HtmlButtonElement | [https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLButtonElement](https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLButtonElement) | 建议将语言切换为English查看 | | Element.querySelector() | [https://developer.mozilla.org/en-US/docs/Web/API/Element/querySelector](https://developer.mozilla.org/en-US/docs/Web/API/Element/querySelector) | 看不太懂时,再切回中文查看 | | click() | [https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/click](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/click) | | | spyOn | [https://jasmine.github.io/api/edge/global.html#spyOn](https://jasmine.github.io/api/edge/global.html#spyOn) | | | Expect | [https://jasmine.github.io/api/edge/global.html#expect](https://jasmine.github.io/api/edge/global.html#expect) | | | 本节源码 | [https://github.com/mengyunzhi/angular11-guild/archive/step3.1.zip](https://github.com/mengyunzhi/angular11-guild/archive/step3.1.zip) | |