본문 바로가기
IT/Spring & Spring Boot

[TDD] Junit으로 Spring Boot에서 TDD(Test-Driven Development)를 통해 로그인 기능 구현하기

by 저당단 2023. 6. 6.
 

[TDD] 실전에서 사용해본 TDD(Test-Driven Development)

현재 회사에서 TDD 방식으로 개발중에 있습니다. TDD를 도입한 건 현재 진행 중인 프로젝트부터라 경험이 많지는 않지만, 조금이나마 도움이 되실 분들이 있을까 해서 포스팅합니다. 사실 테스트

doringri.tistory.com

얼마 전에 저런 글을 썼었는데요, 솔직히 저 같아도 저것만 보곤 TDD에 대해 안 와닿습니다.

그래서 직접 Junit으로 구현하는 과정을 포스팅해보려 합니다.

 

Junit이란?

Junit은 Java 코드 검증을 지원하는 테스트 코드 프레임워크입니다. 테스트 코드 하면 가장 먼저 생각나는 유명한 프레임워크인 xUnit 시리즈 중 하나입니다.

	testImplementation 'org.junit.jupiter:junit-jupiter:5.7.1'
	testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine'

junit을 사용하기 위해 위 코드를 build.gradle에 세팅해주었습니다.

현재 기준 5.7.1이 최신이라 저 버전을 사용했습니다. 아래 링크로 들어가시면 최신 버전을 확인하실 수 있습니다.

 

Testing in Java & JVM projects

If you are developing Java Modules, everything described in this chapter still applies and any of the supported test frameworks can be used. However, there are some things to consider depending on whether you need module information to be available, and mo

docs.gradle.org

 

0. 테스트 코드를 작성하기 위해 기본적인 세팅을 해보자

회사에선 IntelliJ를 씁니다만 VSCode를 사용하도록 하겠습니다. 절대 제가 궁핍해서 그런 건 아닙니다.

 

개발환경

  • Visual Studio Code
  • Java 17
  • Spring Boot 3.1.0
  • Junit 5
  • JPA

간단한 로그인 기능을 만들도록 하겠습니다. 현재 프로젝트는 이런 구조로 되어 있습니다. 전형적인 MVC 구조입니다.

  1. 클라이언트(프론트엔드)와 서버(백엔드) 간 이동하는 데이터의 구조를 나타내는 DTO
  2. 클라이언트와 서버를 연결해주는 컨트롤러(Controller)
  3. 실질적인 기능 구현을 담당하는 서비스(Service)

실질적인 기능은 서비스에 있으므로 서비스의 테스트 코드를 작성하도록 하겠습니다.

그런데 여기서 의문이 생깁니다.

컨트롤러도 테스트 코드를 작성해야 할까?

 

컨트롤러를 테스트할지 말지는 예전부터 논의가 있었습니다.

물론 WebMVCTest 라는 것을 이용해서 postman처럼 테스트를 할 수는 있습니다.

하지만 컨트롤러는 클라이언트와 서비스를 연결하는 기능을 수행할 뿐, 결코 테스트할 만큼의 로직을 수행하지는 않고 있기에 실무에서 저는 따로 작성하지 않고 있습니다.

 

하지만 반대로, 만약 컨트롤러에서 요청 dto의 유효성 검사 작업을 하는 등의 로직이 있다면 테스트 코드를 작성할 의미가 생긴다는 거겠죠. 상황에 맞게 판단하시면 될 것 같습니다.

 

 

1. 실패하는 테스트 코드를 작성해보자

@Service
public class LoginService {
    public ResponseEntity<Integer> login(LoginRequest loginRequest) {
        return new ResponseEntity<Integer>(0, HttpStatus.I_AM_A_TEAPOT);
    }
}
@AllArgsConstructor
public class LoginRequest {
    public String userId;
    public String password;
}

이번에는 이런 간단한 형태의 로그인 서비스를 테스트 코드로 검증해보려 합니다.

클라이언트는 LoginRequest라는 dto를 통해 서버로 로그인을 요청하게 될 것입니다.

응답 dto는 따로 만들지 않고 ResponseEntity를 통한 HTTP status code로 성공, 실패 여부를 나타내려고 합니다.

 

실제 로직은 아직 구현이 되지 않은 상태이므로, 오류가 아닌 실패하는 테스트를 만들기 위해 실전에서 거의 쓰이지 않는HTTP 상태코드인 I_AM_A_TEAPOT을 반환하도록 해 놨습니다. (...) 물론 정석이 아닙니다.

 

메소드 이름에 우클릭 하시고 Go To Test를 클릭하면 테스트 코드를 바로 만들 수 있습니다.

 

