最近和测试杠上了,写了的文章都和测试相关。当然,这里的「测试」并不是具体的某个角色,而是验证程序正确性的工作。曾经,前端如何 TDD 困扰了我很久,随着时间的推移,前端框架开始成熟,我对前端测试有了更深刻的理解,把我做前端 TDD 的方法分享给大家。
理论篇
测试驱动开发,英文全称 Test-Driven Development(简称 TDD),是由Kent Beck 先生在极限编程(XP)中倡导的开发方法。以其倡导先写测试程序,然后编码实现其功能得名。
TDD 能从技术的角度帮我们提高代码质量,使代码执行结果正确,容易理解。由于有测试的保障,开发者可以更放心的重构自己的代码。
当有变更时,测试同学最关心变更的影响范围,然而开发者也很难精准确定变更所带来的影响。虽然我们不追求测试覆盖率,但足够的测试覆盖总是能够给我们更多的信心,TDD 则是增加测试覆盖的唯一途径。
在面对一个完全没有思路的算法的时候,TDD 则变成了测试驱动设计(Test-Driven Design)。选一个最简单的用例,用最简单的代码通过测试。逐渐增加测试 Case、通过测试 、重构来驱动出设计。
TDD 的步骤
- 写一个失败的测试
- 写一个刚好让测试通过的代码
- 重构上面的代码
每一个步骤都是带上一个角色的帽子 ,让你更专注当下这个角色应该做的事。
TDD 的三原则
- 没有测试之前不要写任何功能代码
- 一次只写一个刚好失败的测试,作为新加功能的描述
- 不写任何多余的产品代码,除⾮它刚好能让失败的测试通过
TDD 写出的代码的验证逻辑针对的是独立的代码块,可能不是系统中的业务完整功能。用测试先行的方法写出的漂亮的代码也可能做出的功能不是客户想要的(因为需求理解的错误所导致)。因此,使用 「验收驱动测试开发(ATDD)」是很有必要。
验收驱动测试开发——ATDD(Acceptance Test Driven Development)
ATDD 通过名字就可以看出和 TDD 有着某种神秘的联系, ATDD 是 TDD 的延伸。
在传统做法中,要给系统添加新的特性,开发人员会按照文档开发,测试,最后交给客户验收。ATDD 则有些不同:在编码前先明确新特性的验收标准,将验收标准转换成测试用例(代码),再编写代码让测试通过,当所有的验收条件被满足,也就意味着这个功能完整的实现。
2003 年左右的时候 Kent Beck 曾对 ATDD 提出质疑,时间太早不好查证,我个人猜测原因是 验收条件做为一个 测试 Case 在某些时候会比较大。
举个例子:
> 如果用户购物车里勾选了可以购买的商品,当用户点击下单,则系统为其创建了一个包含勾选商品的一个订单。
可以看出这个验收条件可能要写上一大堆的功能点(如:锁定库存、创建订单等等)才能满足,开发人员必须将 Case 再进行拆分。
创建订单的伪代码:
public Order create(List products) { // 锁定库存 // 创建订单 // 创建定时任务(以便超时支付,而取消订单、释放库存) // ... return null;}
将注释的地方写出相应的函数,如:lockStock()、createOrder()、createOrderTimeoutTask() ...并为这些函数编写单元测试再去实现。当然,你并不需要一次全部完成,而是一次只实现一个任务。
尽管 Kent Beck 曾对 ATDD 提出过质疑,但却为 2012 年出版的《ATDD by Example》 写了推荐序,ATDD 也早成为公认的做法。相比后端,前端更适合使用 ATDD。
测试条件格式
测试条件通常遵循以下形式:
>Given (如果) > 指定的状态,通常是给出的条件; >When (当) > 触发一个动作或者事件; >Then (则) > 对期望结果的验证;
写 JavaScript 同学要比 Java 同学幸福很多,不需要把函数名写的老长:
it('Given a = 1 And b = 2,When execute add(),Then result is 3', () => { // ...});// 如果团队更习惯用中文,可以这样:it('如果: a = 1 并且 b = 2,当:执行 add(),则:结果是 3', () => { // ...});
实践篇
以往的示例都是拿算法来实践,搞的同学们以为 TDD 只适合做一些算法题,所以这次我们不拿算法来做实例,使用一个相对真实且简单的需求——登录。
安装环境
vue create vue-tdd-demo
勾选 Unit Testing (单元测试),后面按照自己喜好来选择。
这里选择 Jest 作为测试框架。
验证环境
安装好之后运行 npm run test:unit ,刚安装的项目就会报错,真让人惆怅!看看如何解决:
jest.config.js 或者 package.json 中找到 transformIgnorePatterns 这个配置
// ...transformIgnorePatterns: ['/node_modules/','/node_modules/(?!vue-awesome)', // 添加此行],
再运行 npm run test:unit
演练实例
需求
> 页面包含用户名、密码输入框和提交按钮,提交之后成功服务端返回状态为 200 然后跳转到 Home 首页,失败则 alert() 文字提示。 > > 在用户名密码为空时不能提交。
通常前端功能可分为两部分,一部分是接口、另一部分是页面。
我们先来实现接口部分:
接口 Service 测试
请求模块使用 axios ,axios-mock-adapter 是一个辅助模块,帮助我们验证接口调用是否正确。
任务拆分
> 登录 Service.login 方法,接受 user 对象(username、password) ,使用 axios 发送请求,URL 为 /users/tokens,使用 POST 方式,返回一个Response 的 Promise 对象; > > 当输入调用 Service.login({username: '谢小呆', password: '123'}); ,则 axios post 的数据则与参数相同;
安装依赖
npm install axiosnpm install axios-mock-adapter --save-dev# oryarn add axiosyarn add axios-mock-adapter -D
编写测试
import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import Service from '@/login/service'; describe('Login service', () => { it('Given 登录信息为 谢小呆,123, When 执行 Service.login() 时,Then 请求参数为 谢小呆,123 的用户', async () => { const mock = new MockAdapter(axios); const expectedResult = { username: '谢小呆', password: '123' }; mock.onPost('/users/token').reply(200); await Service.login(expectedResult); expect(mock.history.post.length).toBe(1); expect(mock.history.post[0].data).toBe(JSON.stringify(expectedResult)); }); }); 这里验证了传入的「参数」与 POST 的数据是否一致,我们并没有真正去发网络请求,也没有必要。毕竟我们并不关心接口「此时」是否能通,只要后端按照我们的接口约定给出特定的返回即可。对单元测试不了解的同学可以参考这里。
此时运行 yarn run test:unit 缺少 service.js 文件。
创建 src/login/service.js 文件
import axios from 'axios';const login = user => axios.post('/users/token', user);export default { login,};
再次运行 yarn run test:unit ,测试通过!
页面、组件测试
对于 Vue 来说页面和组件是同一个东西,Vue 提供了一个很方便的单元测试工具 vue-test-units ,这里就不过多赘述其用法,参考官方文档即可。
任务:当用户访问页面时可以看到用户名、密码输入框和提交按钮,所以页面中只要包含这 3 个元素即可。
import { mount } from '@vue/test-utils'; import Login from '@/login/index.vue'; describe('Login Page', () => { it('When 用户访问登录页面,Then 看到用户名、密码输入框和提交按钮', () => { const wrapper = mount(Login); expect(wrapper.find('input.username').exists()).toBeTruthy(); expect(wrapper.find('input.password').exists()).toBeTruthy(); expect(wrapper.find('button.submit').exists()).toBeTruthy(); }); }); 运行测试报错,缺少 @/login/index.vue 文件
- 用户名:
- 密码:
- 提交
任务:实现双向绑定,在input 中输入用户名为 谢小呆,密码为 123,vue 的 vm.user 为 {username: '谢小呆', password: '123'};
import { mount } from '@vue/test-utils';import Login from '@/login/index.vue';describe('Login Page', () => { it('When 用户访问登录页面,Then 看到用户名、密码输入框和提交按钮', () => { const wrapper = mount(Login); expect(wrapper.find('input.username').exists()).toBeTruthy(); expect(wrapper.find('input.password').exists()).toBeTruthy(); expect(wrapper.find('button.submit').exists()).toBeTruthy(); }); it('Given 用户访问登录页面,When 用户输入用户名为 谢小呆, 密码为 123,Then 页面中的 user 为 {username: "谢小呆