DEV Community

Julian-Chu
Julian-Chu

Posted on

[Go] testing tips part 2: write flexible mock object for test cases without library

In last previous article, we refactored our simple AuthService with dependency injection, and added first unit test with mock object.

The code, we write so far.

Our business logic

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

Our test code

func TestAuthService_LoginSucceed(t *testing.T) {
    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(username, password string) (bool, error) {
    return true, nil
}

It looks like everything is fine, so now we are gonna to add a new test case for LoginFailed.

func TestAuthService_LoginFailed(t *testing.T) {
    s := NewAuthService(&mockPasswordService{})
    ok, err := s.Login("testuser", "wrongPwd")
    if err != nil {
        t.Error(err)
    }

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

When we execute the tests, the new test case is failed because of the hardcoded mockPasswordService. Maybe you consider we could add another mock like mockPasswordService2 in the following code:

func TestAuthService_LoginFailed(t *testing.T) {
    s := NewAuthService(&mockPasswordService2{})
    ok, err := s.Login("testuser", "wrongPwd")
    if err != nil {
        t.Error(err)
    }

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

type mockPasswordService2 struct {
}

func (m mockPasswordService2) verify(string, string) (bool, error) {
    return false, nil
}

Of course, it works well, but the code looks like duplicates, and not flexible for different scenarios. For example, when a new test case asks verify function to return an error, we have to add something similar again, then leads to more duplicates.

How can we write flexible mock objects? Let's try to do it without library, then do it again with library.

Doing without library

Remember what we want to mock? The return of verify function,
so here we will use one very nice feature in Go, "functions are first class".

First of all, we add a public func member, which has the same return type as verify function, in mockPasswordService struct, and second, we call the func member in verify funcion. Finally we assign a function to this member in test case.

// Before
type mockPasswordService struct {
}

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

//After
type mockPasswordService struct {
    VerifyFunc func() (bool,error)
}

func newMockPasswordService() *mockPasswordService {
    return &mockPasswordService{}
}

func (m mockPasswordService) verify(username, password string) (bool, error) {
    return m.VerifyFunc()
}

Of course you can use private member, then inject the VerifyFunc from constructor.

The benefit is that we can change the behavior of mock object in our test case for different scenarios.

func TestAuthService_LoginSucceed(t *testing.T) {
    mockPasswordService := newMockPasswordService()
    mockPasswordService.VerifyFunc = func() (bool, error) {
        return true, nil
    }
    s := NewAuthService(mockPasswordService)
    ok, err := s.Login("testuser", "testpwd")
    if err != nil {
        t.Error(err)
    }

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

func TestAuthService_LoginFailed(t *testing.T) {
    mockPasswordService := newMockPasswordService()
    mockPasswordService.VerifyFunc = func() (bool, error) {
        return false, nil
    }
    s := NewAuthService(mockPasswordService)
    ok, err := s.Login("testuser", "wrongPwd")
    if err != nil {
        t.Error(err)
    }

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

If we are gonna to add 3rd test case like LoginFailedWithError, we don't have to touch the mock type anymore, and easy to assign the behavior of mock object in the test case.

Conclusion

We can use "function as first class" to make flexible mock objects, it works much better than hard-coded mock object. But for complex scenarios or not so small project, using the mock library is the best choice to reduce our work. We will do it in the next article.

Top comments (0)