들어가기에 앞서

이번 프로젝트에서 중요하게 여겼던 점 중 하나는 백엔드 아키텍쳐에 관한 것이었는데, 객체 지향 SOLID 원칙 및 clean architecture의 기저에 있는 원칙들을 지켜가며 코드를 작성하려고 노력했다. 구조화된 코드를 작성하는 이유는 두 가지이다.

1. 유지 보수 용이성

어차피 혼자 진행하는 프로젝트이고 따라서 전체적인 어플리케이션 구조부터 코드레벨의 디테일까지 다 알고 있는 상태기 때문에, 코드의 전체적인 가독성이나 코드의 확장성 같은 것들은 아키텍쳐를 통해 크게 이득을 봤다고 말하긴 힘들 것 같다. 하지만 좋은 코드를 작성하기 위해 고민하고 노력했다는 것만으로 충분한 의미를 찾을 수 있을 것 같고, 만족스러운 경험이었다고 생각한다.

2. 테스트 용이성

(어쩌면 2번은 1번에 포함되는 것일 수도 있겠지만, 테스트의 중요성을 강조하기 위해 따로 적어보았다.) 아키텍쳐를 짜고, 코드를 기능별로 엄격히 분리하면서 얻을 수 있는 또 하나의 장점은 바로 유닛테스트를 하기가 쉬워진다는 것이다. 내가 테스트해야 할 코드에 외부로부터 request를 받아오는 부분, 어플리케이션 내 business logic을 관리하는 부분, DB에 직접적으로 접근하여 데이터를 조회/저장하는 부분 등이 모두 하나의 function에 작성되어 있다고 생각하면 이를 유닛테스트하기란 정말 힘들 것이다.(사실 이러한 코드를 테스트하는 것이 과연 '유닛' 테스트라고 불릴 만 한지도 모르겠다.) 그래서 이번 프로젝트에서는 기능별로 엄격하게 코드를 분리하고 loosely coupled한 구조를 최대한 지향해서 코드를 작성하고, 이를 JUnit을 통해 유닛테스트 해보는 것까지 함께 진행해보았다.

구조

테스트 디렉토리의 구조는 프로덕션 디렉토리의 구조와 대체로 흡사하다.

(production directory 구조)

(test directory 구조)

productino directory에 config 디렉토리는 설정파일을 담아두는 곳이기 때문에 테스트에서는 생략했고, entity 테스트는 중요한 부분이지만 boiler plate 코드를 많이 작성하게 될 것 같아 지금 단계에서는 생략하게 되었다. (쉽게 말하면 귀찮아서..)

Controller

Controller 단에서 테스트 코드는 MockMvc 객체를 이용하여 주로 이루어진다. Controller layer는 request를 받고 response를 보내는 역할을 하는 layer기 때문에 이 부분에 집중해서 테스트를 진행해야 한다.

    @Test
    @Order(1)
    fun registerTest() {
        val user = User(null, "BaekGeunYoung", "qwerqwer", "firstName", "lastName",
            mutableSetOf(Role.ADMIN), Skin.DARK, mutableSetOf(Color(1, 2, 3), Color(127, 128, 17)))
        
        val requestBody = writer.writeValueAsString(user)

        mockMvc.perform(
                post("/api/v1/user/register")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(requestBody))
                .andExpect(status().isOk)
                .andExpect(jsonPath("username").value("BaekGeunYoung"))
                .andExpect(jsonPath("firstName").value("firstName"))
                .andExpect(jsonPath("lastName").value("lastName"))
    }

위의 코드는 회원가입을 테스트 해보는 controller test 코드이다. 적절한 엔드포인트와 request header 및 body를 설정하고 mockMvc를 통해 mocking된 request를 보낸 후, 우리가 기대하는 대로 response가 잘 돌아오는 지를 테스트해 본다. 아래는 이어서 로그인 테스트를 하는 코드이다.

    @Test
    @Order(2)
    fun loginTest() {
        //로그인 테스트
        //1. 로그인 성공 테스트
        var loginInfo = UserLoginRequest("BaekGeunYoung", "qwerqwer")
        var req = writer.writeValueAsString(loginInfo)

        mockMvc.perform(
                post("/api/v1/user/login")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(req))
                .andExpect(status().isOk)

        //2. 로그인 실패 테스트
        loginInfo = UserLoginRequest("BaekGeunYoung", "qwerqwerqwerqwer")
        req = writer.writeValueAsString(loginInfo)

        mockMvc.perform(
                post("/api/v1/user/login")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(req))
                .andExpect(status().isForbidden)

        loginInfo = UserLoginRequest("BaekGeun", "qwer")
        req = writer.writeValueAsString(loginInfo)

        mockMvc.perform(
                post("/api/v1/user/login")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(req))
                .andExpect(status().isForbidden)
    }

