Go's "object-orientation" approach is through interfaces. Interfaces provide a way of specifying the behavior expected of an object, but rather than saying what an object itself can do, they specify what's expected of an object. If any object meets the interface specification it can be used anywhere that interface is expected.
I was working on a new, small piece of software that does image compression for CloudFlare and found a nice use for interfaces when stubbing out a complex piece of code in the unit test suite. Central to this code is a collection of goroutines that run jobs. Jobs are provided from a priority queue and performed in priority order.
The jobs ask for images to be compressed in myriad ways and the actual package that does the work contained complex code for compressing JPEGs, GIFs and PNGs. It had its own unit tests that checked that the compression worked as expected.
But I wanted a way to test the part of the code that runs the jobs (and, itself, doesn't actually know what the jobs do). Because I only want to test if the jobs got run correctly (and not the compression) I don't want to have to create (and configure) the complex job type that gets used when the code really runs.
What I wanted was a DummyJob
.
The Worker
package actually runs jobs in a goroutine like this:
func (w *Worker) do(id int, ready chan int) { for { ready <- id
j, ok := <-w.In
if !ok {
return
}
if err := j.Do(); err != nil {
logger.Printf("Error performing job %v: %s", j, err)
}
}
}
do
gets started as a goroutine passed a unique ID (the id
parameter) and a channel called ready
. Whenever do
is able to perform work it sends a message containing its id
down ready
and then waits for a job on the worker w.In
channel. Many such workers run concurrently and a separate goroutine pulls the IDs of workers that are ready for work from the ready
channel and sends them work.
If you look at do
above you'll see that the job (stored in j
) is only required to offer a single method:
func (j *CompressionJob) Do() error
The worker's do
just calls the job's Do
function and checks for an error return. But the code originally had w.In
defined like this:
w := &Worker{In: make(chan *job.CompressionJob)}
which would have required that the test suite for Worker
know how to create a CompressionJob
and make it runnable. Instead I defined a new interface like this:
type Job interface { Priority() int Do() error }
The Priority
method is used by the queueing mechanism to figure out the order in which jobs should be run. Then all I needed to do was change the creation of the Worker
to
w := &Worker{In: make(chan job.Job)}
The w.In
channel is no longer a channel of CompressionJob
s, but of interfaces of type Job
. This shows a really powerful aspect of Go: anything that meets the Job
interface can be sent down that channel and only a tiny amount of code had to be changed to use an interface instead of the more 'concrete' type CompressionJob
.
Then in the unit test suite for Worker
I was able to create a DummyJob
like this:
var Done bool
type DummyJob struct { }
func (j DummyJob) Priority() int { return 1 }
func (j DummyJob) Do() error { Done = true return nil }
It sets a Done
flag when the Worker
's do
function actually runs the DummyJob
. Since DummyJob
meets the Job
interface it can be sent down the w.In
channel to a Worker
for processing.
Creating that Job
interface totally isolated the interface that the Worker
needs to be able to run jobs and hides any of the other details greatly simplifying the unit test suite. Most interesting of all, no changes at all were needed to CompressionJob
to achieve this.