本节我们补充下单元测试的代码,来展示下当前组件的单元测试应该是什么样子的。 ## 列表初始化 在前面的单元测试中,实际上我们仍然采用了最**喜爱**的观察手法来进行开发。单元测试仅仅起到了脱离业务逻辑独立开发组件的作用。而我们讲单元测试的作用应该是保证代码正确执行,从而替代我们肉眼的观察。那么,用这种**保障**的思想来写单元测试会是个什么样子呢?又该怎么去**想**单元测试应该怎么写呢? 其实只要在单元测试中把我们希望用肉眼看到的结果写出来就好。如果我们愿意,我可以把一些组件中难以用肉眼观察到的中间态给写出来。以当前列表初始化为列,单元测试大概应该这么写: - 在后台模拟数据返回以前,断言table列表中的`tr`仅有标题一行。 - 在后台模拟数据返回以后,然后启动变更检测来更新V层,断言table列表中的`tr`大于一行。 上述两个断言不正是我们用肉眼观察后在心中判断组件是否执行的结果吗?带上这个思想,我们在单元测试中先补充一些注释: ```typescript +++ b/first-app/src/app/student/student.component.spec.ts @@ -31,7 +31,9 @@ describe('StudentComponent', () => { }); fit('onInit', () => { + // 在后台模拟数据返回以前,断言table列表中的`tr`仅有标题一行。 getTestScheduler().flush(); fixture.autoDetectChanges(); + // 在后台模拟数据返回以后,然后启动变更检测来更新V层,断言table列表中的`tr`大于一行。 }); }); ``` 在5.6节中我们使用了`fixture.debugElement.query(By.directive(NavComponent));`来获取导航(菜单)组件;在6.2.3小节中我们使用了`fixture.debugElement.query(By.css('select'))`来获取过select元素;在6.6.4小节中我们使用了`fixture.debugElement.query(By.css('nav'))`来获取导航元素。 > `@angular/platform-browser`中的`By`除了支持`directive()`、`css()`选择器以外,还支持:`all()`方法。 此时我们同样可以使用`By.css()`来获取到`table`元素,然后对`table`中的`tr`数量进行断言,当然这需要一些`html DOM`和`css选择器`知识。 ```typescript fit('onInit', () => { // 在后台模拟数据返回以前,断言table列表中的`tr`仅有标题一行。 + const table = fixture.debugElement.query(By.css('table')).nativeElement as HTMLTableElement; + console.log(table); getTestScheduler().flush(); fixture.autoDetectChanges(); // 在后台模拟数据返回以后,然后启动变更检测来更新V层,断言table列表中的`tr`大于一行。 ``` 在教程中我们大量的使用在`console.log()`来打印数据, 这在开发的初期是非常有必要的。否则很难做到对每行代码的作用、数据的类型了然于胸。此时单元测试将在控制台打印获取到`table`元素。 ![image-20210419083559114](https://img.kancloud.cn/74/66/746680f6bdf79962f17a140a3fda33b9_1222x80.png) 此时我们当击该信息最左侧的三角符号能够查看此元素的具体属性及方法,点最右侧那个类似于方框的符号将自动定位到对应的`html`元素。 需要注意的是,我们在控制台中查到的**对象**值是该对象在我们**查看**时最终值,而非我们在打印时的临时值。我们以输出该元素的高度为例: ```typescript fit('onInit', () => { // 在后台模拟数据返回以前,断言table列表中的`tr`仅有标题一行。 const table = fixture.debugElement.query(By.css('table')).nativeElement as HTMLTableElement; console.log('打印的非对象类型,在控制台查看到的是执行代码时的即时值。当前table的高度为:', table.clientHeight); console.log('打印对象类型,在控制台查看到的是该对象的最终值。', table); getTestScheduler().flush(); fixture.autoDetectChanges(); // 在后台模拟数据返回以后,然后启动变更检测来更新V层,断言table列表中的`tr`大于一行。 }); ``` 在控制台中直接打印`number`类型的数据,打印的为执行`console.log()`时的即时值,此时`table`中由于仅仅有一行标题,所以高度为51。 ![image-20210419084316667](https://img.kancloud.cn/2d/16/2d16037c07de2ada27636c468f67f16a_1264x100.png) 在控制台中打印类型为`HTMLTableElement`的对象,则在控制台中查看到的是该对象的最终值。在最终状态`table`已经填充了模拟后台返回数据,此时不但有一个标题,还有20行数据,所以高度为1170。 ![image-20210419084416400](https://img.kancloud.cn/df/57/df570cb3ca22ae6e144368d4ddb688a9_1086x104.png) 这是由于`console.log()`接收到对象以后,实际上是记录了该对象在**引用**值,在C语言中把这个**引用**称为指针。 ## 指针 基本上所有的语言都使用了C语言中**指针**的思想,如果它们不这么做,那么在进行函数的调用时则需要占用不可控的内存或是面临如何处理对象间循环套用的问题。比如JAVA中的堆和栈,再比如javascript的引用传值,或是PHP的对象传递等实际上都是进行指针传递。 `console.log()`方法同样也是如此,由于接收的对象的复杂性未知,当接着的参数类型为对象时,无论是站在时间的角度上,还是空间的角度上,它都很难存储这个对象的快照。而存储该对象的引用则是最好的方法。在用户在控制中查看数据时,再去内存中获取相应的值进而显示在控制台中。 这就是为什么直接打印的talbe高度会在控制台中显示即时值,而打印table则会显示最终值的原因。 既然`console.log()`在处理对象时查看是最终值,那么我们在开发过程中,又如何在控制台中查看某个对象的即时值呢? ## debug 要想查看对象的即时值,则需要借助浏览器的debug功能。下面,我们分别就`firefox`及`chrome`浏览器做debug展示。 ### firefox 在前面我章节中,我们学习使用了控制台中的Inspetor、Console、Network以及Applicaton。查看在执行过程中对象的瞬时值,则需要使用Debugger: ![image-20210419091824063](https://img.kancloud.cn/58/5b/585b89005cc33f6d1ed1c56a7470b6c2_2028x424.png) 点击Debuger后,点击 `Go to file 打开文件`,在弹出的对画框中输入我们当前的测试文件: ![image-20210419091926634](https://img.kancloud.cn/08/9f/089ff409b42b20117017824c2e33e9f2_1952x274.png) 边输入`firefox`会边把符合要求的文件过滤出来,此时我们选择正在测试的文件后将在Debugger中查看到该文件,然后找到变量table的位置,并在该变量所在行的行号上点击一下: ![image-20210419092108565](https://img.kancloud.cn/5d/c7/5dc75d6d2a2ea3e8c11bead5bba52b9d_1720x172.png) 点击后该行将被点亮,此时刷新浏览器,单元测试执行到该行时将被暂时中断,此时将鼠标移动到`table`变量上,则可以查看该变量的即时值: ![image-20210419092323519](https://img.kancloud.cn/f7/88/f7889e893be7466bb2bd9a49d6c2467b_1894x370.png) 如果我们想在控制中查看这个即时值,则可以点击步进小图标,使用代码由38行执行到39行: ![image-20210419092420760](https://img.kancloud.cn/9e/ac/9eac227ce01923d4400dd3146790da34_3060x682.png) 此时39行代码点亮,表示程序即将执行此行代码,也意味着38行代码已成功执行: ![image-20210419092501479](https://img.kancloud.cn/b2/07/b20776ca1c050714e6f33f9b67d9d8a9_1468x146.png) 然后我们来到控制台,此时查看到的对象即为当前状态下的即时值。 ![image-20210419092550327](https://img.kancloud.cn/a9/af/a9af6eb747ded8c68be16b9db5a86764_1272x204.png) 其实在debugger模式下`console.log()`并未做任何的改变,它依然是显示了此时对象的最终值。只不过由于中断的作用,当前对象的最终值即为当前状态的即时值罢了。 查看完即时值后,再次点击38行的行号,点亮效果消失,重新刷新浏览器恢复为正常执行。 ![image-20210419093229938](https://img.kancloud.cn/16/5d/165d56248285ecec745253ac14a90851_1452x120.png) ### chrome Chrome浏览器debug的方法大同小异,打开控制台并打开Sources选项卡。此处将提示的打开特定文件所需要的快捷键。 ![image-20210419093345077](https://img.kancloud.cn/d1/09/d109997c158db25baf43349782a89bfe_2298x518.png) 比如当前为macos系统,按`command + p`后打开对话框,然后输入预查看变量所在的文件: ![image-20210419093728963](https://img.kancloud.cn/f7/62/f762c2a82b748e669b31e1453ca74218_1560x340.png) 该对话框同样支持过滤功能,只需要输入特定的关键字即可快速的定位到相关的文件,打开变量的所在行并点击行号,则会设置一个断点,刷新浏览器程序执行到此将被中断。 ![image-20210419093841717](https://img.kancloud.cn/78/b5/78b551249c46d47de9aee2f206029f59_2222x188.png) 此后的操作与firefox基本相同。把鼠标移到变量上来查看变量的即时值: ![image-20210419093933137](https://img.kancloud.cn/77/d1/77d1df7c4d6aed7c5d2ef2338700e6f0_2222x322.png) 点击步进时向下执行一行: ![image-20210419093953375](https://img.kancloud.cn/57/48/57481edb09865f210fcae617969d7fdb_3410x374.png) 在控制台中查看对象的即时值: ![image-20210419094015506](https://img.kancloud.cn/88/4e/884ed3294b0cc1a6e8c1964cc1f9edfa_2128x306.png) ### 区别 `firefox`与`chrome`在对`console.log()`的处理上大同小异,但在处理细节上仍有不同。比如在打印`html DOM`时,firefox打印的是对象的属性及方法,而chrome则打印是该对象对应的html元素代码。至于哪个更好,则完全由你来判断,你喜欢哪个,哪个就是最好的。 ## HtmlTableElement 刚刚在调用`query(By.css('table')).nativeElement`时,将返回值看做了`HTMLTableElement`,这是由于我们确信查询到的`table`元素的对象类型就是这个`HTMLTableElement`。当对象指定为特定的类型有个最大的好处就是可以在后续的代码中在编辑器的帮助下快速的获取到将对象上的属性,或是调用该对象上的方法;最大的坏处是如果我们不小心把返回值类型`as`错了,则可以在后续的代码发生一系列BUG。尽管有指定错误的风险,在开发中我们仍然愿意使用`as`关键字来指定一个特定的类型。 比如我们把`table`元素准确的指定为了``HTMLTableElement`,则可以查阅`HTMLTableElement`的官方文档,快速的获取到该元素上的属性、方法。 所有的`html DOM`都可以在[mozilla的官方站点](https://developer.mozilla.org/en-US/docs/Web/API)上找到,如果你还没有完全地切换到看英文资料的习惯上,还可以查问对应的[中文官方站点](https://developer.mozilla.org/zh-CN/docs/Web/API)。我们可以在其主页上找到`HtmlTableElement`的身影: ![image-20210419095217766](https://img.kancloud.cn/35/d9/35d91923c991ba728ad14a1fd291bbf5_2164x322.png) 或是通过首页上方的查询框来查询来相应的元素: ![image-20210419095604278](https://img.kancloud.cn/9c/83/9c83c26e51be94bf690ae22c0955dce5_2170x330.png) 点击后对应的链接后将来到 `HtmlTableElement`的首页,首页最上方法展示了该接口(在mozilla上统一把它们称为接口,这是由于它把不同的浏览器看到了接口的具体实体)的关系图: ![image-20210419095750407](https://img.kancloud.cn/2b/7f/2b7f668abbaeb1cb807f8c8600149930_1886x394.png) 上图展示了`HtmlTableElement`接口的继承关系,所以如果有些属性和方法并不是`Table`元素特有的话,则可以在其父接口、父父接口中去查找。最终我们由[ParentNode](https://developer.mozilla.org/zh-CN/docs/Web/API/ParentNode)中找到的[querySelectorAll()](https://developer.mozilla.org/zh-CN/docs/Web/API/ParentNode/querySelectorAll)方法用于查询`table`元素中的子`tr`元素: ```typescript fit('onInit', () => { // 在后台模拟数据返回以前,断言table列表中的`tr`仅有标题一行。 const table = fixture.debugElement.query(By.css('table')).nativeElement as HTMLTableElement; console.log('打印的非对象类型,在控制台查看到的是执行代码时的即时值。当前table的高度为:', table.clientHeight); console.log('打印对象类型,在控制台查看到的是该对象的最终值。', table); + expect(table.querySelectorAll('tr').length).toBe(1); getTestScheduler().flush(); fixture.autoDetectChanges(); // 在后台模拟数据返回以后,然后启动变更检测来更新V层,断言table列表中的`tr`大于一行。 }); ``` 在断言相等时,我们有`toBe()`及`toEqual()`可用。两者在多数情况下通用,但`toBe()`校验的更为严格,而`toEqual()`则相对不太严格。 最后加入数据返回后断言的代码: ```typescript fit('onInit', () => { // 在后台模拟数据返回以前,断言table列表中的`tr`仅有标题一行。 const table = fixture.debugElement.query(By.css('table')).nativeElement as HTMLTableElement; console.log('打印的非对象类型,在控制台查看到的是执行代码时的即时值。当前table的高度为:', table.clientHeight); console.log('打印对象类型,在控制台查看到的是该对象的最终值。', table); expect(table.querySelectorAll('tr').length).toEqual(1); getTestScheduler().flush(); fixture.autoDetectChanges(); // 在后台模拟数据返回以后,然后启动变更检测来更新V层,断言table列表中的`tr`大于一行。 expect(table.querySelectorAll('tr').length).toBeGreaterThan(1); }); ``` 此时,一个替待了肉眼观察的单元测试代码便真正完成了。 ## 度 古语说过犹不及,水满则溢,月满则亏。都是对**度**的一种描述。在单元测试中,很难把握一个**度**字。以当前测试为例,在开发过程中我们肉眼判断的除了在请求数据返回后行数增加了以外,其实还对表格样式,数据表中的每个单元格的填充文字等。如果把这些判断都写到单元测试中无疑可以提升系统的健壮性,但其实这样做往往得不偿失。 在实际的项目中又该如何把握这个度呢,个人认为适用就好,在适用的前提下尽量地提升单元测试代码的测试覆盖率。如果某段测试代码在项目期间都没有起过**保障**的作用,那么这些测试代码便可以认为是无效的,在后续的开发中再遇到类似情景时则可以考虑省略掉;如果在使用过程中,发现有很多BUG点,则需要考虑应该如何增加单元测试的代码来规避这些BUG,使用单元测试来保证此类BUG不再发生。当有一天我们使用最少的单元测试代码,将整个项目的BUG发生率控制在一个有效地比较小的范围内时,便找到了这个适用的点。 有些时候我们还必须考虑当前技术服务的对象,同样的项目有1万的资金支持与有10万的资金支持,对单元测试的度的把控是不同的;同样的项目有1个月的工期限制还是有3个月的工期限制,对度的把控也不应该相同。技术是为业务服务的,不存在没有业务的技术。无论自己身处什么位置,都应该谨记:不能为了技术而技术! | 名称 | 链接 | | -------- | ------------------------------------------------------------ | | 本节源码 | [https://github.com/mengyunzhi/angular11-guild/archive/step7.4.2.zip](https://github.com/mengyunzhi/angular11-guild/archive/step7.4.2.zip) |