在我们的JavaScript开发团队中,我们接受了编写纯函数式代码的redux/act风格。但是,我们似乎确实在对代码进行单元测试时遇到了麻烦。请考虑以下示例:
function foo(data) {
return process({
value: extractBar(data.prop1),
otherValue: extractBaz(data.prop2.someOtherProp)
});
}
这个函数调用依赖于对process
、extractBar
和extractBaz
的调用,每个函数都可以调用其他函数。总之,它们可能需要一个非平凡的模拟来构造data
参数进行测试。
如果我们接受制作这样一个模拟对象的必要性,并在测试中实际这样做,我们很快就会发现我们有难以阅读和维护的测试用例。此外,它很可能导致一遍又一遍地测试同样的东西,因为process
、extractBar
和extractBaz
的单元测试可能也应该编写。测试这些功能通过 to 接口实现的每个可能的边缘情况foo
很笨拙。
我们有一些解决方案,
但并不真正喜欢任何解决方案,因为两者都不像我们以前见过的模式。
解决方案 1:
function foo(data, deps = defaultDeps) {
return deps.process({
value: deps.extractBar(data.prop1),
otherValue: deps.extractBaz(data.prop2.someOtherProp)
});
}
解决方案 2:
function foo(
data,
processImpl = process,
extractBarImpl = extractBar,
extractBazImpl = extractBaz
) {
return process({
value: extractBar(data.prop1),
otherValue: extractBaz(data.prop2.someOtherProp)
});
}
随着依赖函数调用数量的增加,解决方案 2 会很快污染foo
方法签名。
解决方案 3:
只需接受foo
是一个复杂的复合操作的事实,并对其进行整体测试即可。所有缺点都适用。
请提出其他可能性。我想这是函数式编程社区必须以某种方式解决的问题。
您可能不需要任何您考虑过的解决方案。函数式编程和命令式编程之间的区别之一是,函数式风格应该产生更容易推理的代码。不仅仅是在心理上"玩编译器"并模拟给定的一组输入会发生什么,而是在更多的数学意义上对代码进行推理。
例如,单元测试的目标是测试"所有可能中断的东西"。查看您发布的第一个代码片段,我们可以对函数进行推理并问:"这个函数怎么会坏?这是一个非常简单的函数,我们根本不需要玩编译器。我们可以说,如果process()
函数未能为给定的一组输入返回正确的值,即如果它返回无效结果或抛出异常,则该函数将中断。这反过来意味着我们还需要测试extractBar()
和extractBaz()
是否返回正确的结果,以便将正确的值传递给process()
。
所以实际上,你只需要测试foo()
是否抛出意外的异常,因为它所做的只是调用process()
,你应该在它自己的一组单元测试中测试process()
。extractBar()
和extractBaz()
也是如此.如果这两个函数在给定有效输入时返回正确的结果,它们会将正确的值传递给process()
,如果process()
在给定有效输入时产生正确的结果,那么foo()
也将返回正确的结果。
你可能会说,"争论呢?如果它从data
结构中提取了错误的值怎么办?但这真的能打破吗?如果我们看一下函数,它使用核心 JS 点表示法来访问对象的属性。我们不会在应用程序的单元测试中测试语言本身的核心功能。我们可以只查看代码,推理它基于硬编码的对象属性访问提取值,然后继续进行其他测试。
这并不是说你可以扔掉你的单元测试,但是很多有经验的函数式程序员发现他们需要的测试要少得多,因为你只需要测试可以破坏的东西,而函数式编程减少了可破坏的东西的数量,所以你可以把测试集中在真正有风险的部分上。
顺便说一下,如果你正在处理复杂的数据,并且你担心即使使用FP也可能很难推理出所有可能的排列,你可能想研究生成测试。我认为有一些JS库可以做到这一点。