【第2732期】JavaScript & Node.js 的测试最佳实践 - 第一章: 测试剖析
前言
你的工作中有写测试么?今日前端早读课文章由 @goldbergyoni 分享。
正文从这开始~~
第 0 章:黄金法则
⚪️ 0 黄金法则:设计瘦测试
✅ 建议:测试代码与生产代码不同,要使它变得极其简单、短小、没有抽象、扁平化、使人愉悦、瘦。一段测试代码需要做到让人一眼就能看出其目的。
我们的思维空间被主体生产代码充满,因此无法腾出额外的 “大脑空间” 存放复杂的东西。如果向可怜的大脑中塞进其他复杂代码,将会使得整个部分变慢,而这个部分正是用来解决我们需要测试的问题的。这也是大部分团队放弃测试的原因。
另一方面,测试是一个友好的助手,一个你乐于与之合作、投资小回汇报大的助手。科学证明我们有两套大脑系统:系统 1 用于无需努力的活动如在一个空旷的路上开车;系统 2 用于复杂和繁琐的工作如算一道数学表达式。将你的测试为系统 1 设计,当你看一段测试代码时,需要像改 HTML 文档一样简单而不是像计算 2 × (17 × 24)。
为了达到这个目的,我们可以通过选择性价比高、投入产出比(ROI)高的技术、工具以及测试对象。仅测试需要的内容,努力保持其灵活性,某些时候甚至值得去舍弃一些测试来换取灵活性和简洁性。
下面的大部分建议衍生自这一原则。
准备好开始了吗?
第一章:测试剖析
⚪ 1.1 每个测试用例的名称必须包含三个部分
✅ 建议:一个测试报告需要让不熟悉代码的人(测试、运维)明确知道新的变更是符合需求。因此测试名称需要从需求层面描述,并且包含三个部分:
被测的是什么?(比如 ProductsService.addNewProduct 方法)
在什么条件和场景下?(比如没有 向该方法传入 price 参数)
期望的结果是什么?(比如不允许添加该产品)
❌ 否则:当一个名为 “新增产品” 的测试用例挂掉之后,你如何准确找到是哪里出问题了?
👇 Note: 每一条后面会有一个 代码示例,有时候还会放一张图片说明。
👏 正例:一个包含三部分的用例名
//1. unit under test
describe('Products Service', function() {
describe('Add new product', function() {
//2. scenario and 3. expectation
it('When no price is specified, then the product status is pending approval', ()=> {
const newProduct = new ProductService().add(...);
expect(newProduct.status).to.equal('pendingApproval');
});
});
});
👏 正例:一个包含三部分的用例名
⚪ 1.2 使用 AAA 模式构造测试内容
✅ 建议:将你的测试内容划分为三个部分:布置,执行,断言 —— Arrange, Act & Assert (AAA)。这样读者就无需动用脑细胞理解你的测试内容了:
1st A - 准备(Arrange):一些用于提供上下文的代码。可能包含:构造数据、添加 DB 记录、mocking/stubbing 对象,以及其他的准备代码;
2nd A - 执行(Act):执行测试单元。通常一行代码。
3rd A - 断言(Assert):保证得到的值符合预期。通常一行代码。
❌ 否则:你不仅要花大量时间理解这段代码,而且本该是最简单的部分却耗费了你的大量脑细胞。
👏 正例:一个使用 AAA 模式构造的测试用例
describe('Customer classifier', () => {
test('When customer spent more than 500$, should be classified as premium', () => {
//Arrange
const customerToClassify = {spent:505, joined: new Date(), id:1}
const DBStub = sinon.stub(dataAccess, "getCustomer")
.reply({id:1, classification: 'regular'});
//Act
const receivedClassification = customerClassifier.classifyCustomer(customerToClassify);
//Assert
expect(receivedClassification).toMatch('premium');
});
});
👎 反例:没有分隔、一大坨、难以解释
test('Should be classified as premium', () => {
const customerToClassify = {spent:505, joined: new Date(), id:1}
const DBStub = sinon.stub(dataAccess, "getCustomer")
.reply({id:1, classification: 'regular'});
const receivedClassification = customerClassifier.classifyCustomer(customerToClassify);
expect(receivedClassification).toMatch('premium');
});
⚪ 1.3 用产品语言描述期望:使用 BDD 形式的断言
✅ 建议:使用声明的方式写代码,可以使读者无脑 get 到重点。而如果你的代码使用各种条件逻辑包裹起来,则会增加读者的理解难度。因此,我们应尽量使用类似人类语言的形式描述如 expect 或 should 而不是自己写代码。如果 Chai 和 Jest 不包含你想要的断言,而且这种断言可被高度复用时,你可以考虑 扩展 Jest 匹配器 (Jest) 或者写一个 自定义 Chai 插件
❌ 否则:团队的测试代码会越写越少,而且会用 .skip () 把一些讨厌的测试用例注释掉。
👎 反例:为了了解该用例的目的,读者必须快速浏览冗长复杂的代码
test("When asking for an admin, ensure only ordered admins in results" , () => {
//assuming we've added here two admins "admin1", "admin2" and "user1"
const allAdmins = getUsers({adminOnly:true});
const admin1Found, adming2Found = false;
allAdmins.forEach(aSingleUser => {
if(aSingleUser === "user1"){
assert.notEqual(aSingleUser, "user1", "A user was found and not admin");
}
if(aSingleUser==="admin1"){
admin1Found = true;
}
if(aSingleUser==="admin2"){
admin2Found = true;
}
});
if(!admin1Found || !admin2Found ){
throw new Error("Not all admins were returned");
}
});
👏 正例:快速浏览下面的声明式用例很轻松
it("When asking for an admin, ensure only ordered admins in results" , () => {
//assuming we've added here two admins
const allAdmins = getUsers({adminOnly:true});
expect(allAdmins).to.include.ordered.members(["admin1" , "admin2"])
.but.not.include.ordered.members(["user1"]);
});
⚪ 1.4 坚持黑盒测试:只测 public 方法
✅ 建议:测试内部逻辑是无意义且浪费时间的。如果你的 代码 / API 返回了正确的结果,你真的需要花三个小时时间去测试它内部究竟如何实现的,并且在之后维护这一堆脆弱的测试吗?每当测试一个公共方法时,其私有实现也会被隐式地测试,只有当存在某个问题 (例如错误的输出) 时测试才会中断。这种方法也称为行为测试。另一方面,如果你测试内部方法 (白盒方法)— 你的关注点将从组件的输出结果转移到具体的细节上,如果某天内部逻辑改变了,即使结果依然正确,你也要花精力去维护之前的测试逻辑,这无形中增加了维护成本。
❌ 否则:你的代码将会像狼来了一样:总是叫唤着 “出问题啦”(比如一个因私有变量名改变导致的用例失败)。则人们必然会开始忽略 CI 的通知,直到某天真正的 bug 被忽略……
👎 反例:一个无脑测试内部方法的测试用例
class ProductService{
//this method is only used internally
//Change this name will make the tests fail
calculateVAT(priceWithoutVAT){
return {finalPrice: priceWithoutVAT * 1.2};
//Change the result format or key name above will make the tests fail
}
//public method
getPrice(productId){
const desiredProduct= DB.getProduct(productId);
finalPrice = this.calculateVATAdd(desiredProduct.price).finalPrice;
}
}
it("White-box test: When the internal methods get 0 vat, it return 0 response", async () => {
//There's no requirement to allow users to calculate the VAT, only show the final price. Nevertheless we falsely insist here to test the class internals
expect(new ProductService().calculateVATAdd(0).finalPrice).to.equal(0);
});
⚪ 1.5 使用正确的测试替身(Test Double):避免总用 stub 和 spy
✅ 建议:测试替身是把双刃剑,他们在提供巨大价值的同时,耦合了应用的内部逻辑 (这里有一篇关于测试替身的文章: mocks vs stubs vs spies).
在使用测试替身前,问自己一个很简单的问题:我是用它来测试需求文档中定义的可见的功能或者可能可见的功能吗?如果不是,那就可能是白盒测试了。
举例来说,如果你想测试你的应用程序在支付服务宕机时的合理表现,你可以 stub 支付服务并触发一些 “无响应” 返回,以确保被测试的单元返回正确的值。这可以测试特定场景下的应用程序的行为、响应、输出结果。你也可以使用一个 spy 来断言当服务宕机时发送了一封电子邮件 —— 这又是一个针对可能出现在需求文档中的行为的检查 (“如果无法保存付款,请发送电子邮件”)。反过来,如果你 mock 的支付服务,并确保它被正确调用并传入正确的 JavaScript 类型,那么你的测试重点是内部的逻辑,它与应用的功能关系不大,而且可能会经常变化。
❌ 否则:任何代码重构都要求搜索代码中的所有 mock 并相应地进行更新。测试变成了一种负担,而不是一个帮手。
👎 反例:关注内部实现的 mock
it("When a valid product is about to be deleted, ensure data access DAL was called once, with the right product and right config", async () => {
//Assume we already added a product
const dataAccessMock = sinon.mock(DAL);
//hmmm BAD: testing the internals is actually our main goal here, not just a side-effect
dataAccessMock.expects("deleteProduct").once().withArgs(DBConfig, theProductWeJustAdded, true, false);
new ProductService().deletePrice(theProductWeJustAdded);
dataAccessMock.verify();
});
👏正例:使用 spy 关注于测试需求本身,而作为副作用不得不接触内部
it("When a valid product is about to be deleted, ensure an email is sent", async () => {
//Assume we already added here a product
const spy = sinon.spy(Emailer.prototype, "sendEmail");
new ProductService().deletePrice(theProductWeJustAdded);
//hmmm OK: we deal with internals? Yes, but as a side effect of testing the requirements (sending an email)
});
⚪ 1.6 不要 “foo”,使用真实数据
✅ 建议:生产环境中的 bug 通常是在一些特殊或者意外的输入下出现的 —— 所以测试的输入数据越真实,越容易在早期抓住问题。使用现有的一些库(比如 Faker)去造 “假” 真数据来模拟生产环境数据的多样性和形式。比如,这些库可以生成真实的电话号码、用户名、信用卡、公司名等等。你还可以创建一些测试 (在单元测试之上,而不是替代) 生产随机 fakers 数据来扩展你的测试单元,甚至从生产环境中导入真实的数据。想要进阶的话,请看下一条:基于属性的测试。
❌ 否则:你所有的用例都在 “foo” 之类的输入值下表现正确,结果上线后收到诸如 “@3e2ddsf . ##’1 fdsfds . fds432 AAAA”
之类的输入后挂掉了。
👎 反例:一个用例因使用非真实数据而通过
const addProduct = (name, price) =>{
const productNameRegexNoSpace = /^\S*$/;//no white-space allowd
if(!productNameRegexNoSpace.test(name))
return false;//this path never reached due to dull input
//some logic here
return true;
};
test("Wrong: When adding new product with valid properties, get successful confirmation", async () => {
//The string "Foo" which is used in all tests never triggers a false result
const addProductResult = addProduct("Foo", 5);
expect(addProductResult).toBe(true);
//Positive-false: the operation succeeded because we never tried with long
//product name including spaces
});
👏正例:随机生成真实的输入数据
it("Better: When adding new valid product, get successful confirmation", async () => {
const addProductResult = addProduct(faker.commerce.productName(), faker.random.number());
//Generated random input: {'Sleek Cotton Computer', 85481}
expect(addProductResult).to.be.true;
//Test failed, the random input triggered some path we never planned for.
//We discovered a bug early!
});
⚪ 1.7 基于属性的测试:测试输入的多种组合
✅ 建议:通常我们只会选择部分的数据样例去测试,即使是使用了上一节讲到的工具去模拟真实数据,我们也只覆盖到了一部分输入的组合 (method(‘’, true, 1), method(“string” , false” , 0))
。然而在生产环境中,一个拥有 5 个参数的 API,可能会遇到上千种排列组合,而其中的某一种可能会把你的进程搞挂(见 Fuzz Testing)。如何自动生成这上千种组合并在它们出问题后 catch 到?基于属性的测试适用于这种需求:向你的测试单元传入所有可能的输入组合,以增加发现 bug 的可能。例如,给定一个方法 —— addNewProduct(id, name, isDiscount)
,支持属性测试的库将使用一批 (number, string, boolean)
组合调用此方法,比如 (1,“iPhone”,false)
,(2,“Galaxy”,true)
。您可以使用您最喜欢的测试运行器 (Mocha、Jest 等),
通常,我们为每个测试选择一些输入样本。即使输入格式类似于现实世界的数据 (见子弹 “别 foo”), 我们只涉及几个输入组合 (方法 (“, 真的,1), 方法 (“字符串”, 假”,0)), 然而,在生产中,一个 API 调用与成千上万的 5 个参数可以调用不同的排列,其中一个可能使我们的流程 (见模糊测试)。如果您可以编写一个测试,自动发送 1000 个不同输入的排列组合,并捕获我们的代码未能返回正确响应的输入,那该怎么办?基于属性的测试就是这样一种技术:通过发送所有可能的输入组合到你的测试单元中,它增加了发现 bug 的偶然性。例如,给定一个方法 —addNewProduct(id, name, isDiscount)
— 支持库将使用许多 (number, string, boolean)
组合调用此方法,比如 (1,“iPhone”,false)
,(2,“Galaxy”,true)
。您可以使用您最喜欢的测试运行器 (Mocha、Jest 等):比如 js-verify 或者 testcheck (文档比较好)。 更新: Nicolas Dubien 在下面的回复中建议 了解下 fast-check 它提供了更多的能力,似乎更易维护。
❌ 否则:你无意中选择的输入数据只覆盖了没问题的代码路径。不幸的是,它没有真正发现了 bug。
👏 正例:使用 “fast-check” 测试输入的组合
import fc from "fast-check";
describe("Product service", () => {
describe("Adding new", () => {
//this will run 100 times with different random properties
it("Add new product with random yet valid properties, always successful", () =>
fc.assert(
fc.property(fc.integer(), fc.string(), (id, name) => {
expect(addNewProduct(id, name).status).toEqual("approved");
})
));
});
});
⚪ 1.8 snapshot:如果需要,仅使用短的行内快照
✅ 建议:如果你需要 快照测试,仅使用端快照(比如 3-7 行),并且把它们作为测试的一部分(内联快照)而不是存放到外部文件中。遵循这条指导原则将确保您的测试保持自解释并且不那么脆弱。
另一方面,“经典” 快照教程和工具鼓励我们在一些外部介质上存储大文件(如组件的渲染结果,API 的 JSON 结果),并确保每次运行测试时将新结果与保存的版本进行比较。打个比方,这么做有可能隐式地将我们的测试与包含 3000 个数据值的 1000 行内容关联起来,而这些数据值是测试编写者从来没有读过和考虑过的。这么做将会使得你的用例有 1000 个失败的理由 —— 常常改一行代码就会导致快照失效。这个频率有多高?对于每个空格,注释或少量的 CSS/HTML 更改。不仅如此,失败结果不会给出关于失败的任何提示,因为它只是检查 1000 行内容有没有改动,而且测试编写人员不得不将这一大堆他无法自己验证的长文档作为期望的 true。所有这些都是测试目标不明确、测试目标过多的症状。
这将会使得我们的测试带上一大堆我们以后可能不会再看的数据。这样做有什么问题?你的测试将有无数种理由失败,因为你放入了太多自己不需要关心的结果数据进去,而你又无法抽出足够的精力从结果的 diff 中判断当前表现是否符合期望。
仅在很少的场景下,长外部快照是可以接受的 —— 当测试断言 schema 而不是数据时 (提取值并关注其中的字段),或者当快照的内容很少被更改时。
❌ 否则:一个 UI 测试挂掉了。代码看起来 ok,屏幕上正确渲染了每个像素,发生了什么?- 你的测试发现跟之前的快照相比,新的快照 markdown 中多了一个空格……
👎 反例:为我们的用例耦合看不到的 2000 行代码
it('TestJavaScript.com is renderd correctly', () => {
//Arrange
//Act
const receivedPage = renderer
.create( <DisplayPage page = "http://www.testjavascript.com" > Test JavaScript < /DisplayPage>)
.toJSON();
//Assert
expect(receivedPage).toMatchSnapshot();
//We now implicitly maintain a 2000 lines long document
//every additional line break or comment - will break this test
});
👏 正例:期望是可见且集中的
it('When visiting TestJavaScript.com home page, a menu is displayed', () => {
//Arrange
//Act
receivedPage tree = renderer
.create( <DisplayPage page = "http://www.testjavascript.com" > Test JavaScript < /DisplayPage>)
.toJSON();
//Assert
const menu = receivedPage.content.menu;
expect(menu).toMatchInlineSnapshot(`
<ul>
<li>Home</li>
<li> About </li>
<li> Contact </li>
</ul>
`);
});
⚪ 1.9 不要写全局的 fixtures 和 seeds,而是放在每个测试中
✅ 建议:参照黄金法则,每条测试需要在它自己的 DB 行中运行避免互相污染。现实中,这条规则经常被打破:为了性能提升而在执行测试前全局初始化数据库 (也被称为‘test fixture’)。尽管性能很重要,但是它可以通过后面讲的「分组件测试」缓和。为了减轻复杂度,我们可以在每个测试中只初始化自己需要的数据。除非性能问题真的非常显著,那么可以做一定的妥协 —— 仅在全局放不会改变的数据(比如 query)。
❌ 否则:一部分测试挂了,我们的团队花费大量宝贵时间后发现,是由于两个测试同时改变了同一个 seed 数据导致的。
👎 反例:用例之间不独立,而是依赖同一个全局钩子来生成全局 DB 数据
before(() => {
//adding sites and admins data to our DB. Where is the data? outside. At some external json or migration framework
await DB.AddSeedDataFromJson('seed.json');
});
it("When updating site name, get successful confirmation", async () => {
//I know that site name "portal" exists - I saw it in the seed files
const siteToUpdate = await SiteService.getSiteByName("Portal");
const updateNameResult = await SiteService.changeName(siteToUpdate, "newName");
expect(updateNameResult).to.be(true);
});
it("When querying by site name, get the right site", async () => {
//I know that site name "portal" exists - I saw it in the seed files
const siteToCheck = await SiteService.getSiteByName("Portal");
expect(siteToCheck.name).to.be.equal("Portal"); //Failure! The previous test change the name :[
});
👏 正例:每个用例操作它自己的数据集
it("When updating site name, get successful confirmation", async () => {
//test is adding a fresh new records and acting on the records only
const siteUnderTest = await SiteService.addSite({
name: "siteForUpdateTest"
});
const updateNameResult = await SiteService.changeName(siteUnderTest, "newName");
expect(updateNameResult).to.be(true);
});
⚪ 1.10 不要 catch 错误,expect 它们
✅ 建议:当你测试一些输入是否会触发错误时,使用 try-catch-finally 测试看起来似乎没问题。但结果会比较奇葩(会隐藏测试的意图和期望结果),并且把 tc 复杂化(比如下面的例子)。
一个更优雅的替代方法是使用 Chai 断言 expect(method).to.throw
(或者 Jest 的: expect(method).toThrow()
)。必须保证异常包含一个表示错误类型的属性,否则如果只给出一个通用错误,应用程序没法展示足够的信息。
❌ 否则:从测试报告(如 CI 报告)中查找出错的位置将会很痛苦。
👎 反例:一个长测试用例,尝试使用 try-catch 断言错误
it("When no product name, it throws error 400", async() => {
let errorWeExceptFor = null;
try {
const result = await addNewProduct({name:'nest'});}
catch (error) {
expect(error.code).to.equal('InvalidInput');
errorWeExceptFor = error;
}
expect(errorWeExceptFor).not.to.be.null;
//if this assertion fails, the tests results/reports will only show
//that some value is null, there won't be a word about a missing Exception
});
👏 正例:一个人类可读的期望,它很容易被理解,甚至可被 QA 或技术 PM 理解
it.only("When no product name, it throws error 400", async() => {
expect(addNewProduct)).to.eventually.throw(AppError).with.property('code', "InvalidInput");
});
⚪ 1.11 为你的测试用例打标签
✅ 建议:不同的测试需要在不同的场景中执行:快速冒烟、IO 测试、开发者保存或者提交文件后的测试、当一个新的 PR 提交后需要全量执行的端到端测试 等等。你可以用一些 #cold #api #sanity
之类的标签标注测试来达到这个目的,这样你就可以在测试时仅测试想要的子集。如在 mocha 中可以这样唤起用例组 mocha — grep ‘sanity’ 。
❌ 否则:执行所有的用例,包括执行大量 DB 查询的用例,开发者做的任何小改动都需要等待很长的时间,将会导致开发者不再想运行测试。
👏 正例:将用例标记为‘#cold-test’
使得用例执行者可以仅执行快的用例 (Cold=== 没有 IO 的快速测试,可以在开发人员打字时频繁执行)
//this test is fast (no DB) and we're tagging it correspondigly
//now the user/CI can run it frequently
describe('Order service', function() {
describe('Add new order #cold-test #sanity', function() {
test('Scenario - no currency was supplied. Expectation - Use the default currency #sanity', function() {
//code logic here
});
});
});
⚪ 1.12 其他常见的优秀测试习惯
✅ 建议:本文主要讨论与 Node JS 相关的测试建议,或者至少可以用 Node JS 作为例子。然而,本小节整理了一些众所周知的与 Node 无关的技巧。
学习并实践 TDD 原则 —— 它对许多人来说非常有价值,但是如果它们不适合你的风格,不要害怕,你不是唯一一个。尝试在写代码之前使用 red-green-refactor 风格 编写测试,确保每个测试只检查一项,当你发现一个 bug 时,在修复前新增一个测试在未来检测到它,让每一个测试在变绿之前至少失败一次,快速编写一个简单的代码模块以满足这个测试,然后逐渐将其重构至生产水平,避免任何依赖环境 (路径、操作系统等)。
❌ 否则:你会错过数十年来智慧的结晶。
关于本文
原文:https://github.com/goldbergyoni/javascript-testing-best-practices/blob/master/readme-zh-CN.md
关于【测试】相关推荐,欢迎读者们自荐投稿,前端早读课等你来
【第2725期】伊斯坦布尔测试覆盖率的实现原理
【第2476期】蓝卓@李栋:使⽤Jest+Enzyme测试React组件