본문 바로가기
JAVA/요점정리

자바 스프링부트 테스트코드 작성 JUnit5 그리고 Mock

by 진짠 2024. 6. 18.
728x90

테스트 코드의 중요성

 개발자의 길을 어느덧 3년 걸었지만 테스트 코드의 중요성을 깨닫지 못하고 있었습니다. 웹 프로젝트 여러개를 맡으며 개발이 끝나고 테스트 기간이 되면 단위 테스트를 진행하였는데 직접 버튼을 누르고 로그와 DB를 눈으로 확인하였습니다. 물론 이 방법이 가장 직관적이고 확실한 테스트 방법일 수 있습니다. 하지만 규모가 작은 프로젝트라도 테스트 케이스가 100개가 넘어가는 경우가 비일비재한데 수작업으로 진행하다보니 제대로 확인하지 못하는 경우가 많았습니다. 실제로 회사에 있을때 프로젝트의 완성도에 관한 이야기는 끊임없이 나오곤 했습니다. 저 또한 이 테스트에 대해 많은 고초를 겪었습니다. 내가 더 꼼꼼하게 확인해보면 되겠지, 하며 해봐도 예상치 못한 곳에서 오류가 터졌습니다.

 

 물론 제 부주의로 빼먹은 케이스가 있긴 하지만 나름대로 분석을 해본 결과 몇가지 이유를 발견했습니다.

- 테스트를 했는데 추후에 코드를 수정할 경우

- 테스트 결과 잘못된 부분을 발견해서 코드를 수정했는데 다른 곳에서 오류가 터진 경우

- 오류가 아니었는데 변경사항에 대해 수정하면서 오류가 된 경우

 

 그 외에도 이유가 더 있을 수 있겠지만 공통적인 점은 모두 테스트 후 코드를 수정한 경우 였습니다. 현실적으로 코드를 수정할 때 마다 연관된 기능을 전부 다시 테스트하는 것은 불가능했습니다.

 

 그러한 애로사항이 존재했는데 테스트 코드의 정의와 존재 이유에 대해 공부하며 이러한 사항들을 대부분 해결할 수 있다는 것을 깨달았습니다. 특히 코드를 수정한 뒤 이미 작성한 테스트 코드를 돌리는 것 만으로 점검이 가능한 점이 이 테스트 코드 작성의 존재 이유가 아닐까 생각했습니다.

 

 테스트 코드의 중요성은 많은 곳에서 강조하고 있습니다. 며칠 전 제가 읽었던 '클린 코드(clean code)' 의 저자 로버트 마틴도 그것에 대해 매우 강조했습니다. 교재에 대한 감상문을 포스팅하며 정리한 적이 있습니다.

 

클린코드(clean code)를 읽고 요점 정리 및 코딩의 방향성 잡기

시작 클린 코드. 말그대로 깔끔한 코드를 작성하기 위해 개발자가 알아야 할 사항들을 집필한 교과서 같은 책이었습니다. 다만 우리나라 저자가 아닌 점, 최초 발행일이 2008년인 점을 감안하여

jincchan.tistory.com

 

 그러나 개발자들이 모인 커뮤니티 공간에서 테스트 코드에 대한 견해는 조금의 차이가 있었습니다. 특히 SI개발의 특성 상 테스트 코드의 작성, 그중에서도 TDD의 법칙을 따라야 하는가? 에 대해서는 부정적인 의견이 다수 있었습니다. 데드라인에 맞춰 기능을 개발하고 클라이언트의 눈에 가시적인 성과가 보여야하기 때문에 TDD를 할 시간적 여유가 부족할 수 있습니다. 제가 우려한 점도 이와 비슷했습니다. 시간이 충분하지 않은데 테스트 코드까지 작성하는 것이 과연 옳을까? 회사의 다른 연륜이 쌓인 개발자 분들도 테스트 코드에 대해 잘 아실텐데 사용하지 않은 이유도 이러한 점이 아닐까? 이 부분에 대한 결론은 아직까지 내리지 못하였습니다. 적어도 테스트 코드를 작성해 실제 프로젝트를 몇개정도 완수해봐야 비교가 가능할 것 같았습니다.

 

 그러나 조금 자신있게 결론 내릴 수 있는것은 깨끗한 코드를 지속적으로 유지해야하는 서비스 개발 회사에서는 이 테스트 코드가 필수적이라는 점입니다. 현재 제가 읽고있는 중인 클린 아키텍쳐라는 책에서 어느 회사에 대한 그래프가 나옵니다. 직원의 증가율은 연도가 지나면서 꾸준히 늘어납니다. 그러나 서비스를 지속적으로 개발하는데 드는 비용은 기하급수적으로 늘어나고 있습니다. 인력은 계속 투입되는데 진행사항은 제자리 걸음인 꼴이 되고 있습니다. 물론 근본적인 이유는 소프트웨어 아키텍쳐가 제대로 구축되지 않아서입니다. 제가 말하고 싶은 것은 그만큼 서비스 회사는 코드 자체의 질을 중요시해야 한다는 점입니다. 아키텍쳐는 물론 철저한 테스트 코드 작성을 통해 높은 퀄리티의 서비스를 유지해야 합니다.

 

 좋은 코드를 작성하고 싶고 자체 서비스를 개발하는 회사에 이직을 하고 싶은 저로서는 테스트 코드 작성은 선택이 아닌 필수였습니다.

 

JUnit?

  테스트 코드의 중요성에 대해 알았으니 이제 어떤 프레임워크를 사용해야 하는지 공부했습니다. 저는 JUnit5를 사용하였고 스프링부트 프로젝트 생성 시 기본적으로 내장되어 있습니다. build.gradle(maven의 경우 pom.xml)에 dependencies에 보면 

testImplementation("org.springframework.boot:spring-boot-starter-test")

 

 다음과 같이 등록이 된 것을 확인할 수 있습니다. JUnit은 자바 언어 기반 테스트용 프레임워크로 버전은 5를 사용하고 있습니다. 4에서 5로 넘어오면서 일부 기능에 대한 코드 작성이 조금씩 변경된 것을 알 수 있었습니다.(Mockito에 대한 부분)

 

 프로젝트에 적용하기

 단순히 테스트 코드를 작성하는 것과 실제 프로젝트에 맞게 테스트 코드를 적용하는 것은 다른 문제였습니다. 처음에 어떤 식으로 적용해야 할지 많은 혼란이 있었습니다. 이번 포스팅은 그 고충에 대해 제 나름대로 정의를 내리고 작성한 방법을 적었습니다. 정답과 가깝지 않을 확률이 매우 있으니 이런 이유로 이렇게 작성했구나, 정도로만 봐주시면 될 것 같습니다. 테스트 코드는 지금도 실시간으로 수정중이니 오늘 작성한 이 코드도 당장 내일 생각이 바뀌어 코드 대공사를 할 수 있습니다. 만약 그렇게 된다면 어떤 이유로 변경이 발생했는지 포스팅 할 예정입니다.

 

 간단한 로그인 api를 구현하여 그것에 대한 테스트 코드를 적용해 보았습니다.

 

- 공통

 

 테스트 코드는 모든 파일에 대해 실행합니다. 컨트롤러는 컨트롤러 기능에 맞게 테스트를 작성하고 비즈니스 로직은 그에 맞는 테스트 코드를 작성합니다.

 

 또, 테스트 코드는 성공과 실패의 케이스를 모두 테스트합니다. 실패의 경우는 발생할 수 있는 예외사항들에 대해 케이스를 정리하고 코드를 작성합니다. 

 

 모든 테스트 코드는 각각이 독립적으로 실행되어야 합니다. 어떤 테스트가 선행되어야 실행되는 테스트는 없어야 합니다.

 

 테스트는 빠르게 실행되어야 합니다. 코드에 대해 수정할 경우 빠르게 테스트 코드를 돌려서 정상적으로 기능이 실행되는지 확인이 되는 것을 목표로 합니다.

 

 테스트 코드는 given, when, then 세 단계를 거쳐 진행합니다. given은 데이터를 어떻게 세팅하였는지, when은 데이터 세팅 후 테스트를 할 항목에 대해, then은 테스트 후 결과에 대해 기술합니다.

 

 - Controller

 

 컨트롤러를 테스트해야하는 이유에 대해 고민해보았습니다. 컨트롤러는 실제 api 통신이 일어나는 부분입니다. request, response 데이터를 실제 비즈니스 로직과 교환하는 역할을 합니다. 그렇다면 데이터 객체를 주입했을 때 api통신이 이상적으로 발생하는가? 를 중점적으로 테스트 해야겠다 판단했습니다.

 

@WebMvcTest(LoginController.class)
@WithMockUser
public class LoginControllerTest {

 

 테스트에 필요한 어노테이션을 사용했습니다. WebMvcTest의 경우 컨트롤러에 필요한 빈들을 로드하는 역할을 합니다. 이 경우 loginService나 repository에 관련 빈들이 될 수 있습니다. WithMockUser의 경우 인증된 가짜 사용자 생성을 위해 사용하였습니다. Spring Security를 사용하면 인증된 사용자들만 api에 접근이 가능하도록 설정하게 되는데 테스트 코드에서 접근하면 막히기 때문에 필요하여 사용했습니다.

 

@Test
void 로그인_성공() throws Exception {
    // given
    LoginDto validLoginDto = new LoginDto("validUser", "validPw1!");
    String validRequestBody = objectMapper.writeValueAsString(validLoginDto);
    Response successResponse = new Response(successStatus, MessageUtil.LOGIN_SUCCESS, "data");
    when(loginService.login(validLoginDto)).thenReturn(successResponse);

    // when
    MvcResult mvcResult = mockMvc.perform(post("/v1/login/login")
                    .with(csrf())
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(validRequestBody))
            .andExpect(status().isOk())
            .andReturn();
	//then
    String jsonResponse = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8);
    Response result = objectMapper.readValue(jsonResponse, Response.class);
    assertThat(result.status()).isEqualTo(successStatus);
    assertThat(result.message()).isEqualTo(MessageUtil.LOGIN_SUCCESS);
}

 

dto로 로그인 객체를 설정합니다. 그리고 loginService에서 보낸 응답은 success값을 호출하도록 세팅합니다. 테스트 코드는 독립적으로 실행 가능해야하기 때문입니다.

 

when 단계에서 api를 통신합니다. service에서 전해진 객체(validUser, validPw1!)는 통신결과 status.isOk(), 200값을 도출해야 합니다. 

 

 then 에서는 성공값으로 보내준 객체의 값이 내가 의도한 대로 보내줬는지 테스트했습니다. 

 

@Test
void 로그인_유저존재하지않음() throws Exception {
    // given
    LoginDto invalidLoginDto = new LoginDto("invalidUser", "invalidPw1!");
    String validRequestBody = objectMapper.writeValueAsString(invalidLoginDto);
    when(loginService.login(invalidLoginDto)).thenThrow(new BadRequestException(MessageUtil.USER_NOT_EXIST));

    // when
    MvcResult mvcResult = mockMvc.perform(post("/v1/login/login")
                    .with(csrf())
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(validRequestBody))
            .andExpect(status().isBadRequest())
            .andReturn();

    String jsonResponse = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8);
    ErrorResponse result = objectMapper.readValue(jsonResponse, ErrorResponse.class);
    assertThat(result.status()).isEqualTo(failedStatus);
    assertThat(result.errorMessage()).isEqualTo(MessageUtil.USER_NOT_EXIST);
}

 

 성공 케이스를 테스트 했으니 실패 케이스도 테스트 합니다. 유저가 존재하지 않을 때 로그인은 실패해야 합니다.

 

 invalidUser 데이터 객체를 받은 service는 user_not_exist 메시지와 함께 BadRequestException이라는 예외를 뱉어냅니다. 이 값을 기반으로 api 통신 시 실패 객체(ErrorResponse)를 받고 관련 메시지나 status가 의도한 대로 적용되었는지 확인합니다.

 

- Service

 

 실제 비즈니스 로직이 적용되는 부분입니다. 로그인 기능을 예로 들면 패스워드 검증, 유저아이디 정보 조회, 로그인 토큰 생성 등의 기능이 포함되어 있습니다. 기능들은 모두 개별적으로 테스트해야 하고 성공, 실패 케이스를 분리했습니다.

 

Test
void 패스워드검증_성공() throws Exception {
    //given
    String userPassword = "pw1";
    String encUserPassword = "encPw1";

    //when
    when(passwordEncoder.matches(userPassword, encUserPassword)).thenReturn(true);

    //then
    boolean isReulst = passwordEncoder.matches(userPassword, encUserPassword);
    assertTrue(isReulst, "password validation success");
}

@Test
void 패스워드검증_실패() throws Exception {
    //given
    String userPassword = "pw1";
    String encUserPassword = "encPw1";

    //when
    when(passwordEncoder.matches(userPassword, encUserPassword)).thenReturn(false);

    //then
    BadRequestException badRequestException = assertThrows(BadRequestException.class, () -> loginServiceImpl.passwordMatchException(userPassword, encUserPassword));
    assertEquals(badRequestException.getMessage(), MessageUtil.DIFF_PASSWORD);
}

 

 패스워드의 경우  BCryptPasswordEncorder를 사용하여 암호화 진행했습니다. 이는 Spring security 라이브러리에서 제공하는 기능 중 하나로 패스워드를 안전하게 보호합니다.

 

 마찬가지로 given, when, then 단계로 작성했습니다.

 

private static Stream<Arguments> tokenNullArguments() {
    return Stream.of(
            Arguments.of(null, "mockAccessToken", "mockExpiration"),
            Arguments.of(new LoginDto("testUser", "testPassword"), null, "mockExpiration"),
            Arguments.of(new LoginDto("testUser", "testPassword"), "mockAccessToken", null)
    );
}

@ParameterizedTest
@MethodSource("tokenNullArguments")
void 토큰객체중_하나가_널값일떄_예외처리(LoginDto loginDto, String accessToken, String expiration) throws Exception {
    // given
    if (loginDto != null) {
        given(accessJwtToken.generateAccessToken(any(LoginDto.class))).willReturn(accessToken);
    } else {
        given(accessJwtToken.generateAccessToken(any())).willThrow(new BadRequestException(MessageUtil.TOKEN_FAILED));
    }
    if (expiration != null) {
        given(CalendarUtil.getAddDayDatetime(1)).willReturn(expiration);
    } else {
        given(CalendarUtil.getAddDayDatetime(1)).willReturn(null);
    }
    //when
    //then
    assertThrows(BadRequestException.class, () -> {
        // When
        loginServiceImpl.generateTokenInfo(loginDto);
    });
}

 

 다음은 '로그인 객체를 받아 토큰값을 반환하는 함수' 에 대한 테스트입니다. 반환값은 loginDto, accessToken, expiration을 토큰 객체인 TokenVo에 담습니다. 이들 중 하나라도 null값이 담기면 에러를 출력해야 합니다. 지금이야 파라미터 개수가 많지 않지만 실무에서는 얼마든지 커질 수 있고 수작업으로 진행하기에는 무리가 있다고 판단했습니다. 그래서 ParameterizedTest라는 어노테이션을 적용했습니다.

 

 이는 테스트 메서드에 여러개의 파라미터를 반복적으로 넣어 실행하도록 도와줍니다. @MethodSource를 통해 배열이나 스트림, 혹은 컬렉션 형태의 arguments를 받습니다. 저는 tokenNullArguements를 정의하였는데 이는 스트림 형태이며 각각의 arguments에 서로 다른 null값을 집어넣어 개별 null값에 대한 오류처리를 검증하였습니다.

 

 토큰의 경우 마찬가지로  Spring security에서 제공하는 AccessJwtToken을 사용하였고 위 상황에서는 loginDto 객체를 이용해 값을 반환하도록 테스트했습니다.

 

 비즈니스 로직에 대한 테스트 코드를 작성하며 스프링 부트의 테스트 코드 작성에 대한 방법을 조금이나마 이해할 수 있었습니다. 다양한 실패처리를 테스트하는 과정에서 어떤 라이브러리를 사용해야할지에 대한 고민을 했습니다. 

 

- repository

 

@Test
void 토큰_저장_성공() {
    //given
    Token token = new Token();
    token.setUserSeq(1L);
    token.setRefreshToken("refreshTokenValue");
    token.setTokenExpiration("2024-01-01");
    //when
    Token tokenResult = tokenRepository.save(token);
    //then
    assertNotNull(tokenResult.getUserSeq());
    assertEquals(token.getUserSeq(), tokenResult.getUserSeq());
    assertEquals(token.getRefreshToken(), tokenResult.getRefreshToken());
    assertEquals(token.getTokenExpiration(), tokenResult.getTokenExpiration());
}

private static Stream<Arguments> tokenNullArguments() {
    return Stream.of(
            Arguments.of( null, "2024-01-01"),
            Arguments.of( "refreshTokenValue", null)
    );
}

@ParameterizedTest
@MethodSource("tokenNullArguments")
void 토큰_널값(String refreshToken, String tokenExpiration) {
    //given
    Token failToken = new Token();
    failToken.setUserSeq(1L);
    failToken.setRefreshToken(refreshToken);
    failToken.setTokenExpiration(tokenExpiration);
    // when
    // then
    assertThrows(DataIntegrityViolationException.class, () -> {
        tokenRepository.save(failToken);
    });
}

 

토큰값 저장이 성공했을때와 파라미터 중 null값이 존재했을때 예외처리를 제대로 하는지에 대한 테스트 코드입니다. 이전에 설명했던 방법으로 작성하였습니다.

 

Mock

 추가로 DI의 시점에 대해 고민하며 테스트코드의 Mock객체에 대한 생성 시점을 알 수 있었던 포스팅이 있습니다.

 

스프링 부트 DI - 필드 주입과 생성자 주입의 초기화 시점

