MSA Phase 6. TDD(1) - Unit Test

devty·2023년 9월 19일
1

MSA

목록 보기
7/14
post-thumbnail

서론

사용하게 된 계기

  • 사실 이 부분은 MSA가 아니고 Monolithic Arch에서도 많이 사용하는 기법이다.
  • 처음 도입을 생각했던 건 그 전에 했던 프로젝트에서는 모놀로식 아키텍처를 사용하였는데, 그 프로젝트가 커져가는 과정중에 운영서버에 배포 후 테스트하는 것이 매우 어려웠다.
    • 배후 후에 테스트를 postman을 이용하여 일일이 확인하는 방식으로 하였다.
  • 서비스가 커져가면서 테스트를 해봐야하는 것들이 많아졌는데 모든걸 다 테스트하기엔 개발자에 대한 리소스가 상당히 많이 잡아먹었다.
  • 그래서 이러한 부분을 해소해주기위해 테스트 코드를 작성하여 굳이 postman을 이용하지 않고 테스팅을 한번에 할수 있는 방법인 TDD를 채택하게 되었다.

본론

TDD란?

  • TDD(Test-Driven Development)는 소프트웨어 개발 방법론 중 하나로, 테스트가 개발 과정을 이끌어 나가는 방식이다.
  • TDD는 아래와 같은 기본적인 사이클로 진행된다.
    1. 빨간색(Red) → 먼저, 개발자는 실패하는 단위 테스트를 작성한다. 이 단계에서는 아직 구현되지 않은 기능에 대한 테스트를 작성하기 때문에 테스트는 실패할 것이다.
    2. 초록색(Green) → 그다음, 개발자는 테스트를 통과시킬 수 있도록 최소한의 코드만 작성한다. 이 단계에서는 테스트가 통과하는 것이 목표이므로 아직 코드가 완벽하지 않을 수 있다.
    3. 노란색(Yellow) → 테스트가 통과하면, 개발자는 코드를 정리하고 개선하여 코드의 품질을 높임. 이 단계에서는 기능 추가보다는 코드의 가독성이나 유지보수성을 개선하는 작업이 이루어진.
  • 중요한 것은 실패하는 테스트 코드를 작성할 때까지 실제 코드를 작성하지 않는 것과, 실패하는 테스트를 통과할 정도의 최소 실제 코드를 작성해야 하는 것이다.
  • 이를 통해, 실제 코드에 대해 기대되는 바를 보다 명확하게 정의함으로써 불필요한 설계를 피할 수 있고, 정확한 요구 사항에 집중할 수 있다.

일방적인 개발 방식과 TDD 개발 방식의 차이

  • WFD (Waterfall Dev)
    • 요구사항 분석 → 설계 → 개발 → 테스트 → 배포 → 유지보수
    • 이러한 Flow는 단점이 있다.
      1. 테스트 단계가 매우 늦게 진행되기 때문에 버그나 이슈를 찾는데 시간이 많이 걸린다.
      2. 요구사항 분석 단계, 설계 단계에서 개발자가 직접적으로 해야할 부분이 없다.
        • 따라서, 해당 부분에 테스트 코드를 먼저 짜고 있는 것이 개발을 효율적으로 할 수 있다.
    • 따라서 이러한 단점을 극복하기 위해 TDD가 탄생하였다.
  • TDD (Test-Driven Dev)
    • 요구사항 분석 → 테스트 케이스 작성 → 개발 → 테스트 실행 → 리팩토 → 배포 → 유지보수
  • 아래는 WFD, TDD Flow Chart이다.
    • 그림을 보면 알수 있듯이 왼쪽이 TDD이고, 오른쪽이 WFD이다.
    • 딱 이미지만 봐도 TDD가 더 견고한 Flow를 가지고 있지 않는가.
    • 하지만 이러한 TDD에도 단점이 존재하는데 아래에서 설명하겠다.

