DEV Community

Julian-Chu
Julian-Chu

Posted on • Edited on

[Go] testing tips part 1: isolate your unit tests from external dependency with DI

Series about testing tips in go

I'd like to write a series of articles about testing tips in go, but not from scratch. DI, mock, subtests are planned to cover in this series. If any topic you're interested, just write down your comment.

let's start with a simple scenario:

There is a AuthService in your system to handle user login, you'd like to write unit tests for CI.


///login.go
package auth

type AuthService struct {
}

func NewAuthService() *AuthService {
    return &AuthService{}
}

func (s AuthService) Login(username, pwd string) (bool, error) {
    pwdIsValid, err := s.verifyPassword(username, pwd)
    if err != nil {
        return false, err
    }

    if pwdIsValid {
        return false, nil
    }

    return true, nil
}

// External dependency
func (s AuthService) verifyPassword(username, pwd string) (bool, error) {
    // connect to Database to verify username and password
    panic("not implemented")
}
/// login_test.go
func TestAuthService_LoginSucceed(t *testing.T) {
    s := NewAuthService()
    ok, err := s.Login("testuser", "testpwd")
    if err != nil {
        t.Error(err)
    }

    if !ok {
        t.Error("login failed")
    }
}

After writing first test case, you find the function verifyPassword(..) has external dependency to database. The issue is you can't control what database returns, or you need to make more effort to write integration test to setup and clean database. In order to write unit test here, we have to simulate the return from database. The external dependency is hard coded inside, how can we simulate it?

Dependency injection

The external dependency is hard coded inside and difficult to simulate, so the dependency must be easy to exchange, then we could control it easily.
That's what we need DI(dependency injection). The selected dependency are injected when the AuthService instance is created.

Alt Text

Refactoring code to DI

At the next step, we have to add an interface IPassword, which is embedded in our AuthService, to replace verifyPassword(...).

///login.go
type AuthService struct {
    pwd IPassword
}

func NewAuthService(IPassword IPassword) *AuthService {
    return &AuthService{pwd: IPassword}
}

func (s AuthService) Login(username, pwd string) (bool, error) {
    pwdIsValid, err := s.pwd.verify(username, pwd)
    if err != nil {
        return false, err
    }
    if !pwdIsValid {
        return false, nil
    }
    return true, nil
}

type IPassword interface {
    verify(string, string) (bool, error)
}

Then we could write first mock for our dependency

/// login_test.go
func TestAuthService_LoginSucceed(t *testing.T) {
        // the mock dependency is injected when creating AuthService 
    s := NewAuthService(&mockPasswordService{})
    ok, err := s.Login("testuser", "testpwd")
    if err != nil {
        t.Error(err)
    }

    if !ok {
        t.Error("login failed")
    }
}

type mockPasswordService struct {
}

func (m mockPasswordService) verify(string, string) (bool, error) {
    return true, nil
}

Now our first test case should be passed.

Problem!

You could find in the mockPasswordService, the returned value of verify(...) is hard coded, so when we have to add more test cases, like verify() returns error or false, should we write more mock structs for each scenario?
Of course you should not do this. In next article, I will show to write mock effectively w/o DI framework.

Top comments (0)