DI(의존성 주입)의존성 주입이란 객체가 스프링 빈에 등록된 다른 객체를 필요로 할 경우 해당하는 객체를 가져오는(주입해주는) 행위이다.전통적인 자바에서는 스스로 객체를 생성하거나 찾지

jincchan.tistory.com

 

 다음 포스팅에도 나오지만 Mock 객체의 존재 이유는 '나 이 메소드 테스트할건데 이거 테스트하려면 이 객체가 필요하거든? 근데 테스트는 독립적으로 실행해야 하니까 실제 객체에 DI는 못하고 모의 객체에다 테스트 데이터만 넣어서 success or failed 리턴값만 받을거야.' 이다. 그러니까 Mock 객체의 주입 시점은 테스트가 일어나는 시점입니다. 

 

 그런데 그 후 한가지 문제에 직면했습니다. 제가 따로 만들어놓은 CalendarUtil 클래스 때문입니다. 이 클래스는 날짜를 계산하기 위해 따로 생성한 클래스로 메서드 및 변수가 모두 static 형태입니다. 1년 365일의 불변성 때문에 모두에게 공통으로 적용되기 때문에 다음과 같이 생성하였습니다.

 

 하지만 static 메서드 특성 상 인스턴스를 생성하지 않고 클래스 레벨에서 관리됩니다. 이는 해당 인스턴스 객체를 Mocking하는 Mockito가 처리할 수 없습니다.

 

 왜? 처리할 수 없을까 궁금했습니다. 이 또한 의존성 주입의 시점과 관련되어 있습니다. 의존성 주입은 해당 인스턴스의 생성 시점입니다. 인스턴스가 생성될 때 DI 인터페이스의 구체적인 구현체가 무엇인지에 따라 바뀝니다. 간단한 예제를 통해 알아보겠습니다.

 

// DI 대상 인터페이스
public interface UserRepository {
	void save(User user); 
}

// 인터페이스 구현체1
public class UserRepositoryImpl implements UserRepository {
    @Override
    public void save(User user) {
        System.out.println("Saving user to database");
        // 저장 로직
    }
}

// 인터페이스 구현체2
public class InMemoryUserRepository implements UserRepository {
    @Override
    public void save(User user) {
        System.out.println("Saving user to memory");
        // 메모리 저장 로직
    }
}

// 생성 인스턴스 대상 클래스
public class UserService {
    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public void saveUser(User user) {
        userRepository.save(user);
    }
}

 

 UserRepository 인터페이스는 UserServiceImpl과 InMemoryUserRepository 구현체를 가지고 있습니다. 이것을 DI하는 UserService 인스턴스가 생성될 때 구현체가 무엇이냐에 따라 바뀌는 것입니다. 

public class Main {
    public static void main(String[] args) {
        UserRepository userRepository = new UserRepositoryImpl();
        UserService userService = new UserService(userRepository);

        User user = new User();
        userService.saveUser(user);
    }
}

 

예제이기 때문에 다음과 같이 선언하였지만 실제 스프링 부트에서는 @Configuration 클래스에서 적용할 구현체를 지정해 줄 것입니다. 

 

 때문에 클래스 레벨로 관리되는 static method는 클래스가 메모리에 로드될 때 함께 로드됩니다. 이런 특성 때문에 별도의 상태관리도 할 수 없으며 의존성 주입이 일어나는 인스턴스 생성 시점에 사용할 수 없게 됩니다.

 

 Mockito 또한 마찬가지로 테스트 메소드의 생성 시점에 모의 객체를 생성하는 과정에서 static method는 모킹할 수 없습니다.

 

 하지만 방법은 있습니다.

 

private MockedStatic<CalendarUtil> mockedStatic;

 

 다음과 같이 선언 후 생성, 파괴 시점을 설정해주면 됩니다. 스태틱 메소드를 mockstatic 객체에 담아 로드 시점을 테스트 코드에 맞춰 정해줌으로써 모의 객체처럼 사용하는 방법입니다.

 

@BeforeEach
void setUp() {
    // MockedStatic 객체 생성
    mockedStatic = mockStatic(CalendarUtil.class);
}

@AfterEach
void tearDown() {
    // MockedStatic 객체 닫기
    mockedStatic.close();
}

 

 코드로는 다음과 같이 작성합니다.

 

 물론 실제 테스트 코드에 적용하지 않았습니다. 단순 계산으로만 필요했기 때문에 모킹할 필요가 없었습니다. 하지만 이를 통해 스태틱 메소드가 의존성 주입을 할 수 없는 이유, 시점, 테스트 코드의 모킹 시점 등에 대한 개념을 잡을 수 있어서 뜻 깊었습니다.

 

 

728x90

댓글