TDD에 장점, 단점

  • 장점
    1. 각 단위 기능에 대해 테스트를 진행하면서 코드의 신뢰도를 높일 수 있다.
    2. 나중에 코드 변경이 필요할 때, 테스트 케이스가 있으면 더 안전하고 빠르게 변경할 수 있다.
    3. 초기 단계에서 버그를 발견하고 수정할 수 있으므로, 시간과 비용을 절약할 수 있다.
    4. 안정적인 테스트 케이스가 있다면, 코드 리팩토링을 더 쉽게 진행할 수 있다.
    5. 개발자가 코드를 작성하면서 바로 피드백을 받을 수 있어, 문제를 빨리 발견하고 수정할 수 있다.
  • 단점
    1. 테스트 케이스 작성에 대한 러닝과 프레임워크 설정 등 초기 비용이 상당히 높을 수 있다.
    2. 모든 코드에 대한 테스트 케이스를 먼저 작성해야 하므로 개발 시간이 증가할 수 있다.
    3. 비즈니스 로직이 복잡하면 테스트 케이스 작성도 복잡해질 수 있으며, 이는 추가적인 시간과 노력이 필요하게 된다.
  • 장점은 다 좋은 얘기 뿐이라 필자는 단점을 위주로 보는 편이다.
    • 단점에서 보면 공통적으로 개발 시간과 테스트 코드의 러닝 커브를 얘기하고 있다.
    • 이러한 단점에서 개발 시간은 테스트 코드와 본 코드를 둘다 작성해야하므로 원래보다 2배정도 시간이 더 들지만 서비스가 커지면 커질수록 일일이 테스트를 하는 것보단 시간적인 리소스가 좋아질 것이다.
    • 위 내용에 대한 그래프가 아래처럼 존재한다.
      • 테스트 주도 개발과 폭포수 개발에 대한 그래프인데 처음에는 TDD가 개발 시간이 오래걸리지만 프로젝트에 라이프 타입이 길면 길수록 유지보수에 대한 시간이 크게 줄어드는 것을 확인할 수 있다.
      • 하지만 WFD는 각 단계가 순차적으로 이루어지기에 변경이 어려워서 유지보수 리소스가 증가하게 된다.
    • 그리고 테스트 코드에 대한 러닝 커브는 최대한 내가 열심히 잘 배우면 된다고 생각하여 TDD를 도입하게 되었다.

단위테스트(Unit Test)

  • 단위 테스트엔 크게 두가지 방식이 있다.
  1. 모키시스트(Mockist)
    • 행동 중심 테스트: 객체의 행동에 초점을 맞추어 테스트를 진행합니다.
    • 상호 작용 테스트: 다른 객체와의 상호 작용이나 메소드 호출을 검증하는데 주로 사용됩니다.
    • 모킹 사용: 외부 시스템과의 의존성을 줄이기 위해 모킹(Mocking)을 활용하여 테스트를 수행합니다.
  2. 클래시스트(Classicist)
    • 상태 중심 테스트: 객체의 상태에 초점을 맞추어 테스트를 진행합니다.
    • 결과 기반 테스트: 메서드의 반환 값이나 객체의 상태 변화를 검증하는데 주로 사용됩니다.
    • 외부 의존성: 외부 시스템과의 의존성이 있을 수 있으며, 이로 인해 테스트의 복잡성이 높아질 수 있습니다.

모키시스트(Mockist) vs 클래시스트(Classicist)

요소모키시스트(Mockist)클래시스트(Classicist)
테스트 중점행동상태
테스트 포커스상호 작용결과
모킹 사용아니요
테스트 복잡성낮음높음
외부 의존성낮음높음
  • 내가 정리한 모키시스트와 클래시스트의 가장 큰 차이 두가지는 아래와 같다.
    1. 행동(실행이 됐는지) 확인, 상태(나오는 결과값) 확인 차이
    2. 외부 의존성에서 모키시스트(낮음), 클래시스트(높음) 차이
  • 왜냐하면 클래시스트는 외부 의존성을 다 구현을 해주어야하기에 의존성이 많으면 구현에 영향을 많이 끼치지만 해당 결과값을 확실하게 확인이 가능하다.
  • 또한 모키시스트는 모킹을 통해 외부 의존성을 Mock 등록을 통해 직접 구현하는 것이 아닌 가짜로 생성하여 사용한다 따라서 의존성에 영향을 적게 받으며 가짜로 생성한거이므로 행동만을 판단한다.
  • 상황에 따라 모키시스트, 클래시스트 둘다 구현해도 되고 1개만 구현해도 된다!
    • 그 부분은 개발하다보면 느껴질 것 이다!
    • 참고로 나는 의존서이 1개 이하일 경우에는 클래시스트로 해주고 2개 이상일 땐 모키시스트로 진행하였다!

