loading...

Testing a function that calls a goroutine

fernandoocampo profile image Fernando Ocampo ・5 min read

"If you are using a third party framework to generate SQL your TDD tests should check that the framework is being called correctly." - Uncle Bob

Problem

Creating unit tests where there is concurrency can become complicated if we do not take special care. In this article, we are going to show a simple scenario, but it could take us a few minutes to figure out how to do it. It occurs when we want to create an unit test for a function that internally calls another library asynchronously.

Let's look at the model of this scenario.

Alt Text

Alt Text

now take a look at the notification unit test

We created a basicService with the mock notifier and called the Add() function to verify that the expected message has been sent to the emailNotifierMock.

import (
    "context"
    "testing"

    "github.com/group/project/pkg/employeeapp"
    "github.com/stretchr/testify/assert"
)

func TestEmailNotification(t *testing.T) {
    // GIVEN
    newEmployee := employeeapp.Employee{
        FirstName: "Daenerys",
        LastName:  "Targaryen",
        Email:     "someone@somewhere.com",
        Salary:    25000,
    }
    expectedMessage := employeeapp.Message{
        Subject: "Hey",
        To:      "someone@somewhere.com",
        From:    "anybody@somewhere.com",
        Body:    "Good morning there",
    }
    ctx := context.TODO()
    emailNotifier := NewEmailNotifierMock()
    basicService := employeeapp.NewBasicService(emailNotifier)

    // WHEN
    _, err := basicService.Add(ctx, newEmployee)
    // THEN
    if err != nil {
        t.Errorf("no error was expected but got: %q", err)
    }

    assert.Equal(t, expectedMessage, emailNotifier.messages[0])
}

For the notifier dependency, we have created emailNotifierMock below.

// emailNotifierMock is a Notifier mock that track
// received messages
type emailNotifierMock struct {
    messages []employeeapp.Message
}

func NewEmailNotifierMock() *emailNotifierMock {
    return &emailNotifierMock{
        messages: make([]employeeapp.Message, 0),
    }
}

// Notify adds the given message to the messages map contained in the mock.
func (e *emailNotifierMock) Notify(ctx context.Context, message employeeapp.Message) error {
    e.messages = append(e.messages, message)
    return nil
}

If you run the unit test, the result will be successful.

go test -timeout 30s github.com/group/project/pkg/employeeapp -run ^TestEmailNotification$

ok      github.com/group/project/pkg/employeeapp

now take a look at the synchronous Add() business function. Please see that Notify() call is synchronous.

// Add validates and save a new employee.
func (b *basicService) Add(ctx context.Context, newemployee Employee) (string, error) {
    newemployee.ID = uuid.New().String()
    // do some business logic
    b.notify(ctx, newemployee)
    return newemployee.ID, nil
}

// notify notifies to the employee using the given notifier.
func (b *basicService) notify(ctx context.Context, employee Employee) {
    if b.notifier == nil {
        return
    }
    newMessage := Message{
        Subject: "Hey",
        To:      employee.Email,
        From:    "anybody@somewhere.com",
        Body:    "Good morning there",
    }
    notifierErr := b.notifier.Notify(ctx, newMessage)
    if notifierErr != nil {
        log.Printf("unexpected error: %s sending message %+v to %q ", notifierErr, newMessage, employee.Email)
    }
}

We now receive a request from the tech leader requesting to call the Notify() function asynchronously.

Alt Text

Then we go to basicService logic and create a goroutine to call the internal notify function.

// Add validates and save a new employee.
func (b *basicService) Add(ctx context.Context, newemployee Employee) (string, error) {
    newemployee.ID = uuid.New().String()
    // do some business logic
    go b.notify(ctx, newemployee)
    return newemployee.ID, nil
}

Now we try to run our unit test, but wait, it does not work anymore.

go test -timeout 30s github.com/group/project/pkg/employeeapp -run ^TestEmailNotification$

