Testing Asynchronous Code In Go

Sometimes you may need to test that an asynchronous event has been triggered in your code but you can end up compromising on the quality of your tests when doing so.

This post will show one technique to test this behaviour whilst keeping your tests clean and not complicating your production code

It's assumed you're familiar with writing tests, interfaces, go routines, channels and select from Go.

The code to test

We are writing a service which has a dependency of a repo passed to it. Plain and simple DI for separation of concerns.

The service has a method FaveAndSave which takes a string and it will do some amazing business logic and then save to the repo.

type Repo interface {
    Save(string)
}

type Service struct {
    repo Repo
}

// FaveAndSave does something great with your string and saves it
func (s Service) FaveAndSave(x string) {
    go s.repo.Save("☆" + x)
}

The code calls the repo in a new go routine which makes testing a little tricky.

Let's test it

The benefit of using DI here is that we can now stub our Repo, make it behave how we like and see how it's called.

type repoStub struct{
    saveCalled bool
}

func (s *repoStub) Save(x string) {
    s.saveCalled = true
}

func TestItTriggersSave(t *testing.T) {
    repo := new(repoStub)
    service := Service{repo}

    service.FaveAndSave("Cat")

    if !repo.saveCalled {
        t.Error("Save was not called")
    }
}

This test fails wrongly because the save event is fired off in a separate go routine which ends up being executed after our assertion.

This means the saveCalled value is still set to false when we make our assertions.

Solution 1 - Sleepy time

func TestItTriggersSave(t *testing.T) {
    repo := new(repoStub)
    service := Service{repo}

    service.FaveAndSave("Cat")

    time.Sleep(10 * time.Millisecond)

    if !repo.saveCalled {
        t.Error("Save was not called")
    }
}

We can add a sleep into our test to let the go routine finish before making assertions. But there are a few problems:

  • The sleep length I put in is fairly arbitrary and it's hard to know precisely what value it should be. You dont want to set it to be too long because then your test will be slow, but too short will make the test flaky.
  • It's noise in our test. In this simple example it doesn't seem so bad but it is ultimately more cruft which we need to try and avoid.
  • Sleeps always feel yucky!

Solution 2 - Use a channel in our stubs

We can embrace the asynchronousness of our production code so we dont have to rely on guessing how long the stub takes to get called.

type repoStub struct{
    saveCalled chan bool
}

func newRepoStub() *repoStub {
    s := new(repoStub)
    s.saveCalled = make(chan bool)
    return s
}

func (s *repoStub) Save(x string) {
    s.saveCalled <- true
}

func TestItTriggersSave(t *testing.T) {
    repo := newRepoStub()
    service := Service{repo}

    service.FaveAndSave("Cat")

    if saved := <- repo.saveCalled; !saved {
        t.Error("Save was not called")
    }
}

Rather than setting a normal value we have a channel which gets written to when the stub is called. This means our test can wait on a value to appear on the channel, rather than using sleeps and hoping the value gets set.

This too has problems:

  • If the stub isn't called like we expect then the test will fail, but because of deadlock/test time out. This means our test output is less helpful then it should be.
  • The test now has channels and general asynchronous behaviour which is implementation detail that we dont really need to care about, like the sleeps it is noise.

Solution 3 - Make the stub's value "blocking" with a timeout

It seems these days many developers automatically recoil in disgust when you say "blocking" but it can often be simpler than the alternative.

I write a lot of Javascript in my day job and 99% of tests are littered with promise, done, return and generally a lot of asynchronous cruft because everything is asynchronous.

That can make writing tests quite taxing, especially if you are new to it. Common gotchas include tests not actually running assertions or tests running forever.

Let's keep our tests clean by putting just a little bit of code in to our stubs to make them block for the event we care about

type repoStub struct{
    saveCalled chan bool
}

func newRepoStub() *repoStub {
    s := new(repoStub)
    s.saveCalled = make(chan bool)
    return s
}

func (s *repoStub) Save(x string) {
    s.saveCalled <- true
}

func (s *repoStub) WasSaveCalled() bool {
    select {
    case <-s.saveCalled:
        return true
    case <- time.After(1 * time.Second):
        return false
    }
}

func TestItTriggersSave(t *testing.T) {
    repo := newRepoStub()
    service := Service{repo}

    service.FaveAndSave("Cat")

    if !repo.WasSaveCalled() {
        t.Error("Save was not called")
    }
}

We have moved the responsibility of "Has the stub been called in a timely manner?" to the stub rather than the test by simply adding the method WasSaveCalled.

The code takes advantage of Go's select syntax to block until the value is written or until the timeout occurs.

This feels like a good separation of concerns. Now if we look at the actual test we can see it reads very clearly and has no cruft around go routines or channels.

The code in the stub maybe looks a little involved but you will definitely move these into separate files, perhaps even go generate can be used to auto generate them like goautomock.

Summary

This post was motivated by some work I am doing where we are writing a "listener" to a RabbitMQ channel. The listener takes a chan Message, gets fired off in a go routine and processes messages as they come in to the system.

We went through similar iterations described above until we arrived at solution 3.

Is this idiomatic Go? I'm not sure I know what idiomatic Go is yet but this solution to me ticks a number of boxes I care about in writing software irrespective of the programming language.

  • Easy to read and write test code
  • Separation of concerns in regards to "has this function been called?"
  • Good test output on failure

Hopefully this has been interesting. Know a better way? Flame me on twitter @quii

Footnotes

For the sake of terseness this example just sees if Repo is called, but you can of course see what it was called with. In the real world Repo would probably have return values that we'd stub for tests too.

I like to pass t.Log to my stubs so they can log diagnostic info (such as "I timed out waiting for x"), it has the added bonus they only appear if the test fails