단위 테스트를 진행하는 이유

  1. 초기 개발 단계에서 버그를 찾고 수정함으로써, 후반부에 발생할 수 있는 복잡한 문제를 방지할 수 있다.
  2. 단위 테스트가 있으면, 코드 변경 시 해당 변경이 기존 기능에 미치는 영향을 쉽게 확인할 수 있다.
  3. 단위 테스트는 자동화될 수 있으며, 이를 통해 개발자들이 더 효율적으로 작업할 수 있다.
  4. 단위 테스트는 코드의 기능을 설명하는 일종의 문서 역할을 하므로, 다른 개발자들이 코드를 이해하고 사용하기 쉽게 만든다.
  5. 각 마이크로서비스는 독립적으로 테스트할 수 있으므로, 개발 및 테스트 시간을 단축시킬 수 있습니다.
    • 다른 서비스에서 받아오는 값을 미리 지정해서 사용이 가능하므로 마이크로서비스에서 특화된다.
  6. 마이크로서비스는 독립적으로 배포될 수 있으며, 단위 테스트를 통해 각 서비스의 안정성을 보장할 수 있어, 지속적인 배포 및 통합이 용이하다.

모키시스트(Mockist) 코드

  • 가장 기본이 되는 회원가입 API를 가져왔다. 해당 코드를 모키시스트로 작성할 예정이다.
  • UserRegisterController.java
    public class UserRegisterController {
    
        private final RegisterUserUseCase registerUserUseCase;
        private final UserResponseMapper userResponseMapper;
    
        @PostMapping("/register")
        public ResponseEntity<T> registerUser(
                @RequestBody RegisterUserRequest registerUserRequest
        ) {
            RegisterUserCommand command = RegisterUserCommand.builder()
                    .username(registerUserRequest.getUsername())
                    // 등등
                    .build();
    
            User user = registerUserUseCase.registerUser(command);
            RegisterUserResponse response = userResponseMapper.mapToRegisterUserResponse(user);
            
    				return ResponseEntity.status(HttpStatus.OK).body(response);
        }
    
    }
  • UserRegisterControllerTest.java (앞 부분)
    @ExtendWith(MockitoExtension.class)
    @DisplayName("UserRegisterController 단위 테스트")
    public class UserRegisterControllerTest {
    
        @Mock
        private RegisterUserUseCase registerUserUseCase;
    
        @Mock
        private UserResponseMapper userResponseMapper;
    
        @InjectMocks
        private UserRegisterController userRegisterController;
    
        RegisterUserRequest request;
        User mockUser;
        RegisterUserResponse mockResponse;
    
        @BeforeEach
        public void setUp() {
            // RegisterUserRequest 객체 생성
            request = new RegisterUserRequest(
                    "testuser",
                    // 등등(필요에 따라 추가)
            );
    
            // User 객체 생성
            mockUser = User.builder()
                    .userId(1L)
    								// 등등(필요에 따라 추가)
                    .build();
    
            // RegisterUserResponse 객체 생성
            mockResponse = RegisterUserResponse.builder()
                    .username("testuser")
    								// 등등(필요에 따라 추가)
                    .build();
        }
    
    		// 밑에서 이어짐
    }
    • UserRegisterControllerTest코드가 너무 길어 두 분류로 나눠서 설명을 하겠다.
    • @ExtendWith(MockitoExtension.class) → 해당 범위에서 Mockito의 기능을 사용할 수 있게 해준다.
    • RegisterUserUseCase, UserResponseMapper → 이 두 부분은 UserRegisterController에서 의존성으로 @Mock을 등록하여 실제 객체를 만드는 것이 아닌 가짜 객체를 만들어 등록을 해주기 위함이다.
    • UserRegisterController@InjectMocks을 위에서 등록한 두 Mock 객체를 사용할 수 있도록 설정해둔다.
      • 이렇게 해두면 의존성이 주입된 두 객체( RegisterUserUseCase, UserResponseMapper)를 직접 구현하지 않아도 되고 UserRegisterController에 로직만 명확하게 테스트가 가능하다.
    • @BeforeEach → 모든 테스트가 실행 되기 전에 먼저 실행 되는 메소드이다.
      • UserRegisterController에서 메소드로 구현된 request는 구현해주어야한다.
      • mockUser, mockResponse는 미리 이 두 변수로 받으려고 만들어둔 것이다.
  • UserRegisterControllerTest (뒷부분)
    @ExtendWith(MockitoExtension.class)
    @DisplayName("UserRegisterController 단위 테스트")
    public class UserRegisterControllerTest {
    
        @Test
        @DisplayName("회원가입 요청에 대한 성공 응답 반환")
        public void shouldRegisterUser() throws Exception {
            // given
            when(registerUserUseCase.registerUser(any())).thenReturn(mockUser);
            when(userResponseMapper.mapToRegisterUserResponse(any())).thenReturn(mockResponse);
    
            // when
            ResponseEntity<ReturnObject> response = userRegisterController.registerUser(request);
    
            // then
            assertEquals(HttpStatus.OK, response.getStatusCode());
            assertEquals(mockResponse, response.getBody().getData());
            verify(registerUserUseCase, times(1)).registerUser(any());
            verify(userResponseMapper, times(1)).mapToRegisterUserResponse(any());
        }
    }
    • 회원가입 요청에 대한 성공 응답 반환 메소드(shouldRegisterUser)에 대한 테스트 코드를 보겠다.
    • 주석으로 되어 있는 부분을 먼저 보면 순차적으로 Given, When, Then으로 구성이 되어있다.
      • 주석을 해준 이유는 준비(Given), 실행(When), 검증(Then)을 명확하게 구분하여 코드의 가독성과 유지보수성을 항샹시킨다.
    • Given(준비)에서는 UserRegisterController에 영향을 미치는 즉, 의존성을 주입하는 메소드만 받아서 사용하면 된다.
      • when(registerUserUseCase.registerUser(any())).thenReturn(mockUser) → 의존성으로 주입 받은 registerUserUseCase클래스에 registerUser 메소드에 아무 값(any())이 들어가도 그 결과 값은 꼭 mockUser나오게 미리 가정을 해두는 것이다.
      • when(userResponseMapper.mapToRegisterUserResponse(any())).thenReturn(mockResponse) → 의존성으로 주입 받은 userResponseMapper클래스에 mapToRegisterUserResponse메소드에 아무 값(any())이 들어가도 그 결과 값은 꼭 mockResponse나오게 미리 가정을 해두는 것이다.
    • When(실행)에서는 테스트를 하려는 메소드를 실행시키면 된다.
      • userRegisterController.registerUser(request) → 우린 위에서 @BeforeEach를 통해 request를 미리 만들어두어 바로 사용이 가능하다.
    • Then(검증)에서는 테스트한 메소드에 대한 결과를 검증한다.
      • assertEquals(HttpStatus.OK, response.getStatusCode()) → 응답 상태 코드가 OK(200)인지 확인한다.
      • assertEquals(mockResponse, response.getBody().getData()) → 응답 본문의 데이터가 우리가 mocking한 response 객체와 동일한지 확인한다.
      • verify(registerUserUseCase, times(1)).registerUser(any()) → UserRegisterController에서 registerUserUseCase.registerUser()가 1번 실행됐는지 확인하기.
      • verify(userResponseMapper, times(1)).mapToRegisterUserResponse(any()) → UserRegisterController에서 userResponseMapper.mapToRegisterUserResponse()가 1번 실행됐는지 확인하기.

