Search code examples
unit-testinglanguage-agnosticstubdependency-inversion

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


These are contrived examples and are mostly JavaScript, but the question is meant to be language agnostic and focused on unit testing in general.

Codebase

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;                                                        
}

Unit Tests (Monkey Patch Style)

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

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

Problems

  • Tests tightly coupled to implementation.
  • Monkey patching might not be possible in certain languages.
  • Untested side effect dependency changes do not break existing tests (i.e. silent and unpatched dependencies).

Unit Tests (Dependency Inversion/Injection Style)

I understand the concepts of dependency inversion/injection, stubbing, faking, mocking, etc., but have yet to come across it being practised in real-world multi-level function calls. I.e. The examples I have seen thus far just shown a caller and a callee.

This is what I extrapolate it to be for more than two levels:

// 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);

Problems

  • Tests tightly coupled to implementation.
  • Top-level functions are bloated with unused parameters (that just get passed down).
  • How do you test the highest level function (func1 in this case)? Every time you invert dependency, you inadvertently create another level.

Question

What is the best approach or strategy for dealing with stubbing out dependencies when unit testing in the real-world (i.e. deep levels of function calls)?


Solution

  • Functional programming has many advantages and the one that is relevant here is that it makes testing super easy/clean, because it's easy to achieve dependency inversion/injection.

    You don't need to use a functional programming language like Haskell to write dependency inverted functions, so don't run away yet. Your programming language just needs functions and the ability to refer to functions indirectly (pointers/references).

    I think the best way to explain the strategy is to start with some examples:

    Dynamically Typed Example (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);
    }
    

    Statically Typed Example (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;
       }
    
    }
    

    WrappedDepInvFunc.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;
       }
    
    }
    

    So the only downside to this approach (when using a dynamically typed language) is that because you are potentially calling functions indirectly, you (and/or your IDE) might not detect invalid arguments supplied to those indirect function calls.

    This problem is largely overcome when utilising the compile time type checking of your statically typed language.

    This approach avoids the need for brittle and potentially unavailable monkey-patching and does not come with the problem of having to pass arguments for dependent functions down from high-level functions to lower-level functions.


    Tldr: Put all (or as much as you can) of your logic into dependently inverted functions (which are easy to test via dependency injection) and wrap them in logic-free/minimal functions (which should NOT need to be tested).


    This strategy came to me just now, after drawing inspiration from these two sources: