본문 바로가기
Programming/Go

[GO 인증 구현 with JWT 4] User Repository, Service 구현 및 테스트

by TinKerBellBass 2022. 4. 13.
728x90
반응형

이전 글

 

[GO 인증 구현 with JWT 3] 인증 미들웨어 구현 및 테스트 코드 작성

이전 글 [GO 인증 구현 with JWT 1] 전체적인 인터페이스 작성 Use Case 간단히 구현해 볼 인증의 시나리오 유저 생성 → 생성된 유저 정보로 토큰 발급 → 발급된 토큰에 들어있는 유저ID 를 이용해서

tinkerbellbass.tistory.com

이제 남은 것은 애플케이션 관련 코드인 핸들러, 서비스 리포지토리와 메인 함수 구현만 남았다.

그전에 이전 코드에 변경 사항이 있는데,

생성자를 통해서만 구현체 인스턴스를 생성해서 사용할 수 있게 변경하였다.

생성자를 통해서만 인스턴스를 생성하게 강제해서 얻을 수 있는 이점은

인스턴스를 생성할 때 필드 값을 초기화해야 한다는 것을 상기시켜 준다.

그리고 이름 충돌(?) 문제로 AuthenticationMiddleware 메서드 이름을 StripTokenMiddleware 로 수정했다.

개발자를 위한 작명학원 차리면 장사 잘 되려나...

// 이전 코드
type AuthenticationMiddleware struct {
	secret string
}

func NewAuthentication(secret string) *AuthenticationMiddleware {
	return &AuthenticationMiddleware{secret: secret}
}

// 수정한 코드
type authenticationMiddleware struct {
	secret string
}

func NewAuthentication(secret string) *authenticationMiddleware {
	return &authenticationMiddleware{secret: secret}
}

 

 

남아있는 User 관련 리포지토리, 서비스를 구현할 것인데,

테스트를 어디까지 작성할 것인가 결정해야 한다.

실제 데이터베이스를 사용한다고 하면 외부에 의존해야 하는 테스트 코드를 작성할 확률이 높고,

데이터베이스에 영속화 하고 조회하고 하는 것은 단위 테스트보다는 통합 테스트에 더 어울리기 때문에

리포지토리 테스트는 패스하고,

핸들러도 비슷한 이유로 통합테스트에 더 적합하기에 패스하고,

업무 로직의 핵심인 서비스만 테스트 코드를 작성해 볼 것이다.

그리고 구현 순서는 개인적인 취향의 문제로 의존성의 마지막부터 역순인,

리포지토리, 서비스, 핸들러 순으로 구현할 것이다.

 

User Repository 구현

데이터베이스는 사용하지 않고 간단히 맵을 이용 해서 구현하였다.

Primary Key 인 ID 와 유저 정보를 저장하는 Users 맵으로 구조체를 만들고,

인터페이스에 맞춰서 영속화하고, PK 로 조회하고, User ID 로 조회하는 메서드를 구현하였다.

그리고 User ID 중복을 Save 메서드에서 체크할지 서비스 레이어에서 체크할지 고민했는데,

다음의 세 가지 이유로 서비스 레이어에서 처리하기로 결정했다.

1. 만약 실제 데이터베이스였다면 uniquie 를 걸어 놓을 것이라 에러를 던져주니 그걸 처리하면 될 것

2. User ID 가 동일한 유저는 존재하지 않는다는 것은 서비스 레벨의 문제

3. Save 메서드는 영속화 책임을 가지는 메서드

type userRepository struct {
	ID    int
	Users map[int]*domain.User
}

func NewUserRepository() UserRepositoryIF {
	return &userRepository{
		ID:    0,
		Users: make(map[int]*domain.User),
	}
}

func (u *userRepository) Save(user *domain.User) *domain.User {
	u.ID++
	user.ID = u.ID
	u.Users[u.ID] = user

	return user
}

func (u *userRepository) FindByID(id int) (*domain.User, error) {
	user := u.Users[id]
	if user == nil {
		return nil, errors.New(authentication.ErrUserNotFound)
	}
	return user, nil
}

func (u *userRepository) FindByUserID(userID string) (*domain.User, error) {
	for _, user := range u.Users {
		if user.UserID == userID {
			return user, nil
		}
	}
	return nil, errors.New(authentication.ErrUserNotFound)
}

 

User Service 테스트 케이스 작성

먼저 User Service 에 컴포지션 되어있는 User Repository 를 목킹하자.

고는 자바처럼 런타임에 생성되는 프락시 객체가 없기 때문에 직접 코드를 작성해야 한다.

목킹을 조금이라고 편하게 하기 위해 mockery 라는 목킹 라이브러리를 사용한다.

 

GitHub - vektra/mockery: A mock code autogenerator for Golang

A mock code autogenerator for Golang. Contribute to vektra/mockery development by creating an account on GitHub.

github.com

 

그리고 테스트를 묶을 수 있는 testify 의 Suite 를 사용했다.

 

GitHub - stretchr/testify: A toolkit with common assertions and mocks that plays nicely with the standard library

A toolkit with common assertions and mocks that plays nicely with the standard library - GitHub - stretchr/testify: A toolkit with common assertions and mocks that plays nicely with the standard li...

github.com

suite 를 사용하는 이점으로는 Junit5 의 BeforeEach, AfterEach 같은 전처리, 후처리 메서드를 사용할 수 있다는 것이다.

테스트 실행시마다 의존성 주입해서 인스턴스를 생성 등의 코드를 SetupTest 메서드에 넣고, 

테스트 종료 시마다 자원을 닫는 등의 코드를 TearDownTest 메서드에 넣어서 코드 중복을 줄일 수 있다.

SetupTest 에서는 User Repository 목 인스턴스를 생성하고,

목 인스턴스를 User Service 에 주입해서 User Service 인스턴스를 생성하게 하였고,

TearDownTest 에서는 테스트시에 지정한 목 리턴값을 테스트 후 초기화시켰다.

하나의 메서드에 여러 개의 테스트 코드를 작성하는 경우, 처음 지정한 목 리턴값이 계속 리턴되기 때문에

초기화시켜줘야 한다.

type UserServiceTestSuite struct {
	suite.Suite
	repo    *mocks.UserRepositoryIF
	service UserServiceIF
}

func (u *UserServiceTestSuite) SetupTest() {
	u.repo = new(mocks.UserRepositoryIF)
	u.service = NewUserService(u.repo, "secret")
}

func (u *UserServiceTestSuite) TearsDownTest() {
	u.repo.ExpectedCalls = nil
}

func (u *UserServiceTestSuite) TestSignUpSuccess() {
	u.T().Run("유저 등록에 성공하여 응답 정보가 반환될 것", func(t *testing.T) {
	})
}

func (u *UserServiceTestSuite) TestSignUpFailure() {
	u.T().Run("이미 존재하는 User 의 경우, 에러가 발생할 것", func(t *testing.T) {
	})
}

func (u *UserServiceTestSuite) TestSignInSuccess() {
	u.T().Run("로그인에 성공하여 토큰이 반환될 것", func(t *testing.T) {
	})
}

func (u *UserServiceTestSuite) TestSignInFailure() {
	u.T().Run("등록되어 있지 않는 유저 정보로 로그인하는 경우, 에러가 발생할 것", func(t *testing.T) {
	})
	u.T().Run("잘못된 패스워드로 로그인 하는 경우, 에러가 발생할 것", func(t *testing.T) {
	})
	u.T().Run("토큰 발급에 실패하는 경우, 에러가 발생할 것", func(t *testing.T) {
	})
}

func (u *UserServiceTestSuite) TestGetUserByIDSuccess() {
	u.T().Run("User ID 에 해당하는 유저가 존재하는 경우, 유저 정보가 반환될 것", func(t *testing.T) {
	})
}

func (u *UserServiceTestSuite) TestGetUserByIDFailure() {
	u.T().Run("UserID 에 해당하는 유저가 존재하지 않는 경우, 에러가 발생할 것", func(t *testing.T) {
	})
}

func TestUserService(t *testing.T) {
	suite.Run(t, new(UserServiceTestSuite))
}

 

User Service 구현

인터페이스와 테스트 케이스에 맞춰 User Service 를 구현해 보자.

특별할 것은 없고 자기 자신의 유저 정보를 조회하는 요청을 처리하는 메서드의 이름은

GetSelfUserByID 가 적절할지 잠시 고민했는데,

메서드만 봐서는 처리 내용이 Self User 조회라는 것을 알 수 없기도 하고

