Don’t use mocks :: joe blubaugh

Writing good unit tests is made much easier by dependency injection. This lets you separate your code’s behavior from that of your dependencies:

type MyType struct {
  dep1 DependencyOne
  dep2 DependencyTwo
}

func GoodConstructor(dep1 DependencyOne, dep2 DependencyTwo) *MyType {
  return &MyType{
    dep1: dep1,
    dep2: dep2,
  }
}

func BadConstructor() *MyType {
  return &MyType{
    dep1: NewDepOne(),
    dep2: NewDepTwo(),
  }
}

The example’s a little bit contrived, because you could just directly write
dep1 to the struct field, but for types with complex initialization, the
testability of a type created with GoodConstructor is much higher.

So, what should you pass into the constructor when testing? Many people pass in
mock objects. These are objects that provide the same API as your concrete
dependencies and provide tools for constructing responses to API calls and
assert that certain calls were made with certain arguments. A popular example
in Go is testify/mock.
In Java, you’ll often encounter Mockito. Mocks look
convenient: have the framework generate a mock for you, and then script the
interactions. I’m here to argue that the cost of the convenience is too high.

Mocks create brittle tests

Most mocking frameworks create an assertion on each mock API call and then
verify these assertions after a test. You may write a test like:

func TestAdd(t *testing.T) {
  dep1 := NewDepOneMock()
  dep2 := NewDepTwoMock()
  dut := GoodConstructor(dep1, dep2)
  
  dep1.On(t, "AddOne").Return(1)
  dep1.On(t, "AddOne").Return(2)
  
  dut.Add(2)
  assert.Equal(t, dut.Count(), 2)
}

What happens in dep1 gets a new function Add and you change your code to
use it? You break your test, even though no behavior in your code has
changed
. We shouldn’t be rewriting tests that have the exact same inputs and
expected outputs.

Mocks encourage tests that are too fine-grained

Because your mocked calls are dependent on the current state of the Device
Under Test, you’re also tempted to assert on the internal state of your dut.
These kinds of fine-grained tests are also fragile. You can’t modify the code
without modifying the test, even if the inputs and outputs are unchanged.
That’s unnecessary extra work.

Alternative: Use a Fake

Migrating to a “Fake” is probably the simplest way to move a unit tests off of
a brittle mock. A Fake is an implementation of the dependency that isn’t
“real.” It’s less complex to set up, perhaps, or it doesn’t require a network
connection. An example might be an on-disk implementation of the Amazon S3 API.
To the caller this is indistinguishable, but it’s much easier to use in tests.

Alternative: Don’t unit test calls to complex dependencies. Write integration and behavioral tests for multiple components

One reason we don’t use complex dependencies (even from other packages in the
same codebase) is to avoid testing the code of the dependencies. That shouldn’t
be a unit test’s responsibility.

But an integration test does care about the code in other packages. That’s
the right place to test complex interactions with dependencies.

What about Stubs?

In most definitions, a Stub is like a mock that doesn’t do assertions. This can
be less fragile than a mock – assertions don’t depend on , but it is still a
programmable double of a dependency. It has many of the same problems with
brittleness. Internal logic tests can break tests in the absence of observable
external changes.


Source link