아이디와 비밀번호를 적절하게 설정한 경우는 status를 200으로, 그렇지 않은 경우는 status를 403으로 기대하여 테스트를 진행했다.

++ 추가로, registerTest()에서 사용자 등록을 하고 난 다음에 loginTest()로 로그인 테스트를 해야하기 때문에 테스트 코드를 실행하는 순서를 신경써야 했다. default는 두 함수가 병렬적으로 실행되지만, 순서를 지정해주기 위해 Order라는 annotation을 사용했다.

service

service layer에는 어플리케이션 내의 business logic를 담았다. 그래서 이 business logic의 타당성을 검증할 만한 구체적인 상황에 대한 테스트가 이루어져야 하며, mockito라는 라이브러리를 통해 외부에서 주입받은 의존성 객체들을 적절히 mocking할 수 있다.

    private val user = User(null, "BaekGeunYoung", "qwerqwer", "firstName", "lastName",
            mutableSetOf(Role.ADMIN), Skin.DARK, mutableSetOf(Color(1, 2, 3), Color(127, 128, 17)))
    @Test
    fun signUpSuccessTest() {
        Mockito.`when`(bCryptPasswordEncoder!!.encode(anyString())).thenReturn("qwerqwer")
        Mockito.`when`(userRepository!!.save(user)).thenReturn(user)

        val result = userService!!.signUp(user)

        assertThat(result).isNotNull
        assertThat(result.firstName).isEqualTo(user.firstName)
        assertThat(result.lastName).isEqualTo(user.lastName)
        assertThat(result.username).isEqualTo(user.username)
    }

mockito를 통해 bCryptPasswordEncoder 객체의 encode 함수와 userRepositoy 객체의 save 함수를 적절히 모킹한다. 그리고 나서 userService의 signUp 함수를 실행하면 이 함수의 실행과정에서 encode 함수와 save 함수를 만날 때마다 우리가 모킹했던 내용으로 작동할 것이다.

    @Test
    fun signInTest() {
        given(userRepository!!.findByUsername(anyString())).willReturn(user)

        val authenticator = UsernamePasswordAuthenticationToken(user.username, user.password)
        given(authenticationManager!!.authenticate(any())).willReturn(authenticator)

        val result = userService!!.signIn(user.username, user.password, authenticationManager, jwtTokenProvider!!)

        assertThat(result).isNotNull
        assertThat(result["username"]).isEqualTo(user.username)
        assertThat(result["token"]).isNotNull
    }

signIn 함수의 경우 username과 password를 적절히 검사하는 부분에서 authenticationManager를 사용하고, 토큰을 발급하는 부분에서 jwtTokenProvider를 사용하기 때문에 이들을(필요하다면 적절히 mocking해서) 의존성 주입해주어야 한다.

Repository

repository는 기본적으로 JpaRepository 객체를 상속받아 구현했기 때문에 기능에 문제가 있으리라 생각되진 않았다. 그래서 JpaRepositoy가 제공하는 기본적인 함수들에 대한 테스트는 생략하고, 내가 따로 작성했던 함수들에 대해서만 테스트를 진행하였다.

class UserRepositoryTest(
        @Autowired private val userRepository: UserRepository
) {
    private val user = User(null, "BaekGeunYoung", "qwerqwer", "firstName", "lastName",
            mutableSetOf(Role.ADMIN), Skin.DARK, mutableSetOf(Color(1, 2, 3), Color(127, 128, 17)))

    @BeforeAll
    fun setUp() {
        userRepository.save(user)
    }

    @Test
    fun findByUsername_success() {
        val result = userRepository.findByUsername(user.username)
        assertThat(result).isNotNull
    }

    @Test
    fun findByUsername_fail() {
        val result = userRepository.findByUsername("qweqwe")
        assertThat(result).isEqualTo(null)
    }
}

내가 userRepository에서 따로 추가했던 함수는 findByUsername 함수이므로, 이에 대한 테스트를 진행했다. 우선 setUp함수에서 user 하나를 DB에 넣어주고, findByUsername을 통해 이 user를 잘 찾아내는지 테스트했다.

결론

아키텍쳐의 도움을 받아 기능별로 엄격히 분리된 프로덕션 코드는 유닛테스트를 유닛테스트답게 만든다. 그리고 관련있는 layer들이 최대한 loose하게 연결되어 있을 때 테스트 코드의 작성은 한 결 쉬워진다. 모든 테스트들이 독립적으로 자기가 관장하는 부분만 엄격히 테스트하면 되기 때문에 전체적인 유닛테스트의 커버리지 또한 높을 것이라고 예상할 수 있고, 다른 사람이 작성한 테스트 코드를 읽을 때도 굉장히 편할 것이라고 생각한다.

profile
서울대학교 컴퓨터공학부 github.com/BaekGeunYoung

0개의 댓글