범용적으로 사용할 수 있는 유즈케이스가 아닐까 생각해서 GetUserByID 로 결정했다.

type userService struct {
	userRepository UserRepositoryIF
	secret         string
}

func NewUserService(userRepository UserRepositoryIF, secret string) UserServiceIF {
	return &userService{
		userRepository: userRepository, secret: secret,
	}
}

func (u *userService) SignUp(userDto UserDto) (UserResponse, error) {
	user := &domain.User{
		UserID:   userDto.UserID,
		Password: userDto.Password,
	}
	foundUser, _ := u.userRepository.FindByUserID(user.UserID)
	if foundUser != nil {
		return UserResponse{}, errors.New(authentication.ErrUserAlreadyExists)
	}

	persistedUser := u.userRepository.Save(user)

	return UserResponse{
		ID:     persistedUser.ID,
		UserID: persistedUser.UserID,
	}, nil
}

func (u *userService) SignIn(userDto UserDto) (string, error) {
	user, err := u.userRepository.FindByUserID(userDto.UserID)
	if err != nil {
		return "", err
	}

	if user.Password != userDto.Password {
		return "", errors.New(authentication.ErrInvalidPassword)
	}

	token, err := auth.GenerateToken(auth.NewClaim(user.UserID), "secret")
	if err != nil {
		return "", errors.New(authentication.ErrTokenGenerationFailed)
	}
	return token, nil
}

func (u *userService) GetUserByID(userID string) (UserResponse, error) {
	user, err := u.userRepository.FindByUserID(userID)
	if err != nil {
		return UserResponse{}, err
	}
	return UserResponse{
		ID:     user.ID,
		UserID: user.UserID,
	}, nil
}

 

User Service 테스트 구현

테스트 코드는 양이 많아서 성공 케이스 하나만 살펴보자.

mockery 로 목킹한 repository 메서드들의 행동을 정해주고 테스트 대상 메서드를 실행시키고 검증한다.

mock.MatchedBy 메서드는 구조체를 비교하기 위해 사용하였다.

리플렉션의 deepEqual 같은 것을 쓰면 되겠지만 리플렉션은 프레임워크를 만들지 않는 이상 사용하고 싶지 않다.

MatchedBy 의 파라미터는 fn interface{} 이므로 익명함수를 구현해서 넣으면 된다.

내부적으로 리플렉션을 사용해서 처리하고 있다.

익명함수의 파라미터는 행동을 정의하는 메서드, 여기서는 Save 메서드의 파라미터를 설정하면 되고,

리턴 값은 bool 을 넣어 일치하면 true, 일치하지 않으면 false 를 반환하도록 구현하면 된다.

func (u *UserServiceTestSuite) TestSignUpSuccess() {
	u.T().Run("유저 등록에 성공하여 응답 정보가 반환될 것", func(t *testing.T) {
		u.repo.On("FindByUserID", userID).Return(nil, errors.New(authentication.ErrUserNotFound))
		u.repo.On("Save",
			mock.MatchedBy(func(arg *domain.User) bool { return arg.UserID == userID && arg.Password == password })).
			Return(user)

		got, err := u.service.SignUp(userDto)
		if err != nil {
			t.Error(err)
		}

		assert.Equal(t, id, got.ID)
		assert.Equal(t, userID, got.UserID)
		u.repo.AssertCalled(t, "FindByUserID", userID)
		u.repo.AssertCalled(t, "Save",
			mock.MatchedBy(func(arg *domain.User) bool { return arg.UserID == userID && arg.Password == password }))
	})
}

 

이제 핸들러와 메인 함수, 그리고 포스트맨으로 테스트하는 것이 남아있는데, 이건 다음에 해야겠다.

전체 소스 코드는 https://github.com/jongwon-hyun/go_auth_jwt

 

다음 글

 

[GO 인증 구현 with JWT 5] Handler, Main 구현 및 통합 테스트

이전 글 [GO 인증 구현 with JWT 4] User Repository, Service 구현 및 테스트 이전 글 [GO 인증 구현 with JWT 3] 인증 미들웨어 구현 및 테스트 코드 작성 이전 글 [GO 인증 구현 with JWT 1] 전체적인 인터페..

tinkerbellbass.tistory.com

 

728x90
반응형

댓글