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 - there aren’t assertions that break if the number of calls change - 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.