在生产环境中,常常会在父子组件的链接上出现一些BUG。总结来说,这些BUG大体分为两类。第一类父组件向子组传值的错误,第二类是子组件向父组件弹值的错误。除上述错误,有些时候还会出现方法、属性绑定失败的错误,但这往往是由于拼写造成的(聪明的IDE以及各种工具会替我们快速的完成这一切)。
### 父组件传值
在父子组件在互相传值的过程中,父组件向子组件传入了字段不全或类型不对的数据。比如子组件有以下输入:
```typescript
@Input()
input(a: any) : void {
console.log(a.b.c.d);
}
```
此时如果父组件如果如下调用子组件:
```html
<app-子组件 [a]="{b: {}}"></app-子组件>
```
此时由于传入的`a`并在子组件中调用`a.b.c.d`时,会产生一个在非object上调用`d`的异常。
### 子组件弹值
子组件向上弹值产生的错误也大多发生在数据校验的层面上。比如子组件向父组件弹值为`{b: {}}`,但在父组件中却调用了`xx.b.c.d`,则仍然会发生一个在非object上调用`d`的异常。
## 子组件测试
子组件测试更准备的描述应该为:嵌套组件测试。Angular官方文档推荐使用组件提供桩(Stub)的方式来模拟到嵌套组件。但在实际生产过程中,我们发现这种提供桩的方法并不能够适应子组件的变更情况,这使单元测试失去了其“保障”的作用。在使用组件提供桩的测试方案中,当子组件有功能变更时,单元测试测试通过,却在集成测试或生产环境中发生了错误。
> [info] 尽信书不如无书,看教程也是一样,不要完全地相信我们。此处的思想与Angular给出的测试思想相冲突,希望日后我们能够找到更好的贴近于Angular的官方测试方案。在学习过程中:永远不要怀疑一个人,永远不要放弃怀疑一个人。
有的同学可能对使用单元测试来完成父子组件交互测试的方式有怀疑。他认为在开发过程中,已经手动的完成了父子组件的交互测试,且观察了交互的结果,所以这种使用代码的方式来进行父子组件嵌套测试实际上是冗余的。
其实不然,我们单元测试的目的在于保障在日后的迭代开发中,自己开发的功能不被其它的成员或是自己误杀掉。也是就是说:单元测试的目的并不在于保障目前组件的功能正常,而在于保障日后该组件的功能一直正常。在实际的开发中,每个组件必然不是独立的,一个项目开发完成后随即进行维护期,如果项目动作的好,还会进行功能的修正与更新。单元测试的作用正是:保障在日后对其它关联模块进行修正更新时,当前组件的功能保持正常。
既然错误往往是输入或输出引发的,那么我们在父子组件的嵌套测试中,重点也应该放在输入与输出上。同时,由于使用组件提供桩的方案不能够适应子组件的变化,所以在测试过程中不应该使用组件测试桩,而是应该使用真实的组件。
我们在测试文件中新建一个用于父子组件交互的方法:
```typescript
+++ b/first-app/src/app/student/student.component.spec.ts
@@ -44,4 +44,10 @@ describe('StudentComponent', () => {
// 在后台模拟数据返回以后,然后启动变更检测来更新V层,断言table列表中的`tr`大于一行。
expect(table.querySelectorAll('tr').length).toBeGreaterThan(1);
});
+
+ fit('与分页子组件交互测试', () => {
+ // 模拟后台立即返回数据,接着使用返回的数据重新渲染组件
+ getTestScheduler().flush();
+ fixture.detectChanges();
+ });
});
```
然后使用`ng t`来启动单元测试。
### 获取子组件
5.6小节我们已经掌握了在父组件中获取子组件的方法:使用测试夹具中`debugElement`对上的`query()`方法。
```typescript
+++ b/first-app/src/app/student/student.component.spec.ts
@@ -6,6 +6,7 @@ import {getTestScheduler} from 'jasmine-marbles';
import {MockApiTestingModule} from '../mock-api/mock-api-testing.module';
import {By} from '@angular/platform-browser';
import {PageModule} from '../clazz/page/page.module';
+import {PageComponent} from '../clazz/page/page.component';
describe('StudentComponent', () => {
let component: StudentComponent;
@@ -49,5 +50,9 @@ describe('StudentComponent', () => {
// 模拟后台立即返回数据,接着使用返回的数据重新渲染组件
getTestScheduler().flush();
fixture.detectChanges();
+
+ // 获取分页组件
+ const pageComponent = fixture.debugElement.query(By.directive(PageComponent)).componentInstance as PageComponent
+ expect(pageComponent).toBeTruthy();
});
});
```
单元测试通过,说明成功的获取到了子组件`pageComponent`。
![image-20210525085408730](https://img.kancloud.cn/35/a9/35a99431918bdc0cca59f642171d2e68_1034x194.png)
获取子组件的目的不仅仅在于支持后续子组件的输入、输出测试。子组件获取成功,同时也证明了子组件在初始化过程中没有发生异常。如果后续对`input()`的测试成功,则足以说明:父组件在初始化时绑定了子组件的输入`input()`,而且在调用的过程中没有发生异常。
接下来使用代码来保障当前组件与分页子组件间的交互是正常且符合预期的。
### Input()测试
当前组件在V层中如下调用了分页组件:
```html
<app-page [page]="pageData" (bePageChange)="onPage($event)"></app-page>
```
输入项为`page`并将其赋值为`pageData`,为了保障该功能日后不被误杀,我们需要确保当前组件的`pageData`成功的绑定到了子组件中的`page()`方法。在单元测试中,可以将被测试调用的方法`mock`掉,然后断言这个方法被调用:
```typescript
expect(pageComponent).toBeTruthy();
+
+ // input测试,先mock掉子组件被调用的方法
+ spyOn(pageComponent, 'page');
});
});
```
跟着上面的代码来做的话,IDE会报一个异常:在`pageComponent`上找不到`page`方法。这是由于`PageComponent`上的`page()`方法以`set`关键字来声明,该声明方式代表`page()`被看做一个字段来处理,当i使用`pageComponent.page = 1`时,则会自动调用`set page()`方法。
由于`set page()`并没有被看做一个方法来对象,所以`spyOn()`方式并不适用。在`Jasmine`中应该使用`spyOnProperty(object, propertyName, accessType)`在类似`set page()`的方法中安插间谍。
```typescript
// input测试,先mock掉子组件被调用的方法
- spyOn(pageComponent, 'page');
+ const spy = spyOnProperty(pageComponent, 'page', 'set');
});
});
```
最后继续添加注释:
```typescript
// input测试,先mock掉子组件被调用的方法
const spy = spyOnProperty(pageComponent, 'page', 'set');
// 然后重新为当前组件的pageData赋值
// 重新渲染子组件,触发set page()方法
// 断言子组件对应的方法被成功调用
});
```
完成功能:
```typescript
// input测试,先mock掉子组件被调用的方法
const spy = spyOnProperty(pageComponent, 'page', 'set');
// 然后重新为当前组件的pageData赋值
const pageData = {...{}, ...component.pageData}; ①
component.pageData = pageData;
// 重新渲染子组件,触发set page()方法
fixture.detectChanges();
// 断言子组件对应的方法被成功调用
expect(spy).toHaveBeenCalledWith(pageData); ②
});
```
- ① `{...{}, ...data}`可以快速完成`data`对象的`clone`从而得到一个与`data`一致的新对象。
- ② `spy`此时代表的便是分页组件上被安插了间谍的`set page()`方法。
需要注意的是,上述代码在初始化一个`pageData`时,采用的是对象`clone`的方法,这种方法是非常有必要的,它保障了数据的一致性。
### output()输出测试
与输入的测试大同小异,输出测试即子组件向父组件的弹值测试。与父组件在渲染过程中向子组件主动传值不同,子组件向父组件的传值一般是被动的。我们可以通过是否成功获取子组件来判断父组件向子组件传值时是否发生异常,但却不可以以此来判断子组件向父组件传值是否发生异常。
所以子组件输出测试,应该首先模拟一下子组件的输出,然后重新渲染组件,从而查看子组件的数据弹出是否会引发父组件异常。
```typescript
// 断言子组件对应的方法被成功调用
expect(spy).toHaveBeenCalledWith(pageData);
// 触发子组件弹出并重新进行渲染,未发生异常说明子组件弹值后父组件可以正确处理子组件的弹出数据
});
```
真实的数据被弹出父组件未发生异常后,便可以继续进行output方法的调用测试了:
```typescript
// 断言子组件对应的方法被成功调用
expect(spy).toHaveBeenCalledWith(pageData);
// 触发子组件弹出并重新进行渲染,未发生异常说明子组件弹值后父组件可以正确处理子组件的弹出数据
// output测试,先mock掉父组件的方法
// 调用子组件的弹射器,向父组件传值
// 断言父组件的方法被调用
});
```
思想有了,补充代码便是一件像聊天一样的事情:
一、触发子组件弹出
```typescript
// 触发子组件弹出并重新进行渲染,未发生异常说明子组件弹值后父组件可以正确处理子组件的弹出数据
pageComponent.bePageChange.emit(2);
fixture.detectChanges();
// output测试,先mock掉父组件的方法
// 调用子组件的弹射器,向父组件传值
// 断言父组件的方法被调用
```
为了防止子组件的数据弹出可能会触发mockApi的数据请求,我们还会习惯性的在组件渲染前加入立即返回模拟数据代码:
```typescript
// 触发子组件弹出并重新进行渲染,未发生异常说明子组件弹值后父组件可以正确处理子组件的弹出数据
pageComponent.bePageChange.emit(2);
getTestScheduler().flush();
fixture.detectChanges();
// output测试,先mock掉父组件的方法
// 调用子组件的弹射器,向父组件传值
// 断言父组件的方法被调用
```
二、测试弹出数据成功被组件接收
```typescript
// output测试,先mock掉父组件的方法
const onPageSpy = spyOn(component, 'onPage');
// 调用子组件的弹射器,向父组件传值
pageComponent.bePageChange.emit(1);
// 断言父组件的方法被调用
expect(onPageSpy).toHaveBeenCalledWith(1);
});
```
测试通过:
![image-20210525085408730](https://img.kancloud.cn/35/a9/35a99431918bdc0cca59f642171d2e68_1034x194.png)
## 总结
在进行嵌套组件测试时,主要测试以下几点:
1. 父组件向子组件传值时,子组件不发生异常。
2. 父组件成功地向子组件传了值。
3. 子组件成功地向父组件传了值。
4. 子组件向父组件传值时,父组件不发生异常。
上述几点我们在单元测试中分别使用以下方法来进行保障:
1. 模拟API返回数据,渲染组件,成功获取子组件说明子组件成功渲染,父组件向子组件传值未发生异常。
2. mock掉子组件的`@Input()`属性,变更父组件绑定到子组件的变量,重新渲染组件,断方间谍方法并调用。
3. 触发子组件的数据弹射,重新渲染组件,未发生异常说明父组件接收子组件弹射的数据后未发生异常。
4. mock掉父组件对应的方法,触发数据弹出,断言父组件对应方法被调用。
如此以来,当前组件与子分页组件的交互便开始有单元测试这个"护身符"来"保佑"了。日后一旦某些功能被误杀掉,单元测试便会及时跳出来告知开发者:当前代码已经将我的某些功能误杀了。从而避免了一些迭代开发过程中引发的关联性错误。
最后移除项目中的`fit`进行整体项目测试,未发生异常说明我们当前开发并未对历史上的其它组件功能产生影响。
| 链接 | 名称 |
| ------------------------------------------------------------ | -------------------- |
| [https://jasmine.github.io/tutorials/spying_on_properties](https://jasmine.github.io/tutorials/spying_on_properties) | Spying on properties |
| [https://github.com/mengyunzhi/angular11-guild/archive/step7.4.4.zip](https://github.com/mengyunzhi/angular11-guild/archive/step7.4.4.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 发布部署
- 第九章 总结