DEV Community

Fernando Ocampo
Fernando Ocampo

Posted on

Testing a function that calls a goroutine

"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])
}


Enter fullscreen mode Exit fullscreen mode

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
}


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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)
    }
}


Enter fullscreen mode Exit fullscreen mode

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
}


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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()


Enter fullscreen mode Exit fullscreen mode

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
}


Enter fullscreen mode Exit fullscreen mode
  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
}


Enter fullscreen mode Exit fullscreen mode
  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
    ...


Enter fullscreen mode Exit fullscreen mode

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])
}


Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode




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

Top comments (2)

Collapse
 
fdsmora profile image
Fausto Salazar Mora • Edited

Hi, your code tests that a function in a go routine is called.
It would be interesting to know how to test that the function is called inside a go routine. I think that's harder.
e.g., if you remove the go in the go b.notify(ctx, newemployee) line, the test passes.
I'm figuring out how to do it so that the test fails in that case.

Collapse
 
rogeriotadim profile image
ROGERIO TADIM

I could not find difference between syncronous and assyncronous sequence diagrams. Is there a specific notation for that?