本节可能稍微有些难度,目的在于带领大家更多的了解Angular。如果在学习第二遍后仍然心中没有头绪的话,建议学习完整个教程或对Angular有了更深入的认识或是以后在生产中碰到类似的需求时再阅读。
当前项目我们执行`ng t`的时候,由于测试文件执行的过程是随机的。在某些时候,将发生类似于如下错误:
![image-20210415104727722](https://img.kancloud.cn/c1/43/c1432b2cbbe532bedda51852726456a8_2988x186.png)
或者在控制台中报如下错误:
![image-20210415104748537](https://img.kancloud.cn/5e/1d/5e1d021743e3cf8c4afc5a84bc89c12e_2574x140.png)
以及以下错误:
![image-20210415104806534](https://img.kancloud.cn/ac/b8/acb865d4ae4e6be833213e68dd5558b6_2566x124.png)
或以下错误:
![image-20210415104819641](https://img.kancloud.cn/82/de/82deda7b99e07157f069251cd0808971_2582x138.png)
或以下错误:
![image-20210415104839087](https://img.kancloud.cn/7d/7b/7d7b429fb1497f74a53606f483a195fd_2552x138.png)
或者其它的类似错误。
但奇怪的是,无论我们单独执行任意一个单元测试文件,都不会发生任何异常。这又是为何呢?
## 单例模式
在继续学习之前,让我们新建个测试文件复习一下前面讲过的单例模式。在`src/app`目录下新建`single-case.spec.ts`文件,加入一些前面我们在单例模式的学习中已经认知的代码,其代码初始化如下:
```typescript
import {TestBed} from '@angular/core/testing';
describe('单例模式相关测试', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({})
.compileComponents();
});
});
```
然后新建一个服务A:
```typescript
+++ b/first-app/src/app/single-case.spec.ts
@@ -1,6 +1,18 @@
import {TestBed} from '@angular/core/testing';
+import {Injectable} from '@angular/core';
+import {randomNumber} from '@yunzhi/ng-mock-api';
describe('单例模式相关测试', () => {
+ @Injectable({providedIn: 'root'})
+ class A {
+ key: number;
+
+ constructor() {
+ console.log('a constructor be called');
+ this.key = randomNumber();
+ }
+ }
+
beforeEach(async () => {
await TestBed.configureTestingModule({})
.compileComponents();
```
再建立两个依赖于A的服务BC:
```typescript
+++ b/first-app/src/app/single-case.spec.ts
@@ -13,6 +13,18 @@ describe('单例模式相关测试', () => {
}
}
+ @Injectable({providedIn: 'root'})
+ class B {
+ constructor(private a: A) {
+ }
+ }
+
+ @Injectable({providedIn: 'root'})
+ class C {
+ constructor(private a: A) {
+ }
+ }
+
beforeEach(async () => {
await TestBed.configureTestingModule({})
.compileComponents();
```
然后新建一个测试用例,在该测试用例中,分别获取Angular托管下B以及C的实例。
```typescript
+++ b/first-app/src/app/single-case.spec.ts
@@ -29,4 +29,11 @@ describe('单例模式相关测试', () => {
await TestBed.configureTestingModule({})
.compileComponents();
});
+
+ fit('验证单例', () => {
+ const b = TestBed.inject(B);
+ console.log(b);
+ const c = TestBed.inject(C);
+ console.log(c);
+ });
});
```
控制台如下:
![image-20210415120922802](https://img.kancloud.cn/12/84/1284e5fbfa54d44428f89e6234ab2db0_1398x322.png)
如我们的预期一致,在Angular中服务A是个单例的,且在整个生命周期中仅被初始化一次。
我们说之所以能够在BC中获取到相同的A,是同于A使用了`@Injectable({providedIn: 'root'})`注解。该注解的作用是在根模块中提供A,所以在Angular项目的任意模块需要A的实例时,都会先查是否有了A的实例,如果有则直接注入,如果没有则先实例化一个再注入。
## providers
预使模块能提供A的能力,除了可以把A注入到根模块外,还可以将其声明在`providers`中。为此,我们先删除A类上的`@Injectable({providedIn: 'root'})`注解:
```typescript
+++ b/first-app/src/app/use-class.spec.ts
@@ -3,7 +3,6 @@ import {Injectable} from '@angular/core';
import {randomNumber} from '@yunzhi/ng-mock-api';
describe('useClass相关测试', () => {
- @Injectable({providedIn: 'root'})
class A {
key: number;
```
此时将出现找不到A的提供者的错误:
![image-20210415135249554](https://img.kancloud.cn/2a/be/2abe99c597e4c711bbd09ecf0c1cb024_1666x168.png)
接下来将A声明在当前动态测试模块的`providers`中:
```typescript
+++ b/first-app/src/app/use-class.spec.ts
@@ -25,7 +25,11 @@ describe('useClass相关测试', () => {
}
beforeEach(async () => {
- await TestBed.configureTestingModule({})
+ await TestBed.configureTestingModule({
+ providers: [
+ 👉{provide: ①A, useClass: ②A}
+ ]
+ })
.compileComponents();
});
```
👉 如此当前模块便拥有了投供A的能力。该行语句可理解为:当需要①A时,使用②A是否有可用的对象,有则直接使用,没有则实例化一个。当然,像这种使用A提供A的语句,还可以简写为`{provide: A}`或者`A`。也就是说下面①②③三种写法的是等价的:
```typescript
providers: [
A, ①
{provide: A}, ②
{provide: A, useClass: A} ③
]
```
## forRoot()
前面我们学习过:使用`@Injectable({providedIn: 'root'})`时该服务的注入范围为根模块。而Angular中所有的模块都属于根模块的子模块,所以如果一个服务声明为`@Injectable({providedIn: 'root'})`,则无论在哪个模块中注入它,最终都会得到相同的实例。
但有一些服务即需要将其注入范围声明为根模块,又不能够使用`@Injectable({providedIn: 'root'})`注解,比如说需要进行配置的路由模块,或是需要进行配置的`MockApi`模块,这时候便需要Angular建议的`forRoot()`方法来解决这个问题。
比如我们在提供A时,手动的设定key的值:
```typescript
+++ b/first-app/src/app/single-case.spec.ts
@@ -6,9 +6,9 @@ describe('单例模式相关测试', () => {
class A {
key: number;
- constructor() {
+ constructor(key: number) {
console.log('a constructor be called');
- this.key = randomNumber();
+ this.key = key;
}
}
```
此时历史的写法则将报一个无没初始化A的错误:
![image-20210415150836227](https://img.kancloud.cn/08/79/087935e4d289669aff520223ec0c82fb_1516x108.png)
的确如此:我们在构造函数中声明了一个参数key,但Angular并不清楚这个key具体应该赋给它什么值。此时可以创建一个拥有静态的`forRoot()`模块:
```typescript
+++ b/first-app/src/app/single-case.spec.ts
@@ -1,5 +1,5 @@
import {TestBed} from '@angular/core/testing';
-import {Injectable, ModuleWithProviders} from '@angular/core';
+import {Injectable, ModuleWithProviders, NgModule} from '@angular/core';
describe('单例模式相关测试', () => {
@@ -12,6 +12,18 @@ describe('单例模式相关测试', () => {
}
}
+ @NgModule()
+ class AModule {
+ static ①forRoot(key: number): ②ModuleWithProviders<AModule> {
+ return {
+ ngModule: ③AModule,
+ providers: [
+ {provide: A, ④useValue: new ⑤A(key)}
+ ]
+ };
+ }
+ }
+
@Injectable({providedIn: 'root'})
class B {
constructor(private a: A) {
```
- ① `forRoot()`方法可以接收任意参数
- ② 返回值类型为`ModuleWithProviders`,在该类型上指名了提供的服务类型AModule
- ③ 返回值的ngModule字段对应`ModuleWithProviders`上的泛型
- ④ `useValue`用于返回一个对象。意为当Angular需要一个A实例时,如果有则直接返回;如果没有,则使用`useValue`后的实例
- ⑤ 提供了一个根据参数设置了`key`的实例
如此以来便可以使用`forRoot()`方法来添加一个配置了参数的A服务了:
```typescript
+++ b/first-app/src/app/single-case.spec.ts
@@ -38,8 +38,8 @@ describe('单例模式相关测试', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
- providers: [
- {provide: A, useClass: A.forRoot(123)}
+ imports: [
+ AModule.forRoot(123)
]
})
.compileComponents();
```
注意,此时将`AModule.forRoot()`方法声明在`imports`中而不是`providers`中。如此以来,便可以在动态模块中`imports`相应模块的同时,加入特定的配置参数`123`了:
![image-20210415155002300](https://img.kancloud.cn/66/5f/665f9025920d8a3180da5c113fd112df_810x194.png)
## 步入正题
铺垫了这么多,终于可以步入正题了。之所以在单元测试中出现了随机出现了那么多的错误,是由于`TestBed`在处理拦截器时使用了惰性加载。
我们在单元测试中存在以下代码①:
```typescript
src/app/clazz/class-select.component.spec.ts
providers: [
{
provide: HTTP_INTERCEPTORS, multi: true,
useClass: MockApiTestingInterceptor.forRoot([
TeacherMockApi
])
}
```
以下代码②:
```typescript
src/app/clazz/add/add.component.mock-api.sepc.ts
providers: [
{
provide: HTTP_INTERCEPTORS,
multi: true,
useClass: MockApiInterceptor.forRoot([ClazzMockApi, TeacherMockApi])
}
```
以及以下代码③:
```typescript
src/app/mock-api/mock-api-testing.module.ts
providers: [
{
provide: HTTP_INTERCEPTORS, multi: true,
useClass: MockApiTestingInterceptor.forRoot([
ClazzMockApi,
TeacherMockApi,
StudentMockApi
])
}
]
```
Angular的惰性加载机制使得其在进行多文件的单元测试时,多个文件中的TestBed共享了`MockApiTestingInterceptor.forRoot()`方法中的返回值,该返回值是使用不同的MockApi进行配置的。
所以如果其共享的是①的返回值,该返回值则仅仅添加了`TeacherMockApi`对应的几个模拟接口,此时依赖于另外两个模拟接口的单元测试将报错;如果其共享的是②的返回值,该返回值则仅仅添加了`ClazzMockApi, TeacherMockApi`两个模拟接口, 此时依赖于另一个模拟接口的单元测试将报错;如果其共享的是③的返回值,则将添加所有的模拟接口,此时所有的单元测试将正常执行。
问题猜测到了,解决问题的方案也就不难了。只需要在所有应用后台模拟API的地方添加上所有的Mock文件即可。或是删除在其它测试文件中加入Http拦截器的代码,改为引用`MockApiTestingModule`从而达到引入所有API的目的。
具体修正文件列表如下:
```typescript
clazz/add/add.component.mock-api.spec.ts
clazz/clazz.component.spec.ts
clazz/klass-select/klass-select-form-control.component.spec.ts
clazz/klass-select/klass-select.component.spec.ts
```
请自行修正。
<hr>
模拟接口的错误修正后,最后再修正个逻辑上的错误。把鼠标放到出错的文件名上,浏览器将显示该文件所在的具体位置。
![image-20210415161608176](https://img.kancloud.cn/95/ee/95ee97f9fcd84e096abbd14d6308ff7e_2092x414.png)
找到该文件的第29行代码:
```typescript
beforeEach(() => {
fixture = TestBed.createComponent(EditComponent);
component = fixture.componentInstance;
👉 fixture.detectChanges();
});
```
说明在组件初始化时发生了错误。继续查看错误的堆栈信息发现是在组件的46行,调用loadbyId方法时触发了MockApi,近而发生了异常:
![image-20210415161848961](https://img.kancloud.cn/ea/70/ea706a6291baa412fb40cda71486a728_1012x272.png)
```typescript
ngOnInit(): void {
34 const id = this.activatedRoute.snapshot.params.id;
35 👉 this.loadById(+id);
}
/**
* 由后台加载预编辑的班级.
* @param id 班级id.
*/
loadById(id: number): void {
console.log('loadById');
this.formGroup.get('id')?.setValue(id);
this.httpClient.get<Clazz>('/clazz/' + id.toString())
46 👉 .subscribe(clazz => {
console.log('接收到了clazz', clazz);
this.nameFormControl.patchValue(clazz.name);
this.formGroup.get('teacherId')?.setValue(clazz.teacher.id);
}, error => console.log(error));
}
```
分析代码可知,这是由于在组件初始化时,将34行代码获取到的ID值null再转换为数字时发生了NaN错误造成的。预解决这个错误,则需要保证37行代码接收的值非null。
为此,我们组件初始化以前为这个34的代码设置一个id值:
```typescript
+++ b/first-app/src/app/clazz/edit/edit.component.spec.ts
@@ -6,6 +6,7 @@ import {MockApiTestingModule} from '../../mock-api/mock-api-testing.module';
import {ReactiveFormsModule} from '@angular/forms';
import {getTestScheduler} from 'jasmine-marbles';
import {RouterTestingModule} from '@angular/router/testing';
+import {ActivatedRoute} from '@angular/router';
describe('EditComponent', () => {
let component: EditComponent;
@@ -26,6 +27,8 @@ describe('EditComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(EditComponent);
component = fixture.componentInstance;
+ const activatedRoute = TestBed.inject(ActivatedRoute);
+ activatedRoute.snapshot.params.id = 123;
👉 fixture.detectChanges();
});
```
这样的话,当执行到👉组件初始化的代码时,获取`activatedRoute.snapshot.params.id`便会得到数据123,而非null了。
至此,我们解决了单元测试中所有非预期错误。
| 名称 | 链接 |
| -------- | ------------------------------------------------------------ |
| 单例服务 | [https://angular.cn/guide/singleton-services](https://angular.cn/guide/singleton-services) |
| 本节源码 | [https://github.com/mengyunzhi/angular11-guild/archive/step7.3.zip](https://github.com/mengyunzhi/angular11-guild/archive/step7.3.zip) |
- 序言
- 第一章 Hello World
- 1.1 环境安装
- 1.2 Hello Angular
- 1.3 Hello World!
- 第二章 教师管理
- 2.1 教师列表
- 2.1.1 初始化原型
- 2.1.2 组件生命周期之初始化
- 2.1.3 ngFor
- 2.1.4 ngIf、ngTemplate
- 2.1.5 引用 Bootstrap
- 2.2 请求后台数据
- 2.2.1 HttpClient
- 2.2.2 请求数据
- 2.2.3 模块与依赖注入
- 2.2.4 异步与回调函数
- 2.2.5 集成测试
- 2.2.6 本章小节
- 2.3 新增教师
- 2.3.1 组件初始化
- 2.3.2 [(ngModel)]
- 2.3.3 对接后台
- 2.3.4 路由
- 2.4 编辑教师
- 2.4.1 组件初始化
- 2.4.2 获取路由参数
- 2.4.3 插值与模板表达式
- 2.4.4 初识泛型
- 2.4.5 更新教师
- 2.4.6 测试中的路由
- 2.5 删除教师
- 2.6 收尾工作
- 2.6.1 RouterLink
- 2.6.2 fontawesome图标库
- 2.6.3 firefox
- 2.7 总结
- 第三章 用户登录
- 3.1 初识单元测试
- 3.2 http概述
- 3.3 Basic access authentication
- 3.4 着陆组件
- 3.5 @Output
- 3.6 TypeScript 类
- 3.7 浏览器缓存
- 3.8 总结
- 第四章 个人中心
- 4.1 原型
- 4.2 管道
- 4.3 对接后台
- 4.4 x-auth-token认证
- 4.5 拦截器
- 4.6 小结
- 第五章 系统菜单
- 5.1 延迟及测试
- 5.2 手动创建组件
- 5.3 隐藏测试信息
- 5.4 规划路由
- 5.5 定义菜单
- 5.6 注销
- 5.7 小结
- 第六章 班级管理
- 6.1 新增班级
- 6.1.1 组件初始化
- 6.1.2 MockApi 新建班级
- 6.1.3 ApiInterceptor
- 6.1.4 数据验证
- 6.1.5 教师选择列表
- 6.1.6 MockApi 教师列表
- 6.1.7 代码重构
- 6.1.8 小结
- 6.2 教师列表组件
- 6.2.1 初始化
- 6.2.2 响应式表单
- 6.2.3 getTestScheduler()
- 6.2.4 应用组件
- 6.2.5 小结
- 6.3 班级列表
- 6.3.1 原型设计
- 6.3.2 初始化分页
- 6.3.3 MockApi
- 6.3.4 静态分页
- 6.3.5 动态分页
- 6.3.6 @Input()
- 6.4 编辑班级
- 6.4.1 测试模块
- 6.4.2 响应式表单验证
- 6.4.3 @Input()
- 6.4.4 FormGroup
- 6.4.5 自定义FormControl
- 6.4.6 代码重构
- 6.4.7 小结
- 6.5 删除班级
- 6.6 集成测试
- 6.6.1 惰性加载
- 6.6.2 API拦截器
- 6.6.3 路由与跳转
- 6.6.4 ngStyle
- 6.7 初识Service
- 6.7.1 catchError
- 6.7.2 单例服务
- 6.7.3 单元测试
- 6.8 小结
- 第七章 学生管理
- 7.1 班级列表组件
- 7.2 新增学生
- 7.2.1 exports
- 7.2.2 自定义验证器
- 7.2.3 异步验证器
- 7.2.4 再识DI
- 7.2.5 属性型指令
- 7.2.6 完成功能
- 7.2.7 小结
- 7.3 单元测试进阶
- 7.4 学生列表
- 7.4.1 JSON对象与对象
- 7.4.2 单元测试
- 7.4.3 分页模块
- 7.4.4 子组件测试
- 7.4.5 重构分页
- 7.5 删除学生
- 7.5.1 第三方dialog
- 7.5.2 批量删除
- 7.5.3 面向对象
- 7.6 集成测试
- 7.7 编辑学生
- 7.7.1 初始化
- 7.7.2 自定义provider
- 7.7.3 更新学生
- 7.7.4 集成测试
- 7.7.5 可订阅的路由参数
- 7.7.6 小结
- 7.8 总结
- 第八章 其它
- 8.1 打包构建
- 8.2 发布部署
- 第九章 总结