- 原文地址:Mocking is a Code Smell
- 原文做者:Eric Elliott
- 译文出自:掘金翻译计划
- 本文永久连接:github.com/xitu/gold-m…
- 译者:yoyoyohamapi
- 校对者:IridescentMia athena0304
(译注:该图是用 PS 将烟雾处理成方块状后获得的效果,参见 flickr。)javascript
这是 “软件编写” 系列文章的第十一部分,该系列主要阐述如何在 JavaScript ES6+ 中从零开始学习函数式编程和组合化软件(compositional software)技术(译注:关于软件可组合性的概念,参见维基百科 < 上一篇 | << 返回第一篇html
关于 TDD (Test Driven Development:测试驱动开发)和单元测试,我最常听到的抱怨就是,开发者常常要和隔离单元所要求的 mock(模拟)做斗争。一些开发者并不知道单元测试真正意义所在。实际上,我发现开发者迷失在了他们单元测试文件中的 mock(模拟)、fake(伪造对象)、和 stub(桩)(译注:三者都是 Test Double(测试替身),可参看单元测试中 Mock 与 Stub 的浅析,Unit Test - Stub, Mock, Fake 簡介),这些测试替身并无执行任何现实中实现的代码。前端
另外一方面,开发者容易陷入 TDD 的教条中,想方设法地要完成 100% 的代码覆盖率,即使这样作会使他们的代码愈来愈复杂。java
我常常告诉开发者 mock 是一种代码异味(code smell),但大多数开发者的 TDD 技巧偏离到了追求 100% 单元测试覆盖率的阶段,他们没法想象去掉一个个的 mock 该怎么办。为了将 mock 置入到应用中,他们尝试对测试单元包裹依赖注入函数,更糟糕地,还会将服务打包进依赖注入容器。node
Angular 作得很极端,它为全部的组件添加了依赖注入,试图让人们将依赖注入看做是解耦的主要方式。但事实并不是如此,依赖注入并非完成解耦的最佳手段。react
学习高效的 TDD 的过程也是学习如何构建更加模块化应用的过程。android
TDD 不是要复杂化代码,而是要简化代码。若是你发现当你为了让代码更可测试而牺牲掉代码的可读性和可维护性时,或者你的代码由于引入了依赖注入的样板代码而变臃肿时,你正在错误地实践 TDD。ios
不要觉得在项目中引入依赖注入就能模拟整个世界。它们未必能帮到你,相反还会坑了你。编写更多的可测试代码本应当可以简化你的代码。它不只要求更少的代码行数,还要求代码更加可读、灵活以及可维护,依赖注入却与此相反。git
本文将教会你两件事:github
更复杂的代码一般伴有更加臃肿的代码。你对整洁代码的渴望就像你对房屋整洁的渴望那样:
“代码异味指的是系统深层次问题反映出来的表面迹象” ~ Martin Fowler
代码异味并不意味着某个东西彻底错了,或者是某个东西必须当即获得修正。它只是一个经验法则,来提醒你要作出一些优化了。
本文以及本文的标题没有暗示全部的 mock 都是很差的,也没有暗示你别再使用 mock 了。
另外,不一样类型的代码须要不一样程度(或者说不一样类型)的 mock。若是代码是为了方便 I/O 操做的,那么测试就应当着眼于 mock I/O,不然你的单元测试覆盖率将趋近于 0。
若是你的代码不存在任何逻辑(只含有纯函数组成的管道或者组合),0% 的单元测试覆盖率也是能够接受的,由于此时你的集成测试或者功能测试的覆盖率接近 100%。然而,若是代码中存在逻辑(条件表达式,变量赋值,显式函数调用等),你可能须要单元测试覆盖率,此时你有机会去简化你的代码以及减小 mock 需求。
mock 是一个测试替身(test double),在单元测试过程当中,它负责真正的代码实现。在整个测试的运行期内,一个 mock 可以产生有关它如何被测试对象所操纵的断言。若是你的测试替身产生了断言,在特定的意义上,它就是一个 mock。
“mock”一词更经常使用来指代任何测试替身的使用。考虑到本文的创做初衷,咱们将交替使用 “mock” 和“测试替身”两个词以符合潮流。全部的测试替身(dummy、spy、fake 等等)都表明了与测试对象紧耦合的真实代码,所以,全部的测试替身都是耦合的标识,优化测试,也间接帮助优化了代码质量。与此同时,减小对于 mock 的需求可以大幅简化测试自己,由于你再也不须要花费时间去构建 mock。
单元测试是测试单个工做单元(模块,函数,类),测试期间,将隔离单元与程序剩余部分。
集成测试是测试两个或多个单元间集成度的,功能测试则是从用户视角来测试应用的,包含了完整的用户交互工做流,从 mock UI 操做,到数据层更新,再到对用户输出(例如应用在屏幕上的展现)。功能测试是集成测试的一个子集,由于他们测试了应用的全部单元,这些单元集成在了当前运行应用的一个上下文中。
通常而言,只会使用单元的公共接口(也叫作 “公共 API” 或者 “表面积”)来测试单元。这被称为黑盒测试。黑盒测试对于测试的健壮度更有利,由于对于某个测试单元,其公共 API 的变化频度一般小于实现细节的变化频度,即公共 API 通常是稳定的。若是你写白盒测试,这种测试就能知道功能实现细节,所以任何实现细节的改变都将破坏测试,即使公共 API 的功能仍然不变。换言之,白盒测试会引发一些耗时的重复工做。
测试覆盖率与被测试用例所覆盖的代码数量有关。覆盖率报告能够经过插桩(instrumenting)代码以及在测试期间记录哪行代码被执行了来建立。通常来讲,咱们追求高测试覆盖率,可是当覆盖率趋近于 100% 时,将形成收益递减。
我的而言,将测试覆盖率提升到 90% 以上彷佛也并不能再下降更多的 bug。
为何会这样呢?100% 的覆盖率不是意味着咱们 100% 肯定代码已经按照预期实现了吗?
事实证实,没那么简单。
大多数开发者并不知道其实存在着两种覆盖率:
用例覆盖率与用例场景有关:代码在真实环境的上下文将如何工做,该环境包含有真实用户,真实网络情况甚至还有黑客的非法攻击。
覆盖率标识了代码覆盖上的弱点或威胁,而不是用例覆盖上的弱点和威胁。相同的代码可能服务于不一样的用例,单一用例可能依赖了当前测试对象之外的代码,甚至依赖了另外一个应用或者第三方 API。
因为用例可能涉及环境、多个单元、用户以及网络情况,因此不太可能在只包含了一个测试单元的测试集下覆盖全部所要求的用例。从定义上来讲,单元测试对各个单元进行独立地测试,而非集成测试,这也意味着,对于只包含了一个测试单元的测试集来讲,集成或者功能用例场景下的用例覆盖率趋近于 0%。
100% 的代码覆盖率不能保证 100% 的用例覆盖率。
开发者对于 100% 代码覆盖率的追求看来是走错路了。
使用 mock 来完成单元测试中单元隔离的需求是由各个单元间的耦合引发的。紧耦合会让代码变得呆板而脆弱:当须要改变时,代码更容易被破坏。通常来讲,耦合越少,代码更易扩展和维护。锦上添花的是,耦合的减小也会减小测试对于 mock 的依赖,从而让测试变得更加容易。
从中不难推测,若是咱们正 mock 某个事物,就存在着经过减小单元间的耦合来提高代码灵活性的空间。一旦解耦完成,你将不再须要 mock 了。
耦合反映了某个单元的代码(模块、函数、类等等)对于其余单元代码的依赖程度。紧耦合,或者说一个高度的耦合,反映了一个单元在其依赖被修改时有多大可能会损坏。换言之,耦合越紧,应用越难维护和扩展。松耦合则能够下降修复 bug 和为应用引入新的用例时的复杂度。
耦合会有不一样形式的反映:
紧耦合有许多成因:
相较于函数式代码,命令式以及面向对象代码更易遭受紧耦合问题。这并不是是说函数式编程风格能让你的代码免于紧耦合困扰,只是函数式代码使用了纯函数做为组合的基本单元,而且纯函数自然不易遭受紧耦合问题。
纯函数:
纯函数是如何减小耦合的?
一切皆可。软件开发的实质是一个将大的问题划分为若干小的、独立的问题(分解),再组合各个小问题的解决方式来构成应用去解决大问题(合成)的过程。
当咱们的分解策略失败时,咱们才须要 mock。
当测试单元把大问题分解为若干相互依赖的小问题时,咱们须要引入 mock。换句话说,若是咱们假定的原子测试单元并非真正原子的,那么就须要 mock,此时,分解策略也没能将大的问题划分为小的、独立的问题。
当分解成功时,就能使用一个通用的组合工具来组合分解结果。例以下面这些:
lodash/fp/compose
asyncPipe()
,使用 composeM()
、composeK()
的 Kleisli 组合。当你使用通用组合工具时,组合的每一个元素均可以在不 mock 其它的状况下进行独立的单元测试。
组合自身将是声明式的,因此它们包含了 0 个可单元测试的逻辑 (能够假定组合工具是一个本身有单元测试的第三方库)。
在这些条件下,使用单元测试是没有意义的,你须要使用集成测试替代之。
咱们用一个你们熟悉的例子来比较命令式和声明式的组合:
// 函数组合
// import pipe from 'lodash/fp/flow';
const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x);
// 待组合函数
const g = n => n + 1;
const f = n => n * 2;
// 命令式组合
const doStuffBadly = x => {
const afterG = g(x);
const afterF = f(afterG);
return afterF;
};
// 声明式组合
const doStuffBetter = pipe(g, f);
console.log(
doStuffBadly(20), // 42
doStuffBetter(20) // 42
);
复制代码
函数组合是将一个函数的返回值应用到另外一个函数的过程。换句话说,你建立了一个函数管道(pipeline),以后向管道传入了一个值,这个值将流过每一个函数,这些函数就像是流水线上的某一步,在传入下一个函数以前,这个值都会以某种方式被改变。最终,管道中的最后一个函数将返回最终的值。
initialValue -> [g] -> [f] -> result
复制代码
在每一个主流编程语言中,不管这门语言是什么范式,组合都是组织应用代码的主要手段。甚至连 Java 也是使用函数(方法)做为两个不一样类实例间传递消息的机制。
你能够手动地组合函数(命令式的),也能够自动地组合函数(声明式的)。在非函数第一类(first-class functions)语言中,你别无选择,只能以命令式的方式来组合函数。但在 JavaScript 中(以及其余全部主流语言中),你可使用声明式组合来更好地组织代码。
命令式编程风格意味着咱们正在命令计算机一步步地作某件事。这是一种如何作(how-to)的引导。在上面的例子中,命令式风格就像在说:
x
。afterG
的绑定,将 g(x)
的结果分配给它。afterF
的绑定,将 f(afterG)
的结果分配给它。afterF
的结果。命令式风格的组合要求组合中牵涉的逻辑也要被测试。虽然我知道这里只有一些简单的赋值操做,可是我常在我传递或者返回错误的变量时,看到过(而且本身也写过)bug。
声明式风格的组合意味着咱们告诉计算机事物之间的关系。它是一个使用了等式推理(equational reasoning)的结构描述。声明式的例子就像在说:
doStuffBetter
是 函数 g
和 f
的管道化组合。仅此而已。
假定 f
和 g
都有它们本身的单元测试,而且 pipe()
也有其本身的单元测试(在 Lodash 中是 flow()
,在 Ramda 中是 pipe()
),因此就没有须要进行单元测试的新的逻辑。
为了让声明式组合正确工做,咱们组合的单元须要被 解耦。
为了去除耦合,咱们首先须要对于耦合来源有更好的认识。下面罗列了一些耦合的主要来源,它们被按照耦合的松紧程度进行了排序:
紧耦合:
const enhancedWidgetFactory = compose(eventEmitter, widgetFactory, enhancements);
中,widgetFactory
依赖了 eventEmitter
松耦合:
讽刺的是,多数耦合偏偏来自于最初为了减小耦合所作的设计中。但这是能够理解的,为了可以将小问题的解决方案从新组成完整的应用,单元彼此就须要以某种方式进行集成或者通讯。方式有好的,也有很差的。只要有必要,就应当规避紧耦合产生的来源,一个健壮的应用更须要的是松耦合。
对于我将依赖注入容器和依赖注入参数划分到 “紧耦合” 分组中,你可能感到疑惑,由于在许多书上或者是博客上,它们都被分到了 “松耦合” 一组。耦合不是个是非问题,它描述了一种程度。因此,任何分组都带有主观和专断色彩。
对于耦合松紧界限的划分,我有一个立见分晓的检验方法:
测试单元是否能在不引入 mock 依赖的前提下进行测试?若是不行,那么测试单元就 紧耦合 于 mock 依赖。
你的测试单元依赖越多,越可能存在耦合问题。如今咱们明白了耦合是怎么发生的,咱们能够作什么呢?
以上几点意味着那些你用来创建网络请求和操纵请求的代码都不须要单元测试,它们须要的是集成测试。
再唠叨一下:
不要对 I/O 进行单元测试。
I/O 针对于集成测试。
在集成测试中,mock 和 fake(伪造)都是彻底 OK 的。
纯函数的使用须要多加练习,在缺少练习的状况下,如何写一个符合预期的纯函数不会那么清晰明了。纯函数不能直接改变全局变量以及传给它的参数,如网络对象、磁盘对象或者是屏幕对象。纯函数惟一能作的就是返回一个值。
若是你向纯函数传入了一个数组或者一个对象,而且你要返回对象或者数组变化了的版本,你不要直接改变并返回它们。你应当建立一个知足对应变化的对象拷贝。对此,你能够考虑使用数组的访问器方法 (而不是 可变方法,例如 Array.prototype.spilce
、Array.prototype.sort
等),或在 Object.assign()
中新建立一个空对象做为目标对象,再或者使用数组或者对象的展开语法。例子以下:
// 非纯函数
const signInUser = user => user.isSignedIn = true;
const foo = {
name: 'Foo',
isSignedIn: false
};
// Foo 被改变了
console.log(
signInUser(foo), // true
foo // { name: "Foo", isSignedIn: true }
);
复制代码
与:
// 纯函数
const signInUser = user => ({...user, isSignedIn: true });
const foo = {
name: 'Foo',
isSignedIn: false
};
// Foo 没有被改变
console.log(
signInUser(foo), // { name: "Foo", isSignedIn: true }
foo // { name: "Foo", isSignedIn: false }
);
复制代码
又或者,你能够选择一个针对于不可变对象类型的第三方库,例如 Mori 或者是 Immutable.js。我但愿有朝一日,在 JavaScript 中,有相似于 Clojure 中的不可变数据类型,但我可等不到那会儿了。
你可能以为返回新的对象会形成必定的性能开销,由于咱们建立了新对象,而不是直接重用现有对象,可是一个利好是咱们可使用严格比较(也叫相同比较:identity equality)运算符(===
检查)来检查对象是否发生了改变,这时,咱们再也不须要遍历整个对象来检测其是否发生了改变。
这个技巧可让你的 React 组件有一个复杂的状态树时渲染更快,由于你可能不须要在每次渲染时进行状态对象的深度遍历。继承 PureComponent
组件,它经过状态(state)和属性(prop)的浅比较实现了 shouldComponentUpdate()
。当它检测到对象相同时,它便知道对应的状态子树没有发生改变,所以也就不会再进行状态的深度遍历。
纯函数也可以记忆化(memoized),这意味着若是接收到了相同输入,你不须要再重复构建完整对象。利用内存和存储,你能够将预先计算好的结果存入一张查找表中,从而下降计算复杂度。对于开销较大、但不会无限需求内存的计算任务来讲,这个是很是好的优化策略。
纯函数的另外一个属性是,因为它们没有反作用,就可以在拥有大型集群的处理器上安全地使用一个分治策略来部署计算任务。该策略一般用在处理图像、视频或者声音帧,具体说来就是利用服务于图形学的 GPU 并行计算,但如今这个策略有了更广的使用,例如科学计算。
换句话说,可变性不老是很快,某些时候,其优化代价远远大于优化受益,所以还会让性能变慢。
有若干策略能帮助你将反作用从逻辑中隔离出来,下面罗列了当中的一些:
asyncPipe()
来组合那些返回 promise 的函数。call()
不会当即调用一个函数。取而代之的是,它会返回一个包含了待调用函数引用及所需参数的对象,saga 中间件则会负责调用该函数。这样,call()
以及全部使用了它的函数都是纯函数,这些函数不须要 mock,从而也利于单元测试。pub/sub 是 publish/subscribe(发布/订阅) 模式的简写。在该模式中,测试单元不会直接调用彼此。取而代之的是,他们发布消息到监听消息的单元(订阅者)。发布者不知道是否有单元会订阅它的消息,订阅者也不知到是否有发布者会发布消息。
pub/sub 模式被内置到了文档对象模型(DOM)中了。你应用中的任何组件都能监听到来自 DOM 元素分发的事件,例如鼠标移动、点击、滚动条事件、按键事件等等。回到每一个人都使用 jQuery 构建 web 应用的时代,常常见到使用 jQuery 来自定义事件使 DOM 转变为一个 pub/sub 的 event bus,从而将视图渲染这个关注点从状态逻辑中解耦出来。
pub/sub 也内置到了 Redux 中。在 Redux 中,你为应用状态(被称为 store)建立一个全局模型。视图和 I/O 操做没有直接修改模型(model),而是分派一个 action 对象到 store。一个 action 有一个称之为 type
的属性,不一样的 reducer 按照该属性进行监听及响应。另外,Redux 支持中间件,它们也能够监听而且响应特殊的 action 类型。这种方式下,你的视图不须要知道你的应用状态是如何被操纵的,状态逻辑也不须要知道关于视图的任何事。
经过中间件,也可以轻易地打包新的特性到 dispatcher 中,从而驱动横切关注点(cross-cutting concerns),例如对 action 的日志/分析,使用 storage 或者 server 来同步状态,或者加入 server 和网络节点的实时通讯特性。
有时,你可使用 monad 组合(例如组合 promise)来减小你组合当中的依赖。例如,下面的函数由于包含了逻辑,你就不得不 mock 全部的异步函数才能进行单元测试:
async function uploadFiles({user, folder, files}) {
const dbUser = await readUser(user);
const folderInfo = await getFolderInfo(folder);
if (await haveWriteAccess({dbUser, folderInfo})) {
return uploadToFolder({dbUser, folderInfo, files });
} else {
throw new Error("No write access to that folder");
}
}
复制代码
咱们写一些帮助函数伪代码来让上例可工做:
const log = (...args) => console.log(...args);
// 下面这些能够无视,在真正的代码中,你会使用真实数据
const readUser = () => Promise.resolve(true);
const getFolderInfo = () => Promise.resolve(true);
const haveWriteAccess = () => Promise.resolve(true);
const uploadToFolder = () => Promise.resolve('Success!');
// 随便初始化一些变量
const user = '123';
const folder = '456';
const files = ['a', 'b', 'c'];
async function uploadFiles({user, folder, files}) {
const dbUser = await readUser({ user });
const folderInfo = await getFolderInfo({ folder });
if (await haveWriteAccess({dbUser, folderInfo})) {
return uploadToFolder({dbUser, folderInfo, files });
} else {
throw new Error("No write access to that folder");
}
}
uploadFiles({user, folder, files})
.then(log)
;
复制代码
咱们使用 asyncPipe()
来完成 promise 组合,实现对上面业务的重构:
const asyncPipe = (...fns) => x => (
fns.reduce(async (y, f) => f(await y), x)
);
const uploadFiles = asyncPipe(
readUser,
getFolderInfo,
haveWriteAccess,
uploadToFolder
);
uploadFiles({user, folder, files})
.then(log)
;
复制代码
由于 promise 内置有条件分支,所以,例子中的条件逻辑能够被轻松移除了。因为逻辑和 I/O 没法很好地混合在一块儿,所以咱们想要从依赖 I/O 的代码中去除逻辑。
为了让这样的组合工做,咱们须要保证两件事:
haveWriteAccess()
在用户没有写权限时须要 reject。这能让分支逻辑转到 promise 上下文中,咱们不须要单元测试,也无需担心分支逻辑(promise 自己拥有 JavaScript 引擎支持的测试)。pipelineData
类型来完成组合,该类型只是一个包含了以下 key 的对象:{ user, folder, files, dbUser?, folderInfo? }
。它建立一个在各个组件间共享的结构依赖,在其它地方,你可使用这些函数更加泛化的版本,而且使用一个轻量的包裹函数标准化这些函数。当这些条件知足了,就能很轻松地、相互隔离地、脱离 mock 地测试每个函数。由于咱们已经将组合管道中的全部逻辑抽出了,单元测试也就再也不须要了,此时应当登场的是集成测试。
牢记:逻辑和 I/O 是相互隔离的关注点。 逻辑是思考,反作用(I/O)是行为。三思然后行!
redux-saga 所使用的策略是使用对象来描述将来计算。该想法相似于返回一个 monad,不过它不老是必须返回一个 monad。monad 可以经过链式操做来组合函数,可是你能够手动的使用命令式风格代码来组合函数。下面的代码大体展现了 redux-saga 是如何作到用对象描述将来计算的:
// console.log 的语法糖,一下子咱们会用它
const log = msg => console.log(msg);
const call = (fn, ...args) => ({ fn, args });
const put = (msg) => ({ msg });
// 从 I/O API 引入的
const sendMessage = msg => Promise.resolve('some response');
// 从状态操做句柄或者 reducer 引入的
const handleResponse = response => ({
type: 'RECEIVED_RESPONSE',
payload: response
});
const handleError = err => ({
type: 'IO_ERROR',
payload: err
});
function* sendMessageSaga (msg) {
try {
const response = yield call(sendMessage, msg);
yield put(handleResponse(response));
} catch (err) {
yield put(handleError(err));
}
}
复制代码
如你所见,全部的单元测试中的函数调用都没有 mock 网络 API 或者调用任何反作用。这样作的好处还有:你的应用将很容易 debug,而不用担忧不肯定的网络状态等等......
当一个网络错误出现时,想要去 mock 看看应用里将发生什么?只须要调用 iter.throw(NetworkError)
另外,一些库的中间件将驱动函数执行,从而在应用的生产环境触发反作用:
const iter = sendMessageSaga('Hello, world!');
// 返回一个反映了状态和值的对象
const step1 = iter.next();
log(step1);
/* => { done: false, value: { fn: sendMessage args: ["Hello, world!"] } } */
复制代码
从 call()
中解构出 value,来审查或者调用将来计算:
const { value: {fn, args }} = step1;
复制代码
反作用只会在中间件中运行。当你测试和 debug 时你能够跳过这一部分。
const step2 = fn(args);
step2.then(log); // 将打印一些响应
复制代码
若是你不想在使用 mock API 或者执行 http 调用的前提下 mock 一个网络的响应,你能够直接传递 mock 的响应到 .next()
中:
iter.next(simulatedNetworkResponse);
复制代码
接下来,你能够继续调用 .next()
直到返回对象的 done
变为 true
,此时你的函数也会结束运行。
在你的单元测试中使用生成器(generator)和计算描述,你能够 mock 任何事物而不须要调用反作用。你能够传递值给 .next()
调用以伪造响应,也可使用迭代器对象来抛出错误从而 mock 错误或者 promise rejection。
即使牵涉到的是一个复杂的、混有大量反作用的集成工做流,使用对象来描述计算,都让单元测试再也不须要任何 mock 了。
使用更优架构的努力是好的,但在现实环境中,咱们不得不使用他人的 API,而且与遗留代码打交道,大部分这些 API 都是不纯的。在这些场景中,隔离测试替身是颇有用的。例如,express 经过连续传递来传递共享的可变状态和模型反作用。
咱们看到一个常见例子。人们告诉我 express 的 server 定义文件须要依赖注入,否则你怎么对全部在 express 应用完成的工做进行单元测试?例如:
const express = require('express');
const app = express();
app.get('/', function (req, res) {
res.send('Hello World!')
});
app.listen(3000, function () {
console.log('Example app listening on port 3000!')
});
复制代码
为了 “单元测试” 这个文件,咱们不得不逐步创建一个依赖注入的解决策略,并在以后传递全部事物的 mock 到里面(可能包括 express()
自身)。若是这是一个很是复杂的文件,包含了使用了不一样 express 特性的请求句柄,而且依赖了逻辑,你可能已经想到一个很是复杂的伪造来让测试工做。我已经见过开发者构建了精心制做的 fake 和 mock,例如 express 中的 session 中间件、log 操纵句柄、实时网络协议,应有尽有。我从本身面对 mock 时的坚苦卓绝中得出了一个简单的道理:
这个文件不须要单元测试。
express 应用的 server 定义主要着眼于应用的 集成。测试一个 express 的应用文件从定义上来讲也就是测试程序逻辑、express 以及各个操做句柄之间的集成度。即使你已经完成了 100% 的单元测试,也不要跳过集成测试。
你应当隔离你的程序逻辑到分离的单元,并分别对它们进行单元测试,而不该该直接单元测试这个文件。为 server 文件撰写真正的集成测试,意味着你确实接触到了真实环境的网络,或者说至少借助于 supertest 这样的工具建立了一个真实的 http 消息,它包含了完成的头部信息。
接下来,咱们重构 Hello World 的 express 例子,让它变得更可测试:
将 hello
句柄放入它本身的文件,并单独对其进行单元测试。此时,再也不须要对应用的其余部分进行 mock。显然,hello
不是一个纯函数,所以咱们须要 mock 响应对象来保证咱们可以调用 .send()
。
const hello = (req, res) => res.send('Hello World!');
复制代码
你能够像下面这样来测试它,也能够用你喜欢的测试框架中的指望(expectation)语句来替换 if
:
{
const expected = 'Hello World!';
const msg = `should call .send() with ${ expected }`;
const res = {
send: (actual) => {
if (actual !== expected) {
throw new Error(`NOT OK ${ msg }`);
}
console.log(`OK: ${ msg }`);
}
}
hello({}, res);
}
复制代码
将监听句柄也放入它本身的文件,并单独对其进行单元测试。咱们也将面临相同的问题,express 的句柄不是纯函数,因此咱们须要 mock logger 来保证其可以被调用。测试与前面的例子相似。
const handleListen = (log, port) => () => log(`Example app listening on port ${ port }!`);
复制代码
如今,留在 server 文件中的只剩下集成逻辑了:
const express = require('express');
const hello = require('./hello.js');
const handleListen = require('./handleListen');
const log = require('./log');
const port = 3000;
const app = express();
app.get('/', hello);
app.listen(port, handleListen(port, log));
复制代码
你仍然须要对该文件进行集成测试,单多余的单元测试再也不可以提高你的用例覆盖率。咱们用了一些很是轻量的依赖注入来把 logger 传入 handleListen()
,固然,express 应用能够不须要任何的依赖注入框架。
因为集成测试是测试单元间的协做集成的,所以,在集成测试中伪造 server、网络协议、网络消息等等来重现全部你会在单元通讯时、CPU 的跨集群部署及同一网络下的跨机器部署时遇到的环境。
有时,你也想测试你的单元如何与第三方 API 进行通讯,这些 API 想要进行真实环境的测试将是代价高昂的。你能够记录真实服务下的事务流,并经过伪造一个 server 来重现这些事务,从而测试你的单元和第三方服务运行在分离的网络进程时的集成度。一般,这是测试相似 “是否咱们看到了正确的消息头?” 这样诉求的最佳方式。
目前,有许多集成测试工具可以节流(throttle)网络带宽、引入网络延迟、建立网络错误,若是没有这些工具,是没法用单元测试来测试大量不一样的网络环境的,由于单元测试很难 mock 通讯层。
若是没有集成测试,就没法达到 100% 的用例覆盖率。即使你达到了 100% 的单元测试覆盖率,也不要跳过集成测试。有时 100% 并不真的是 100%。
DevAnyWhere 能帮助你最快进阶你的 JavaScript 能力,如组合式软件编写,函数式编程一节 React:
Eric Elliott 是 “编写 JavaScript 应用” (O’Reilly) 以及 “跟着 Eric Elliott 学 Javascript” 两书的做者。他为许多公司和组织做过贡献,例如 Adobe Systems、Zumba Fitness、The Wall Street Journal、ESPN 和 BBC 等 , 也是不少机构的顶级艺术家,包括但不限于 Usher、Frank Ocean 以及 Metallica。
大多数时间,他都在 San Francisco Bay Area,同这世上最美丽的女子在一块儿。
掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、React、前端、后端、产品、设计 等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。