Golang 인터페이스와 단위 테스트
테스트 자동화Test automation의 일환으로 필자는 Golang으로 테스트 코드를 작성할 때 먼저 통합 테스트Integration Test[1]를 작성한다. 통합 테스트 코드를 작성하다 보면 주요 테스트 시나리오 외의 케이스를 촘촘하게 테스트 하기 어려운데 그 이유는 테스트 실행 비용(예. 웹 서버, 데이터베이스 등등)이 비싸고 비슷한 케이스를 작성하다 보면 코드 중복이 많이 발생하여 유지 보수가 어렵기 때문이다.
통합 테스트를 보완해 주는 것은 단위 테스트Unit Test다. 단위 테스트는 목 객체MockObject나 스텁Stub 같은 테스트 대역Test Double을 사용하여 의존하는 대상을 격리 시켜 테스트 대상만 독립적으로 테스트한다. 따라서 통합 테스트처럼 실행이 필요한 자원이 필요 없기 때문에 상대적으로 실행 비용이 저렴하고 속도가 빠르다. 또한 테스트 대상만 독릭적으로 테스트하기 때문에 촘촘한 테스트가 상대적으로 수월하다.
단위 테스트는 이상적으로 테스트 대상(=단위)이 의존하는 것에 대해 독립적(Independent)으로 작성 되어야 한다.(중략)독립적인 테스트 케이스를 작성 하기 위해서는 어떻게 해야 할까? 하나의 방법은 테스트 대상을 의존하는 것으로부터 격리(Isolation) 시키는 것이다. 프로그래밍 관점에서 격리 시킨다는 것은 테스트 대상이 의존하는 것을 실제가 아닌 다른 것으로 대체 하는 것이다. - 단위 테스트 케이스와 테스트 더블(Test Double)
이 글은 Golang 인터페이스를 이용한 단위 테스트하는 방법을 소개한다.
단위 테스트 대상
먼저 테스트 대상을 살펴보자. 아래 그림에서 붉은 색으로 표시한 AuthService가 테스트 대상이다. AuthService는 MemberService에 의존하고 있다.
코드는 아래와 같다. AuthService는 MemberService에게 아이디를 넘겨 Member를 조회하고 비밀번호를 확인 후에 JWT 토큰을 생성한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
type AuthService struct { } func (AuthService) AuthWithSignIdPassword(ctx context.Context, signIn dtos.MemberSignIn) (token security.JwtToken, err error) { memberEntity, err := member.MemberService{}.GetMemberBySignId(ctx, signIn.Id) if err != nil { return } err = memberEntity.ValidatePassword(signIn.Password) if err != nil { err = domain.ErrAuthentication return } token, err = security.JwtAuthentication{}.GenerateJwtToken(security.UserClaim{ Id: memberEntity.ID, Roles: memberEntity.GetRoleNames(), Permissions: memberEntity.GetPermissionNames(), }) return } //... package member type MemberService struct { } func (MemberService) GetMemberBySignId(ctx context.Context, signId string) (MemberEntity, error) { return memberRepository{}.FindBySignId(ctx, signId) }
강하게 결합된 코드가 만드는 격리의 어려움
AuthService는 MemberService 구조체(struct)로 직접 인스턴스를 생성하여 사용하고 있다.
1
member.MemberService{}
앞서 언급했듯이 AuthService 단위 테스트를 하기 위해서는 의존하는 대상(MemberService)으로 부터 격리해야 한다. 하지만 매우 강하게 결합되어 있어 현재 상태로는 테스트 대역으로 대체하기 어렵기 때문에 AuthService만 테스트하기 어렵다.
인터페이스를 통한 느슨한 결함
MemberService를 테스트 대역으로 대체하기 위해서는 강하게 결합된 부분을 느슨하게 만들 필요가 있다. 이 때 사용할 수 있는 것이 Golang 인터페이스이다.
코드를 바꿔보자. 기존 MemberService를 인터페이스로 변경하고 MemberServiceImpl를 추가로 만들어 인터페이스를 구현한다. Golang에서는 인터페이스 구현할 때 덕 타이핑Duck Typing을 사용한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
type MemberService interface { GetMemberBySignId(ctx context.Context, signId string) (MemberEntity, error) } type MemberServiceImpl struct { } func (MemberServiceImpl) GetMemberBySignId(ctx context.Context, signId string) (MemberEntity, error) { return memberRepository{}.FindBySignId(ctx, signId) } type AuthService struct { } func (s AuthService) AuthWithSignIdPassword(ctx context.Context, signIn dtos.MemberSignIn) (token security.JwtToken, err error) { // 인터페이스로 변경 var memberService member.MemberService = member.MemberServiceImpl{} memberEntity, err := memberService.GetMemberBySignId(ctx, signIn.Id) // ... }
의존성 주입Dependency Injection
이제 AuthService는 MemberService 인터페이스를 사용한다. 하지만 여전히 결합도가 높은데 그 이유는 인터페이스 구현체(MemberServiceImpl)를 직접 생성하고 있기 때문이다.
1
var memberService member.MemberService = member.MemberServiceImpl{}
느슨한 결합을 달성하기 위해서는 AuthService는 MemberService 인터페이스 구현체를 직접 생성하지 않아야 한다. 즉, 인터페이스만 알고 실제 구현체 무엇인지 몰라야 한다. 이 문제는 MemberService 인터페이스 구현체를 다른 곳에서 생성하여 AuthService에게 인터페이스로 넘겨줌으로써 해결 할 수 있다. 이렇게 다른 객체의 의존성을 제공하는 것을 ‘의존성 주입’라고 한다.
Golang으로 만든 의존성 주입 라이브러리가 여럿 있지만 사용법을 익히고 부가적인 코드를 작성하기 보다 시작은 단순하게 직접 작성해보자.
아래 코드를 보면 객체 생성 책임지는 팩토리Factory를 만들었다. 팩토리는 객체를 생성할 때 의존성 객체도 함께 생성한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
package factories func NewMemberService() member.MemberService { return member.MemberServiceImpl{} } func NewAuthService() auth.AuthService { return auth.AuthService{MemberService: NewMemberService()} } // ... package controllers type AuthController struct { } func (AuthController) AuthWithSignIdPassword(ctx echo.Context) error { // ... authService := factories.NewAuthService() jwtToken, err := authService.AuthWithSignIdPassword(ctx.Request().Context(), memberSignIn) // ... } // ... type AuthService struct { MemberService member.MemberService } func (s AuthService) AuthWithSignIdPassword(ctx context.Context, signIn dtos.MemberSignIn) (token security.JwtToken, err error) { memberEntity, err := s.MemberService.GetMemberBySignId(ctx, signIn.Id) // ... }
이제 AuthService에 의존성 객체를 직접 생성하지 않고 외부에서 주입 받는다. 인터페이스에만 의존하기 때문에 구현체를 테스트 대역(MockMemberService)으로 바꿔치기할 수 있다.
AuthService 단위 테스트
단위 테스트를 작성해 보자. 먼저 인터페이스를 구현한 테스트 대역(MockMemberService)을 만든다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
type MockMemberService struct { } func (MockMemberService) GetMemberBySignId(ctx context.Context, signId string) (MemberEntity, error) { if signId == "ymyoo" { return MemberEntity{ Model: gorm.Model{ ID: 1, }, Type: "site", SignId: "ymyoo", Name: "유영모", Password: "$2a$04$7Ca1ybGc4yFkcBnzK1C0qevHy/LSD7PuBbPQTZEs6tiNM4hAxSYiG", }, nil } else { return MemberEntity{}, domain.ErrNotFound } }
아래는 테스트 대역을 사용한 단위 테스트 케이스이다. GivenWhenThen 패턴을 사용해서 작성하였다. 유심히 볼 부분은 AuthService를 생성하는 부분이다. MockMemberService을 사용하고 있다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
func TestAuthService_AuthWithSignIdPassword(t *testing.T) { // given signId := "ymyoo" signIn := dtos.MemberSignIn{ Id: signId, Password: "123456", } // when authService := AuthService{MemberService: member.MockMemberService{}} token, err := authService.AuthWithSignIdPassword(nil, signIn) // then assert.Nil(t, err) assert.NotEmpty(t, token) }
GoMock
촘촘한 테스트를 위해 MockMemberService는 매개변수에 따라 다양한 값을 반환해야 한다. 예를 들면 아이디가 없다던지, 비밀번호가 틀리다던지 등이 있을 것이다. 문제는 이렇게 다양한 값을 반환하도록 MockMemberService에 코딩하는 것이 매우 귀찮기도 하거니와 케이스가 바뀔 때마다 코드를 바꾸어 주어야 하기 때문에 유지보수도 쉽지 않다.
GoMock은 Golang 라이브러리로써 Mock이나 Stub을 자동 생성해 준다. GoMock을 적용해 보자.
첫 번째로 아래 명령어로 라이브러리를 설치한다.
1
go get github.com/golang/mock/mockgen@v1.6.0
두 번째로는 mockgen 명령어로 MemberService의 Mock 코드를 생성한다.
1
mockgen -destination=mock_member_service.go -package=member . MemberService
세 번째로 기존 테스트 케이스를 아래 처럼 변경한다. 직접 만든 MockMemberService를 사용하지 않고 GoMock이 생성해준 코드(member.NewMockMemberService)로 변경한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
func TestAuthService_AuthWithSignIdPassword(t *testing.T) { // given signId := "ymyoo" signIn := dtos.MemberSignIn{ Id: signId, Password: "123456", } ctrl := gomock.NewController(t) defer ctrl.Finish() mockMemberService := member.NewMockMemberService(ctrl) mockMemberService. EXPECT(). GetMemberBySignId(gomock.Any(), gomock.Eq("ymyoo")). Return(member.MemberEntity{ Model: gorm.Model{ ID: 1, }, Type: "site", SignId: "ymyoo", Name: "유영모", Password: "$2a$04$7Ca1ybGc4yFkcBnzK1C0qevHy/LSD7PuBbPQTZEs6tiNM4hAxSYiG", }, nil) // when authService := AuthService{ MemberService: mockMemberService, } token, err := authService.AuthWithSignIdPassword(nil, signIn) // then assert.Nil(t, err) assert.NotEmpty(t, token) }
GoMock은 위의 코드에서 처럼 EXPECT로 함수와 매개변수에 따라 다양하게 반환 값을 설정할 수 있다.
주석
[1] 여기서 말하는 통합 테스트 코드는 별도의 목 객체MockObject이나 스텁Stub 같은 테스트 대역Test Double을 사용하지 않고 작성하는 테스트 코드를 말한다.