클래시스트(Classicist) 코드

  • 위에서 사용했던 UserRegisterController.java에 대해서 클래시스트를 작성하겠다.
  • UserRegisterControllerTest.java (앞 부분)
    @DisplayName("UserRegisterController 단위 테스트")
    public class UserRegisterControllerTest {
    
        private UserRegisterController userRegisterController;
        private RegisterUserUseCase registerUserUseCaseFake;
        private UserResponseMapper userResponseMapperFake;
    
        @BeforeEach
        void setUp() {
            registerUserUseCaseFake = new RegisterUserUseCaseFake();
            userResponseMapperFake = new UserResponseMapperFake();
            userRegisterController = new UserRegisterController(registerUserUseCaseFake, userResponseMapperFake);
        }
    
        private static class RegisterUserUseCaseFake implements RegisterUserUseCase {
    
            @Override
            public User registerUser(RegisterUserCommand command) {
                return User.builder()
                        .username(command.getUsername())
                        .build();
            }
        }
    
        private static class UserResponseMapperFake extends UserResponseMapper {
    
            @Override
            public RegisterUserResponse mapToRegisterUserResponse(User user) {
                return RegisterUserResponse.builder()
                        .username(user.getUsername())
                        .build();
            }
    
            // UserRegisterController에서는 사용하지 않는 메소드라 구현 X
            @Override
            public LoginUserResponse mapToLoginUserResponse(User user) {
                return null;
            }
        }
    }
    • UserRegisterControllerTest코드가 너무 길어 두 분류로 나눠서 설명을 하겠다.
    • 클래시스트 테스트는 의존받는 클래스를 모두 구현해주어야한다. 따라서 해당 테스트 코드에서 의존 받는 클래스(RegisterUserUseCase, UserResponseMapper)에 대한 구현체(RegisterUserUseCaseFake, UserResponseMapperFake)를 만들어주어야한다.
      • 끝에 Fake를 붙여준 이유는 가짜 구현체를 나타내기 위함이다.
    • @BeforeEach → UserRegisterController에 대한 의존성(Fake)을 생성자로 초기화 시켜준다.
    • class RegisterUserUseCaseFake implements RegisterUserUseCaseRegisterUserUseCase 인터페이스의 registerUser 메소드를 오버라이딩한다.
    • class UserResponseMapperFake extends UserResponseMapperUserResponseMapper 인터페이스의 mapToRegisterUserResponse, mapToLoginUserResponse메소드를 오버라이딩하고 구현한다.
      • 하지만 UserRegisterController클래스에서 mapToLoginUserResponse를 사용하지 않기에 디테일하게 구현하지 않았다.
  • UserRegisterControllerTest (뒷 부분)
    @DisplayName("UserRegisterController 단위 테스트")
    public class UserRegisterControllerTest {
        
    		@Test
        @DisplayName("회원가입 요청에 대한 성공 응답 반환")
        void testRegisterUserSuccess() {
    				// given
            RegisterUserRequest request = new RegisterUserRequest(
                    "testuser123",
                    "Test@12345",
                    "Test@12345",
                    "testNickname",
                    "010-1234-5678",
                    "test@example.com"
            );
    
    				// when
            ResponseEntity<ReturnObject> responseEntity = userRegisterController.registerUser(request);
    
            // then
            assertEquals(HttpStatus.OK, responseEntity.getStatusCode());
            assertNotNull(responseEntity.getBody());
            assertEquals(request.getUsername(), ((RegisterUserResponse) responseEntity.getBody().getData()).getUsername());
        }
    }
    • 회원가입 요청에 대한 성공 응답 반환(testRegisterUserSuccess) 메소드에 대한 테스트 코드를 보겠다.
    • Given(준비)에서는UserRegisterController에서 registerUser메소드에 들어가는 request를 정의해준다.
    • When(실행)에서는 테스트를 하려는 메소드를 실행시키면 된다.
    • Then(검증)에서는 테스트한 메소드에 대한 결과를 검증한다.
      • 실제로 의존받는 클래스들은 구현하였기 때문에 값에대한 검증이 가능하다.
      • assertEquals(HttpStatus.OK, responseEntity.getStatusCode()) → 실제 값에서 200ok가 나왔는지 확인한다.
      • assertNotNull(responseEntity.getBody()) → 실제 값이 있는지 판단한다.
      • assertEquals(request.getUsername(), ((RegisterUserResponse) responseEntity.getBody().getData()).getUsername()) → request로 받은 유저 네임과 실제 반환된 값에 유저 네임이 같은지 확인한다.

모키시스트(Mockist) 코드 vs 클래시스트(Classicist) 코드

  • 두개에 대한 코드차이를 위해서 보았을 것이다.
  • 의존성 2개정도 있던 클래스(UserRegisterController)를 테스트를 하려니 모키시스트는 의존성에 대한 클래스를 Mocking 해주면 편하게 작업하였지만, 클래시스트는 의존성에 대한 클래스를 일일이 다 구현해줘야해서 생각보다 오래걸렸다.
  • 따라서 내 생각으로는 의존성이 1개 이하 라면 클래시스트로 작성하는 것이 편하고, 2개 이상이면 모키시스트로 작성하는 것이 좋다고 생각한다.
  • 그리고 의존성이 많은데 상태를 확인해야한다면 통합테스트로 전향하는 것도 생각해보는 것이 좋을 것이다.

결론

후기

  • 이번에 TDD를 경험해보았는데 테스트 코드를 작성하고 본 코드를 작성하니 불필요한 코드가 없이 딱 틀에 맞는 코드를 구현하게 되었다.
  • 또한, 테스트 코드를 디테일하게 작성하다 본 코드에 오류를 찾아 수정한 경험도 꽤 있었다.
  • 뭔가 느낌일수도 있지만..코드가 깔끔해진 것 같다..ㅎㅎ
  • 테스트 코드 작성하면서 문서화작업이 같이 된 것도 느꼈다.
  • 이제 추후에 jacoco를 이용해서 테스트 커버리지를 확인하여 부족한 부분을 채울 예정이다!
profile
지나가는 개발자

0개의 댓글