依赖反转、猴子修补、两者兼而有之还是两者都最适合单元测试?

Is dependency inversion, monkey patching, both or neither most appropriate for unit testing?

提问人:pleasedesktop 提问时间:11/24/2016 更新时间:1/10/2017 访问量:970

问:

这些是人为的例子,大部分是 JavaScript,但这个问题与语言无关,并且通常侧重于单元测试。

代码库

function func1() {                                                               
  return func2(7, 4);                                                            
}                                                                                

function func2(param1, param2) {                                                 
  return param1 + param2 + func3(11) + func4(14, 2, 8);                          
}                                                                                

function func3(param1) {                                                         
  return param1 + 5;                                                             
}                                                                                

function func4(param1, param2, param3) {                                         
  return func5(6, 1) + param1 + param2 + param3;                                 
}                                                                                

function func5(param1, param2) {                                                 
  return param1 + param2;                                                        
}

单元测试(Monkey Patch 样式)

function func2_stub(param1, param2) {
  return 5;
}

monkey_patch(func2, func2_stub);
assert(func1() == 5);

问题

  • 测试与实现紧密耦合。
  • 在某些语言中可能无法进行猴子修补。
  • 未经测试的副作用依赖项更改不会破坏现有测试(即静默和未修补的依赖项)。

单元测试(依赖项反转/注入样式)

我了解依赖反转/注入、存根、伪造、模拟等概念,但尚未在现实世界的多级函数调用中实践过它。也就是说,到目前为止我看到的例子只是显示了一个调用方和一个被调用方。

这是我推断的,它超过两个级别:

// Refactored code

function func1() {                                                               
  return func2(func3, func4, func5, 7, 4);                                       
}                                                                                

function func2(dependent1, dependent2, dependent3, param1, param2) {             
  return param1 + param2 + dependent1(11) + dependent2(dependent3, 14, 2, 8);    
}                                                                                

function func3(param1) {                                                         
  return param1 + 5;                                                             
}                                                                                

function func4(dependent1, param1, param2, param3) {                             
  return dependent1(6, 1) + param1 + param2 + param3;                            
}                                                                                

function func5(param1, param2) {                                                 
  return param1 + param2;                                                        
}

// Tests

function func5_stub(param1, param2) {
  return 5;
}

assert(func4(func5_stub, 1, 2, 3) == 11);

问题

  • 测试与实现紧密耦合。
  • 顶级函数因未使用的参数(只是被传递下来)而变得臃肿。
  • 如何测试最高级别的函数(在本例中为 func1)?每次反转依赖关系时,都会无意中创建另一个级别。

问题

在现实世界中进行单元测试(即深层次的函数调用)时,处理存根依赖项的最佳方法或策略是什么?

单元测试 与语言无关 存根 依赖项反转

评论


答:

1赞 pleasedesktop 1/10/2017 #1

函数式编程有很多优点,与此相关的是,它使测试变得超级简单/干净,因为它很容易实现依赖反转/注入。

你不需要使用像 Haskell 这样的函数式编程语言来编写依赖反转函数,所以先不要逃跑。 您的编程语言只需要函数和间接引用函数的能力(指针/引用)。

我认为解释该策略的最好方法是从一些示例开始:

动态类型化示例 (JavaScript)

/*
 * This function is now trivial to unit test.
 */
function depInvFunc(param1, param2, depFunc1, depFunc2) {
  // do some stuff

  var result1 = depFunc1(param1);
  var result2 = depFunc2(param2);

  if (result1 % 15 === 0) {
    result1 *= 4;
  }

  return result1 + result2;
}

/*
 * This function can be used everywhere, as opposed to using the above function
 * and having to specify the dependent param functions all the time.
 * 
 * This function does not need to be tested (nor should it be), because it has
 * no logic, it's just a simple function call.
 *
 * Think of these kinds of wrapper dependent-defining functions as configuration
 * functions (like config files). You don't have unit tests for your configs,
 * you just manually check them yourself.
 */
function wrappedDepInvFunc(param1, param2) {
  return depInvFunc(param1, param2, importedFunc1, importedFunc2);
}

静态类型示例 (Java)

DepInvFunc.java:

public class DepInvFunc {

   public int doDepInvStuff(String param1, String param2, Dep1 dep1, 
                            Dep2 dep2) {
      // do some stuff

      int result1 = dep1.doDepStuff(param1);
      int result2 = dep2.doDepStuff(param2);

      if (result % 15 == 0) {
         result1 *= 4;
      }

      return result1 + result2;
   }

}

包装的DepInvFunc.java:

public class WrappedDepInvFunc {

   public int wrappedDoDepInvStuff(String param1, String param2) {
      Dep1 dep1 = new Dep1();
      Dep2 dep2 = new Dep2();

      return DepInvFunc().doDepInvStuff(param1, param2, dep1, dep2);
   }

}

Dep1.java:

public class Dep1 {

   public int doDepStuff(String param1) {
      // do stuff
      return 5;
   }

}

Dep2.java:

public class Dep2 {

   public int doDepStuff(String param1) {
      // do stuff
      return 7;
   }

}

因此,这种方法(使用动态类型语言时)的唯一缺点是,由于您可能间接调用函数,因此您(和/或您的 IDE)可能无法检测到提供给这些间接函数调用的无效参数。

当使用静态类型语言的编译时类型检查时,这个问题在很大程度上得到了克服。

这种方法避免了对脆弱且可能不可用的猴子修补的需求,并且不会出现必须将依赖函数的参数从高级函数传递到较低级别函数的问题。


Tldr:将所有(或尽可能多的)逻辑放入依赖反转函数中(通过依赖注入很容易测试),并将它们包装在无逻辑/最小函数中(不需要测试)。


我刚才从这两个来源汲取灵感后想到了这个策略: