Or Ricon's Website

On the usefulness of stubs

01/01/2019

I'd like to introduce you to a pattern that I've been using in Go for quite a while now. This pattern has become my hammer and it makes everything look like a nail.

As a pre-requisite for the following discussion, let's set up an example scenario where we have the following:

// Publisher publishes data
type Publisher interface {
    Publish(b []byte) error
}

func doSomething(name string, p Publisher) error {
    // Compose message
    msg := []byte("Hello, " + name + "!")

    // Publish message
    return p.Publish(msg)
}

We're dealing with a function which constructs a message for the given name and uses the given Publisher to publish the message. The key thing to notice here is that the function takes an interface as input, rather than a direct implementation of a Publisher.

As the saying goes: "Take interfaces and return structs".

The pattern that I would like to show you is to stub your interface, as follows:

// StubPublisher is a stub of a Publisher
type StubPublisher struct {
  PublishFn func(b []byte) error
}

// Publish calls the underlying Publish method
func (p *StubPublisher) Publish(b []byte) error {
  return p.PublishFn(b)
}

To stub the interface, we create a StubPublisher struct and define a field for the Publish method called PublishFn. In this way, the value of PublishFn can be set and changed whenever we please, which allows greater flexibility than if we had hard-coded its implementation.

Now, bear with me. On its surface, this looks rather simple and there's a myriad of other ways to accomplish the same thing. But I hope that through the following examples I'll be able to convince you that this pattern is actually quite powerful and useful in a great variety of scenarios.

So without further ado, here are those scenarios.

1. Testing

The first, most common use-case for an interface stub is for testing. In our code, it's not uncommon to find functions that make use of interfaces to invoke some kind of functionality.

The Common Approach

In the common approach, a mock implementation is defined via a struct and is used in the tests. This works, although can have the side-effect of the struct collecting various pieces of functionality that are required by various tests, such as the times a method was called, params the method was called with or whether the method should return an error or not.

type mockPublisher struct {
    timesCalled int
    params      [][]byte
    errToReturn error
}

func (p *mockPublisher) Publish(b []byte) error {
    p.timesCalled++
    p.params = append(p.params, b)
    if p.errToReturn != nil {
        return p.errToReturn
    }
    return nil
}

func TestDoStuff(t *testing.T) {
    p := &mockPublisher{}

    if err := doStuff(p); err != nil {
        t.Fatalf("unexpected error: %s", err)
    }
}

Using a stub

In contrast, when using a stub, we're able to construct a minimal implementation designed for our test case. We don't need to carry over cruft from other tests.

func TestDoStuff(t *testing.T) {
    timesCalled := 0
    p := &StubPublisher{
        PublishFn: func(b []byte) error {
            timesCalled++
            return nil
        },
    }

    if err := doStuff(p); err != nil {
        t.Fatalf("unexpected error: %s", err)
    }
}

2. Decoration

Let's consider a common case of wanting to add some additional functionality to our code, such as reporting metrics.

The Common Approach

This approach tends to conflate the core implementation of the interface with the decorating behavior, e.g metrics, by implementing them alongside each other. In this example, we consider a Kafka publisher.

type KafkaPublisher struct {
    // Kafka related fields
}

func (p *KafkaPublisher) Publish(b []byte) error {
    // report metrics
    startTime := time.Now()
    defer func() {
        metrics.Time("publisher", time.Since(startTime))
    }()

    // do Kafka related stuff
}

Using a stub

In this approach, we keep the core implementation of the interface oblivious as to any side-effects its operation may cause, such as logging, metrics collection, etc. In this case, the Kafka publisher knows nothing about metrics.

func main() {
    p := NewKafkaPublisher(...)
    p = wrapPublisherWithMetrics(p)
}

func wrapPublisherWithMetrics(p Publisher) Publisher {
    return &StubPublisher{
        PublishFn: func(b []byte) error {
            startTime := time.Now()
            defer func() {
                metrics.Time("publisher", time.Since(startTime))
            }()

            return p.Publish(b)
        },
    }
}

3. Composability

Stubs make it easy to create ad-hoc compositions of interfaces without needing to create extra structs like MultiPublisher, etc.

func main() {
    p1 := NewPublisher(...) // Goes to one place
    p2 := NewPublisher(...) // Goes to another place

    p = composePublishers(p1, p2)
}

func composePublishers(ps ...Publisher) Publisher {
    return &StubPublisher{
        PublishFn: func(b []byte) error {
            for _, p := range ps {
                if err := p.Publish(); err != nil {
                    return err
                }
            }
            return nil
        },
    }
}

4.Separation of Concerns

Notice how in the above examples we separate the implementation of the interface from the actual usage of the interface. Aside from the fact that we are performing dependency-injection, we are ensuring that the user of the interface knows nothing about how the interface is implemented which means we can switch out the core implementation at any point in time.

5. Inlinability

Inlinability is the ability to set up a stub anywhere in our code.

The Common Approach

This is somewhat of a contrived example, but let's say there is a group of small behavior alterations we want to enact on our interface. The common approach is to pollute our code with additional struct definitions in order to implement these behavior changes.

func main() {
    p := NewPublisher()
    pp = &TwicePublisher{p: p}
}

type TwicePublisher struct {
    p Publisher
}

func (p *TwicePublisher) Publish(b []byte) error {
    for i := 0; i < 2; i++ {
        if err := p.p.Publish(b); err != nil {
            return err
        }
    }
    return nil
}

Using a stub

When using stubs, the fact that we don't need to bloat our code by creating a new struct for every slight behavior alteration we want to enact on our interfaces is empowering and means we are more likely to perform these alterations as they take almost no effort at all.

func main() {
    p := NewPublisher()

    pp = &StubPublisher{
        PublishFn: func(b []byte) error {
            for i := 0; i < 2; i++ {
                if err := p.p.Publish(b); err != nil {
                    return err
                }
            }
            return nil
        },
    }
}

Conclusion

I hope this was informative to you and that you've gained something, even if you don't necessarily like this pattern. This is one of my favorite patterns to use in Go. The other two are using the context properly and error groups.

Back to top