由于在上个小节中启用了**数据源**机制,当用户的登录状态发生改变时。负责记录用户登录状态的数据源能够及时的将登录状态的最新值发送给所有的订阅者。所以注销功能的实现只需要向该数据源发送false值即可。 # 原型 找到nav组件,并在V层中参考bootstrap的示例文档加入注销按如下: src/app/nav/nav.component.html ```html ... <form class="form-inline"> <button class="btn btn-success" type="button" (click)="onLogout()">注销</button> </form> </nav> ``` 对应在C层中增加onLogout方法: src/app/nav/nav.component.ts ```html onLogout() { console.log('onLogout'); } ``` ## 单元测试 使用单元测试以确认用户点击"注销"按钮时成功的触发了C层的onLogout方法: src/app/nav/nav.component.spec.ts ```javascript fit('点击注销按钮', () => { spyOn(component, 'onLogout'); FormTest.clickButton(fixture, 'button'); expect(component.onLogout).toHaveBeenCalled(); }); ``` # 功能开发 在点击注销按钮时向TeacherService中发送**登录状态**为false即可: src/app/nav/nav.component.ts ```javascript onLogout() { this.teacherService.setIsLogin(false); } ``` ## 单元测试 当被测组件nav中声明依赖于TeacherService时,按前面已掌握的测试方法,有两种解决方面来做测试准备: 第一种方案:由于TeacherService声明的注入范围为root,所以当前的测试模块可以直接获取到TeacherService。同时又由于TeacherService声明依赖于HttpClient等服务,所以要使得测试模块成功的启用TeacherService,那么直接在测试模块中注入TeacherService所依赖服务所在的模块即可,比如TeacherService依赖于HttpClient,则当前测试模块的准备代码如下: src/app/nav/nav.component.spec.ts ```javascript imports: [RouterTestingModule, HttpClientTestingModule ✚ ] ``` 这种方案最大的缺点在于:1. 当前测试的为nav组件,却需要考虑TeacherService的依赖,这脱离了测试主线,跑题了。在测试nav组件时我们希望仅考虑该组件的依赖,而不希望再去考虑依赖的依赖。 2. 当依赖的服务的依赖链较短时还行,在如果nav组件依赖于a,然后a又依赖于b,b又依赖于c,等等等等。这在测试的构建中无疑走向一个无底洞。3. 这极不易于维护。以TeacherService为例。当前项目中Login及nav组件分别依赖于TeacherService,同时TeacherService组件又声明依赖于HttpClient。那么在构建login及nav的测试模块时,则需要分别声明依赖于HttpClient。一旦TeacherService的依赖发生变更,比如TeacherService又声明依赖于Router,那么此时login及nav的测试模块便需要分别增加对Router的依赖支持。不止如此,在这种测试模式下,也会给维护人员带来一定的思想负担:本来仅是给TeacherService中增加了一个依赖,为何nav及Login组件报错呢? 第二种方案:为了避免测试模块在获取TeacherService时使用真实的TeacherService而带来的第一点带来的种种问题。可以在测试模块中使用provide为TeacherService指定提供者TeacherStubService。这样一来当测试模块需要TeacherService时,将直接使用该TeacherStubService。 src/app/nav/nav.component.spec.ts ```javascript providers: [ {provide: TeacherService, useClass: TeacherStubService} ] ``` 这种方式规避了方案一中的弊端。但当组件与服务的依赖关系增加多时会为书写增加一些负担。比如Login及nav组件同时声明依赖于TeacherService及Router,则需要于两个组件测试模块中分别为TeacherService及Router声明提供者TeacherStubService及RouterStub。 本节提供的另一种新的解决方案将完美的规避上述两种方案的弊端,从而使构建单元测试模块变成一件非常轻松的事情。 # TestModule 如下图首先新建一个TeacherService的替身TeacherServiceStub ![](https://img.kancloud.cn/52/be/52be7885992474d681f06fa812e287d5_518x105.png) 接着新建一个TestModule,并在该模块中使用声明:使用TeacherStubService来代替TeacherService ![](https://img.kancloud.cn/a0/cf/a0cfb3f5fdbeaae909c15e6440469d0d_539x341.png) 接下来在相应的动态测试模块中引入该TestModule ![](https://img.kancloud.cn/e9/11/e911d1a273318b26c486ff6c0776515e_603x649.png) 则相应的动态测试模块将读取providers中的信息,从而将TeacherService做为依赖TeacherService注入对应的测试模块: ![](https://img.kancloud.cn/df/6e/df6e82296885af9c3aff2536df1491f3_713x646.png) 同时,还可以在测试模块声明其它真实服务的替身。如此一来,可以首先在TestModule中声明项目中所用服务的替身,然后在对应的测试模块中声明`import TestModule`,则无论测试组件依赖于哪些服务都可以被TestModule中的替身所满足。下图展示了TestModule是如何同时提供TeacherService及Router替身的。 ![](https://img.kancloud.cn/ff/79/ff79024af3e9247c7120915dc0de63f8_820x685.png) ## 初始化 ``` panjiedeMac-Pro:web-app panjie$ cd src/app/ panjiedeMac-Pro:app panjie$ ng g module test CREATE src/app/test/test.module.ts (190 bytes) ``` ## 创建TeacherStubService 进入test文件夹并创建service文件夹,接着进行service文件夹,使用`ng g s teacher-stub --skip-tests`命令来创建一个没有单元测试文件的服务。 ``` panjiedeMac-Pro:service panjie$ ng g s teacher-stub --skip-tests ➊ CREATE src/app/test/service/teacher-stub.service.ts (140 bytes) ``` * ➊ 加入`--skip-tests`以避免创建冗余的单元测试文件 打开`src/app/test/service/teacher-stub.service.ts`,删除`@Injectable`的部分以防止误将其做为非测试的依赖被添加。 ```javascript @Injectable({ ✘ providedIn: 'root' ✘ }) ✘ export class TeacherStubService { constructor() { } } ``` * @Injectable的作用是设置注入范围,即声明在某个范围以内该服务可以被做为依赖自动注入。 * 去除@Injectable,以避免在开发过程中**误**注入此测试服务。 ## 加入providers ``` @NgModule({ declarations: [], imports: [ CommonModule ], providers: [ {provide: TeacherService➊, useClass: TeacherStubService➋} ] }) export class TestModule { } ``` * ➊➋ 使用TeacherStubService来代替TeacherService ## 测试 src/app/nav/nav.component.spec.ts ```javascript beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [NavComponent], imports: [RouterTestingModule, TestModule ✚ ], }) .compileComponents(); })); fit('测试依赖注入', () => { const service = TestBed.get(TeacherService); console.log(service); }); ``` 查看控制台打印信息如下: ![](https://img.kancloud.cn/d5/a4/d5a4ad5908de8b07805787e361a6cfe7_426x199.png) 控制台的打印信息印证了TestModule中声明的provide对于此测试模块是生效的,当获取测试模块中被注入的TeacherService时,实际上获取到的是TeacherStubService 基于此测试方案,继续完成nav组件中logout方法的单元测试 # logout单元测试 src/app/nav/nav.component.spec.ts ```javascript fit('onLogout', () => { const service = TestBed.get(TeacherService) as➊ TeacherService; spyOn(service, 'setIsLogin'); component.onLogout(); expect(service.setIsLogin).toHaveBeenCalledWith(false); }); ``` * ➊ 使用as进行类型转换,以便在进一步操作service变量时能够能到编辑器的进一步提示 ![](https://img.kancloud.cn/10/f7/10f71bc95f51f9698bebb5ecc22111c2_396x109.png) 提示说setisLogin方法并不存在。这个原因是由于使用了TeacherStubService替换了真实的TeacherService,但TeacherStubService尚未添加setIsLogin方法。解决方案如下: src/app/test/service/teacher-stub.service.ts ```javascript export class TeacherStubService { setIsLogin(isLogin: boolean): void { return; } } ``` 再次运行单元测试通过。 # 修正遗漏问题 上个小节中进行了功能修正后并未及时的修正单元测试,当所有的`fit`恢复为`it`后做全局单元测试: ![](https://img.kancloud.cn/1d/84/1d84fee6e3fe24e255cd16a638915e04_618x304.png) ## LoginComponent 此单元测试的具体错误信息为: ``` Error: Expected spy log to have been called with [ true ] but it was never called. at <Jasmine> at UserContext.<anonymous> (http://localhost:9876/_karma_webpack_/src/app/login/login.component.spec.ts:70:25) ``` 说在login.component.spec.ts的第70行的断言发生错误,断言spy log被调用,但是其根本没有被调用过。 ```javascript expect(console.log).toHaveBeenCalledWith(true); ``` 查看对应C层的onSubmit方法后,确认该方法的逻辑已经由在控制台打印日志改为:返回的结果为真,则继续调用teacherService.setIsLogin方法;为假则在控制台打印"用户名密码错误" 修正如下: src/app/login/login.component.spec.ts ```javascript beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [LoginComponent], imports: [ ReactiveFormsModule, HttpClientTestingModule ✘ TestModule ✚ ] }) .compileComponents(); })); fit('onSubmit', () => { // 获取teacherService实例,并为其login方法设置替身 const teacherService = TestBed.get(TeacherService) as TeacherService; spyOn(teacherService, 'login').and.returnValue(of(true)); spyOn(teacherService, 'setIsLogin'); spyOn(console, 'log'); // 添加测试数据并调用 component.formGroup.get('username').setValue('testUsername'); component.formGroup.get('password').setValue('testPassword'); component.onSubmit(); // 断言成功调用teacherService的login方法 expect(teacherService.login).toHaveBeenCalledWith('testUsername', 'testPassword'); expect(teacherService.setIsLogin).toHaveBeenCalledWith(true); }); ``` 测试完返回真以后,继续测试返回假: src/app/login/login.component.spec.ts ```javascript spyOn(teacherService, 'login').and.returnValue(of(true)); ✘ spyOn(teacherService, 'login').and.returnValues(of(true), of(false)); ➊ ... expect(teacherService.setIsLogin).toHaveBeenCalledWith(true); // teacherService.login返回假时 component.onSubmit(); expect(console.log).toHaveBeenCalledWith('用户名密码错误'); }); ``` * ➊ 对teacherService.log进行多次调用时应该使用returnValues方法,该方法接收的值将按teacherService.log被调用的顺序,每次被调用时返回1个。 对应为TeacherStubService添加login方法 src/app/test/service/teacher-stub.service.ts ```javascript login(username: string, password: string): Observable<boolean> { return null; } ``` 单元测试通过。 ## AppComponent > should create the app 报错信息如下: ``` 'app-login' is not a known element: 1. If 'app-login' is an Angular component, then verify that it is part of this module. 2. If 'app-login' is a Web Component then add 'CUSTOM_ELEMENTS_SCHEMA' to the '@NgModule.schemas' of this component to suppress this message. ("<app-nav *ngIf="isLogin"></app-nav> ``` 这是由于在app组件中使用了login组件,但却没有将login组件声明在declarations中的原因,当前学习过的解决方案为两个。 第一种解决方案是引入真实的login组件,然后再引入真实的login组件所依赖的其它依赖,比如HttpClientTestingModule。这种依次向上解决依赖的方案的弊端在本节稍前的部分已有所阐述。 第二种方法是在本测试模块中声明一个与login组件具有相同selector的测试组件,并将此组件加入到测试模块中。此方案解决了第一种方案依次向上依赖的问题,在可维护性上也比较出色。但login组件同时被多个组件引用时,则需要在引用其的多个组件的测试模块中对应加入相同的login组件替身,而这必将违反不造相同的轮子的原则。 在此给出第三方案:在TestModule中声明与login组件相同selector的同名组件。然后供所有import TestModule的测试模块使用。 ### 初始化 在进行src/app/test文件夹,新建component文件夹并进入: ``` panjiedeMac-Pro:component panjie$ ng g c login --skip-tests CREATE src/app/test/component/login/login.component.sass (0 bytes) CREATE src/app/test/component/login/login.component.html (20 bytes) CREATE src/app/test/component/login/login.component.ts (266 bytes) UPDATE src/app/test/test.module.ts (478 bytes) ``` ### export 与提供服务替身的思想不同,若想在import TestModule的测试模块中认识LoginComponent,只需要将其声明为在exports中即可: src/app/test/test.module.ts ```javascript @NgModule({ declarations: [LoginComponent], imports: [ CommonModule ], exports➊: [ LoginComponent ], providers: [ {provide: TeacherService, useClass: TeacherStubService} ] }) export class TestModule { } ``` * ➊ exports来声明模块中的**公有**组件,即该组件可被import TestModule的其它模块识别被使用 ### 引入TestModule src/app/app.component.spec.ts ```javascript imports: [ RouterTestingModule, TestModule ✚ ], ``` 加入TestModule后,单元测试通过。此时并不需要像第一种方案一样去处理复杂的依赖关系,也可以在其它组件引入login组件时引用TestModule从而达到快速的构建测试模块的目的。 # 集成测试 重启后台后数据表可能已被自动清空,此时若想实现登录功能,则需要你自己想办法先在数据表中添加一名测试教师。具体测试过程略。 ![](https://img.kancloud.cn/7f/be/7fbe4c7ee34d7b7edcff7892eeb95b58_1425x380.gif) # 参考文档 | 名称 | 链接 | 预计学习时长(分) | | --- | --- | --- | | 源码地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step5.1.5](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step5.1.5) | - |