@ExtendWith(MockitoExtension.class)
public class LoginRequestTest {
    @InjectMocks
    private LoginService loginService;
    
    @Nested
    class 아이디가_빈_값이라면 {
        @Test
        void 상태코드_400을_리턴한다() {}
    }

    @Nested
    class 패스워드가_빈_값이라면 {
        @Test
        void 상태코드_400을_리턴한다() {}
    }
    
    @Nested
    class 아이디가_맞지_않다면 {
        @Test
        void 상태코드_401을_리턴한다() {}
    }

    @Nested
    class 비밀번호가_맞지_않다면 {
        @Test
        void 상태코드_401을_리턴한다() {}
    }

    @Nested
    class 비밀번호가_맞다면 {
        @Test
        void 상태코드_200을_리턴한다() {}
    }
}​

로그인 시 일어날 수 있는 케이스에 대해 처리하는 틀만 짠 상태입니다. 여기서 요구사항을 위한 설계를 한다고 생각할 수 있겠죠.

맨 첫 번째 케이스에 대한 테스트 코드를 짜 보겠습니다.

 

※ 최상단의 @ExtendWith과 서비스의 @InjectMock 은 꼭 달아주셔야 합니다. 안 그러면 필요한 Mock(테스트할 때 사용할 가짜 객체)을 주입받지 못해 NullPointException 오류가 발생합니다.

 

    @Nested
    class 아이디가_빈_값이라면 {
        @Test
        void 상태코드_400을_리턴한다() {
            //given
            LoginRequest loginRequest = new LoginRequest("", "testPassword");
            
            //when
            ResponseEntity<Integer> result = loginService.login(loginRequest);

            //then
            assertEquals(HttpStatus.BAD_REQUEST.value(), result.getStatusCode());
        }
    }

given-when-then 패턴을 이용해 테스트 코드를 작성했습니다.

코드를 보고 대충 이해하시겠지만 짧게 패턴에 대해 설명하자면,

  1. given: 테스트 실행 전의 상태, 즉 이 테스트 케이스를 실행하기 위해 준비해야 할 것들 정의
  2. when: 테스트할 메소드를 실행하는 부분
  3. then: 실질적인 검증 부분

이라고 할 수 있습니다. 코드를 보시면 클라이언트의 요청, 즉 전제조건을 given에, 그 요청을 실행하는 부분when에, result가 진짜 400(Bad Request)인지 검증하는 부분then에 서술되어 있죠.

assertEquals는 Junit에서 제공하는 검증 메소드입니다. 두 파라미터가 일치하는지 검증해줍니다.

 

테스트 코드가 작성되었으니 좌측에 보이는 실행 버튼을 눌러서 테스트가 오류 없이 잘 실패하는지 확인해보겠습니다.

 

왼쪽에 ⓧ 표시가 보이시나요? 테스트가 오류가 아닌 실패를 했다는 뜻입니다.

 

참고로 오류가 일어나면 마크가 ⊙ 표시가 됩니다.

 

이렇게 실패하는 테스트 코드를 모든 케이스에 대해 작성합니다.

package com.example.demo.controller.login.service;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;

import java.util.Optional;

import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

import com.example.demo.controller.login.dto.LoginRequest;
import com.example.demo.controller.login.model.User;
import com.example.demo.controller.login.repository.UserRepository;

@ExtendWith(MockitoExtension.class)
public class LoginServiceTest {
    @InjectMocks
    private LoginService loginService;

    @Mock
    private UserRepository userRepository;

    @Nested
    class 아이디가_빈_값이라면 {
        @Test
        void 상태코드_400을_리턴한다() {
            //given
            LoginRequest loginRequest = new LoginRequest("", "testPassword");
            
            //when
            ResponseEntity<Integer> result = loginService.login(loginRequest);

            //then
            assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode());
        }
    }

    @Nested
    class 패스워드가_빈_값이라면 {
        @Test
        void 상태코드_400을_리턴한다() {
            //given
            LoginRequest loginRequest = new LoginRequest("testId", "");
            
            //when
            ResponseEntity<Integer> result = loginService.login(loginRequest);

            //then
            assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode());
        }
    }

    @Nested
    class 아이디가_맞지_않다면 {
        @Test
        void 상태코드_401을_리턴한다() {
            //given
            LoginRequest loginRequest = new LoginRequest("wrongId", "testPassword");
            when(userRepository.findByUserId(loginRequest.userId)).thenReturn(Optional.ofNullable(null));
            
            //when
            ResponseEntity<Integer> result = loginService.login(loginRequest);

            //then
            assertEquals(HttpStatus.UNAUTHORIZED, result.getStatusCode());
        }
    }

    @Nested
    class 비밀번호가_맞지_않다면 {
        @Test
        void 상태코드_401을_리턴한다() {
            //given
            LoginRequest loginRequest = new LoginRequest("testId", "wrongPassword");
            when(userRepository.findByUserId(loginRequest.userId)).thenReturn(Optional.of(new User(1, "testId", "testPassword")));
            
            //when
            ResponseEntity<Integer> result = loginService.login(loginRequest);

            //then
            assertEquals(HttpStatus.UNAUTHORIZED, result.getStatusCode());
        }
    }

    @Nested
    class 비밀번호가_맞다면 {
        @Test
        void 상태코드_200과_유저_시퀀스를_리턴한다() {
            //given
            LoginRequest loginRequest = new LoginRequest("testId", "testPassword");
            when(userRepository.findByUserId(loginRequest.userId)).thenReturn(Optional.of(new User(1, "testId", "testPassword")));
            
            //when
            ResponseEntity<Integer> result = loginService.login(loginRequest);

            //then
            assertEquals(HttpStatus.OK, result.getStatusCode());
            assertEquals(1, result.getBody());
        }
    }
}

중요한 건 JPA Repository의 메소드를 사용했다면 Repository도 전부 Mock으로 만들어줘야 합니다.

즉, findByUserId라는 메소드에 들어갈 값과 리턴값을 모두 given 부분에 지정해줘야 합니다.

3, 4, 5번째 케이스에 있는 given의 when 메소드를 잘 봐주세요.

 

public interface UserRepository extends JpaRepository<User, Integer> {
    public Optional<User> findByUserId(String userId);
}

실제 UserRepository의 findByUserId 메소드는 Optional<User> 타입을 반환합니다.

이 메소드가 아무것도 반환하지 않을 때를 상정해서 "아이디가 맞지 않다면" 이라는 조건의 케이스에선 findByUserId 메소드가 Optional.ofNullable(null)을 반환하도록 해줍니다.

 

이렇듯, TDD를 잘 활용하기 위해서는 메소드에 대한 정확한 이해가 필요합니다.

 

 

2. 테스트 코드를 성공시키기 위한 소스 코드를 작성해보자

테스트 코드를 모두 작성했으니 이제는 직접 로직을 작성하여 테스트 코드를 통과하도록 만듭니다.

package com.example.demo.controller.login.service;

import java.util.Optional;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;

import com.example.demo.controller.login.dto.LoginRequest;
import com.example.demo.controller.login.model.User;
import com.example.demo.controller.login.repository.UserRepository;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class LoginService {
    private final UserRepository userRepository;

    public ResponseEntity<Integer> login(LoginRequest loginRequest) {
        if (!isRequestValid(loginRequest)) {
            return new ResponseEntity<Integer>(0, HttpStatus.BAD_REQUEST);
        }

        Optional<User> foundUser = userRepository.findByUserId(loginRequest.userId);

        if (foundUser.isPresent()) {
            User user = foundUser.get();
            
            if (loginRequest.password == user.password) {
                return new ResponseEntity<Integer>(user.seq, HttpStatus.OK);
            }
        }

        return new ResponseEntity<Integer>(0, HttpStatus.UNAUTHORIZED);
    }

    private boolean isRequestValid(LoginRequest loginRequest) {
        return !(loginRequest.userId.isBlank() || loginRequest.password.isBlank());
    }
}

테스트 케이스들의 조건을 모두 충족시킬 수 있는 로직을 작성하였습니다.

물론 실전에서는 로그인 기능에 암복호화나 해싱작업이 필요하지만 여기선 생략합니다.

 

이러고 테스트를 돌리면...

!!

모든 테스트 케이스가 성공했습니다.

이번에는 운이 좋았지만 로직을 작성하다 보면 예상치 못한 테스트 코드상의 에러가 발생할 때도 있습니다.

로직을 수정하면 테스트 코드도 같이 수정해야 하죠.

 

하지만 TDD를 잘 이용하면 그만큼 견고하고 요구 사항에 적합한 프로그램을 만들 수 있지 않을까요?

 

라고 되뇌이며 저는 요즈음에도 회사에서 테스트 코드의 에러를 고칩니다...

 

오늘은 TDD의 흐름에 대해 포스팅했습니다. 코드에 대해선 구체적인 설명이 부족했던 감이 있는데요, 초보인 저는 틀린 부분이나 지적은 언제나 환영합니다.

 

읽어주셔서 감사합니다.