--- FAIL: TestEmailNotification (0.00s)
panic: runtime error: index out of range [0] with length 0 [recovered]
    panic: runtime error: index out of range [0] with length 0

and it becomes worst when we add the -race flag.

go test -race -timeout 30s github.com/group/project/pkg/employeeapp -run ^TestEmailNotification$
--- FAIL: TestEmailNotification (0.00s)
==================
WARNING: DATA RACE
Write at 0x00c0000932d0 by goroutine 9:
  github.com/group/project/pkg/employeeapp_test.(*emailNotifierMock).Notify()

Well that's the reason of this article.

Solution

... One way to solve this could be to follow my proposal without changing our implementation.

  1. Add a sync.WaitGroup and a sync.Mutex attributes in the Notifier mock.
// emailNotifierMock is a domain.Notifier mock that track
// received messages
type emailNotifierMock struct {
    err      error
    messages []employeeapp.Message
    mu       sync.Mutex
    wg       sync.WaitGroup
}
  1. In the Notify() function within notifier mock we add a defer wg.Done() statement. Here we have to avoid race conditions on the messages slice, because we don't know how many goroutines could try to push items into it. That's the reason of the sync.Mutex attribute and the Lock() and Unlock() calls.
// Notify adds the given message to the messages map contained in the mock.
func (e *emailNotifierMock) Notify(ctx context.Context, message employeeapp.Message) error {
    defer e.wg.Done()
    if e.err != nil {
        return e.err
    }
    e.mu.Lock()
    e.messages = append(e.messages, message)
    e.mu.Unlock()
    return nil
}
  1. In the unit test before calling the Add() function, we add the emailNotifier.wg.Add(1) statement and after calling the Add() function we add the following also emailNotifier.wg.Wait().
    ...
    // WHEN
    emailNotifier.wg.Add(1)
    _, err := basicService.Add(ctx, newEmployee)
    emailNotifier.wg.Wait()
    // THEN
    ...

obtaining this result.

import (
    "context"
    "sync"
    "testing"

    "github.com/group/project/pkg/employeeapp"
    "github.com/stretchr/testify/assert"
)

func TestEmailNotification(t *testing.T) {
    // GIVEN
    newEmployee := employeeapp.Employee{
        FirstName: "Daenerys",
        LastName:  "Targaryen",
        Email:     "someone@somewhere.com",
        Salary:    25000,
    }
    expectedMessage := employeeapp.Message{
        Subject: "Hey",
        To:      "someone@somewhere.com",
        From:    "anybody@somewhere.com",
        Body:    "Good morning there",
    }
    ctx := context.TODO()
    emailNotifier := NewEmailNotifierMock()
    basicService := employeeapp.NewBasicService(emailNotifier)

    // WHEN
    emailNotifier.wg.Add(1)
    _, err := basicService.Add(ctx, newEmployee)
    emailNotifier.wg.Wait()
    // THEN
    if err != nil {
        t.Errorf("no error was expected but got: %q", err)
    }

    assert.Equal(t, expectedMessage, emailNotifier.messages[0])
}

now let's check the unit test again.

go test -race -timeout 30s github.com/group/project/pkg/employeeapp -run ^TestEmailNotification$
ok      github.com/group/project/pkg/employeeapp

Conclusion

  1. Always make your code testeable. In the above scenario, the notifier has a well-known interface and even internally in the service, we are calling a private function.
  2. The interfaces go a long way in making the code testable.
  3. As much as possible, just test the public or visible features. Try not to make private functions part of your base code contract. That will give you flexibility in the future. Otherwise, feel free to test them with special care.
  4. sync.WaitGroup library help us to wait for goroutines.
  5. Mocks provide the opportunity to have control over the behavior of the code.
  6. Remember to use go test -race if the unit test involves goroutines.

Acknowledgement

Posted on by:

fernandoocampo profile

Fernando Ocampo

@fernandoocampo

Constant learner, Passionate developer, Software craftsman, good practices advocate, Rider

Discussion

pic
Editor guide