[Java] JUnit을 활용해서 테스트 코드 써보기/Mockito when(), BDDMokito given(), AssertJ then

말하는 감자·2025년 4월 7일

내일배움캠프

목록 보기
36/73

참고 :

준비하기



[ 기본 개념 익히기 ]

테스트를 작성해야 하는 이유? 🤔

  • 디버깅 감소
    • 테스트를 한 번 작성해두면 프로젝트가 살아 있는 내내 값비싼 결함을 예방해주고, 짜증 나는 디버깅에서 해방시켜준다.
  • 자신 있는 변경
    • 좋은 테스트로 무장한 프로젝트는 자신감을 갖고 변경하고 리팩토링 할 수 있다.
  • 더 나은 문서자료
    • 하나의 행위만 집중해 검증하는 명확한 테스트는 마치 실행 가능한 문서와 같다.
  • 사려 깊은 설계
    • 새로 작성한 코드의 테스트를 작성하는 일은 실질적으로 해당 코드의 API가 잘 설계되었는지를 시험하는 행위이다.
  • 고품질의 릴리스를 빠르게
    • 건실한 자동 테스트 스위트를 갖춘 팀은 새로운 버전을 릴리즈하며 불안에 떨지 않는다.
  • 효율적인 협업
    • 팀 내 개발자 간의 지식 공유 도구로 사용될 수 있다.
    • 코드 리뷰와 신규 개발자 온보딩 과정에서 테스트 코드가 해당 기능의 동작 방식을 명확히 설명
    • 리뷰어가 변경된 코드가 제대로 작동하는지 검증하는 시간을 줄여준다.



JUnit이 뭔가요?

  • Java 단위 테스트 프레임워크
  • 코드 변경 시, 기존 기능이 깨지지 않았는지 빠르게 확인 가능
  • 자동 테스트 → 버그 줄이고, 리팩토링도 자신 있게 가능



[ 필요한 라이브러리 ]

요즘 Java 단위테스트 작성에는 크게 2가지 라이브러리가 사용된다.

  • JUnit5: 자바 단위 테스트를 위한 테스팅 프레임워크
  • AssertJ: 자바 테스트를 돕기 위해 다양한 문법을 지원하는 라이브러리

JUnit 만으로도 단위 테스트를 충분히 작성할 수 있다. 하지만 JUnit에서 제공하는 assertEquals()와 같은 메소드는 AssertJ가 주는 메소드에 비해 가독성이 떨어진다. 그렇기 때문에 순수 Java 애플리케이션에서 단위 테스트를 위해 JUnit5와 AssertJ 조합이 많이 사용된다.

이번 게시글(겸 챌린지반 과제)에서는 junit을 사용해서 테스트 코드를 작성하고자 한다.


의존성 추가 (Gradle)

// build.gradle
 // JUnit 5
testImplementation("org.springframework.boot:spring-boot-starter-test")
 // AssertJ
testImplementation 'org.assertj:assertj-core:3.25.3'
 
 
test {
    useJUnitPlatform()
}

💡 참고

현재는 Spring Boot 3.xx기반의 버전을 사용하지만,
실무에서는 2.xx 버전을 사용하는 Legacy Code가 남아있을 수 있다.
2.xx 버전에서는 위의 설정과는 다른 부분이 있으니 💡참고💡



[ given-when-then 전략 지키기 ]

  • 테스트 코드 작성시 given-when-then 패턴으로 주석과 함께 3등분해서 나누어서 작성
    • given(준비) - when(실행) - then(검증) 순으로 작성해야 가독성이 좋고 관리가 편리해진다.
  • given(준비):
    - 어떠한 데이터가 준비되었을 때.
    • 테스트에 사용하는 변수, 입력 값 등을 정의한다.
  • when(실행):
    - 어떠한 함수를 실행하면 ( 실제로 액션을 함 )
    • When 은 가장 중요한 구문이지만 가장 짧다.
  • then(검증):
    - 어떠한 결과가 나와야 한다.
    - 예상한 값, 실제 행동을 통해서 나온 값을 검증



[ 좋은 단위 테스트? - FIRST 규칙 ]

FIRST 규칙

1️⃣ Fast
2️⃣ Independent
3️⃣ Repeatable
4️⃣ Self-Validating
5️⃣ Timely

1️⃣Fast

좋은 단위 테스트는 실행이 빨라야한다.

단위 테스트는 내부 코드만 테스트할 때와 외부 자원을 다룰 경우의 실행 시간 차이가 크다.

모든 외부 자원을 다루어 한 테스트가 200ms를 소모할 때 2500개의 테스트를 수행한다면 8분이 걸리게 된다.

단위 테스트는 대상 시스템에 대해 지속적이고 빠르게 피드백을 주는데 가치가 있기 때문에 빨라야 한다.

단위 테스트의 실행이 오래 걸린다면 테스트 작성의 의미가 퇴색되는 것이므로 우리는 느린 테스트 코드를 지양해야 한다.

2️⃣Isolated

좋은 단위 테스트는 테스트하고자 하는 단위 기능에 집중해야 한다.
즉, 독립적이어야함 (진짜 DB를 갔다오는것도아니고, 진짜 API를 쓰는것도아님!!)

단위 테스트를 작성할 때 테스트하고자 하는 단위 기능을 명확하게 하지 않는다면 그 테스트는 하나 이상의 기능을 테스트 할 것이다.

단위 테스트가 통합 테스트보다 장점을 갖는 것은 하나의 테스트 당 하나의 기능만을 테스트하기 때문이다.

3️⃣Repeatable

좋은 단위 테스트는 반복적으로 수행하더라도 항상 같은 결과를 반환해야 한다.

그렇기 위해서는 결과가 어떻게 나올지 명확해야 하며 통제할 수 있어야 한다.

테스트 대상 코드의 나머지를 격리하고 Mock객체를 활용하는 방안을 사용해 이를 해결할 수 있다.

4️⃣Self-validating

좋은 단위 테스트는 기대하는 결과가 무엇인지 단언(assert)해야 한다.

테스트 결과를 검증할 때 System.out.println이나 log.info등을 이용해 직접 비교하며 검증할 수도 있다.

하지만 이렇게 되면 많은 비용을 지불하게 된다.

그렇기 때문에 JUnit에서 제공하는 assert와 같은 검증 코드를 이용해 검증하도록 한다.

5️⃣Timely

좋은 단위 테스트는 미루지 않고 즉시 작성한다.

단위 테스트는 소프트웨어 개발의 완성도, 품질을 높이는 좋은 습관이다.

만약 테스트를 제때 작성하지 않고 미루어 작성하지 않는다면 코드에 결함이 발생할 확률이 높아진다.

이런 점에서 TDD와 같은 프로세스가 등장하게 되었다.


[ 사용 어노테이션 ]

어노테이션쓸 수 있는 상황설명
@Test모든 테스트 메서드에 사용테스트임을 나타냄
@BeforeEach각 테스트 실행 전에 초기화 코드 작성할 때예: 공통 Mock 객체 초기화, 데이터 준비
@AfterEach 각 테스트 후 정리 코드 작성할 때예: 자원 정리, 로그 확인 등
@DisplayName테스트 메서드 설명 작성테스트 이름을 보기 좋게 커스터마이징
@Disabled일시적으로 테스트 제외할 때테스트 실행하지 않음 (개발 중 임시 스킵)
@ExtendWith(MockitoExtension.class)✅ Mockito 기반 테스트 시 필수@Mock, @InjectMocks 등 사용 가능하게 함

💡 참고 : JUnit 4 vs JUnit 5






적용하기

[ JUnit 기본 구조 이해 ]

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class CalculatorTest {

    @Test
    void testAdd() {
    	//given
        Calculator cal = new Calculator();
        
        //when
        int result = cal.add(2, 3);
        
        //then
        assertEquals(5, result); // 예상값 5와 실제값 비교
    }
}

assertEquals(expected, actual) : 예상값과 실제값이 같아야 통과

🍞 알아둬야 할 Assert 메서드

Assertions : 값 비교, null 여부 확인 등
|메서드 |설명|
|---|---|
|assertEquals(a, b) |a와 b가 같은지 검사|
|assertTrue(expr)|expr이 true인지 검사|
|assertNotNull(obj)| obj가 null이 아님을 검사|
|assertThrows(예외.class, () -> {...}) |예외 발생 테스트|

🔍 isEqualTo

기본적으로 assert 함수류들은 한눈에 보기 편해서 찾기도 쉬운거같은 느낌이다...
결과가 기대하는 값과 일치하는지 확인한다.

        assertThat(result.getUsername()).isEqualTo(username);

result.getUsername()username랑 같은가~~



🔍 assertThatThrownBy (예상하는 예외가 발생할 때!!1)

익셉션이 발생하는 케이스를 테스트할 때 사용한다.

assertThatThrownBy(() -> someMethod()).isInstanceOf(SomeException.class).hasMessage("오류 메시지");
  • 람다식 안에 있는 코드를 실행했을 때 예외가 발생해야 한다는 것을 검증
  • 실제로 예외가 던져지지 않으면 테스트는 실패함 (안에 있는 메시지가 달라도 실패..)
  • 예외가 던져졌다면, 그 타입과 메시지도 검증할 수 있음.



🔍 isInstanceOf(Class<.?>)

.isInstanceOf(StringIndexOutOfBoundsException.class)

앞에서 나온 예외가 괄호안의 예외랑 같은지 우선 비교함.
나는 지금 커스텀으로 예외 다 만들어줘서 그거 집어넣었다.
사실 사진보면 로그인했을때 복사해온거 그대로와서 지금 확인하고 급하게 고침 ㄷㄷ;;



문제점

생각해보니 암호화를 위한 클래스를 따로 떼놓지 않아서
가진 클래스들이 모두 다른 클래스들과 연동되어있다.
그러면 그 클래스 안의 함수들의 리턴값들도 다 알아야하고, 테스트를 위해서 값도 넣어줘야하는데 이부분이 골치다.



해결방법 ; Mock 객체

@Mock : 테스트할 클래스의 의존성(예: Repository)을 가짜로 만듦
UserController에 대한 테스트를 한다고 치면
UserService는 테스트 대상이 아니기때문에 mocking이 필요하다....


@InjectMocks : 실제 테스트할 클래스에 @Mock으로 만든 의존성을 주입

<의존성 추가하기>

    // Mockito (가짜 객체 만들기)
    testImplementation 'org.mockito:mockito-core:5.11.0'
    testImplementation 'org.mockito:mockito-junit-jupiter:5.11.0'



[ 테스트 인스턴스 만들기 ]

테스트 인스턴스란?

JUnit은 설정된 테스트 단위로 테스트 객체를 만듭니다. 이를 테스트 인스턴스라고 말한다.
테스트 실행 범위라고 생각하면 된다.

JUnit 5는 테스트 인스턴스 생성 기본 단위가 메소드이다.
각 메소드 별로 따로 인스턴스가 생성되어 테스트를 한다는 뜻입니다.

메소드 단위로 인스턴스가 만들어지면 테스트 간 영향이 없어 단위 테스트하기에 좋다.

메소드 끼리 영향을 주는 테스트 케이스를 테스트하려면 어떻게 해야 할까요? 그때 사용하는 게 @TestIntance입니다.

@TestInstance

테스트 인스턴스의 생성 단위를 변경하기 위해 사용하는 어노테이션
설명
테스트 인스턴스 생성 단위 설정

  • PER_METHOD : 메소드 단위 
  • (기본값)PER_CLASS : 클래스 단위



테스트 인스턴스 만들기

클래스에서 우클릭 + Generate

근데 회원가입을 성공 / 실패 하는 것으로 나눠서 짜야하니깐 사실 저 함수는 쓸모가없어졌다..


나같은경우는
가짜 객체인 UserRepository userRepository;가 필요하고,
UserServiceImpl 객체를 생성하면서, 위에서 선언한 userRepository mock을 주입해야한다.

그럼

    @Mock
    UserRepository userRepository;

    @InjectMocks
    UserServiceImpl userService;

이것들이 제일 먼저 필요함!!!
저런 @Mock, @InjectMocks 같은 어노테이션을 사용하려면 ㅌ클래스에서 위에서 말한
@ExtendWith(MockitoExtension.class) ㄴ어노테이션이 필요하다

그럼 클래스부분 선언은

@ExtendWith(MockitoExtension.class)
class UserServiceImplTest {

    @Mock
    UserRepository userRepository;

    @InjectMocks
    UserServiceImpl userService;

이렇게된다..



[ 단위 테스트 함수 작성하기 ]

테스트할 함수

@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService{

    private final ScheduleRepository scheduleRepository;
    private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
    private final UserRepository userRepository;

    /**
     * 회원 가입 메서드입니다.
     * 사용자의 정보를 받아 새로운 계정을 생성하고 저장한 후, 응답 DTO로 변환하여 반환합니다.
     * @param username 가입할 사용자의 아이디
     * @param password 가입할 사용자의 비밀번호
     * @param email 가입할 사용자의 이메일
     * @return 생성된 사용자의 정보를 담은 UserResponseDto 객체
     */
    @Override
    public UserResponseDto signUp(String username, String password, String email) {
        Optional<User> loginUser = userRepository.findByEmail(email);

        if(loginUser.isPresent()){
            throw new UnauthorizedActionException("회원가입이 불가능한 이메일입니다.");
        }

        //비밀번호 암호화
        String encodedPassword = encoder.encode(password);

        User user = new User(username,encodedPassword,email);
        User savedUser = userRepository.save(user);
        return new UserResponseDto(savedUser.getId(), savedUser.getUsername(), savedUser.getEmail(),
                savedUser.getCreateDate(), savedUser.getUpdateDate());
    }



등장하는 User 엔티티

이거 테스트 작성떄문에 Setter 적용해줫다ㅣ.

/**
 * JPA 사용을 위해 엔티티 등록을 해줍니다.
 * 사용하는 테이블 이름은 USER입니다.
 */
@Getter
@Entity
@Setter
@Table(name = "user")
public class User extends DateEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;


    @Column(nullable = false)
    private String username;


    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String email;

    public User(String username, String password, String email){
        this.username = username;
        this.password = password;
        this.email = email;
    }

    public User() {

    }

}



아 참고로 공통적으로 들어가는 이름, 이메일 내용으로

    String username;
    String password;

    @BeforeEach
    void setUp() {
        username = "테스트이름";
        password = "password123";
    }

이걸 클래스안에서 적용했다.

성공적인 회원가입

<전체 코드>

    void signUpSuccess() {
        // given
        String email = "test1@email.com";

        given(userRepository.findByEmail(email)).willReturn(Optional.empty());

        User user = new User(username, password, email);
        user.setId(1L); // 테스트용 ID 지정
        given(userRepository.save(any(User.class))).willReturn(user);

        // when
        UserResponseDto result = userService.signUp(username, password, email);

        // then
        assertThat(result.getUsername()).isEqualTo(username);
        assertThat(result.getEmail()).isEqualTo(email);
    }

설명 (내가 몰랐던거 위주 ㅇㅇ)

머리깨지는줄
//given

given(userRepository.findByEmail(email)).willReturn(Optional.empty());

userRepository.findByEmail(email) 이 함수를 사용하게되면
-> whillReturn 안의 내용이 반환될거야! (Optional.empty()) << 반환됨
성공적인 회원가입이기 때문에 텅텅비는것이 정상적임.

User user = new User(username, password, email);
user.setId(1L); // 테스트용 ID 지정

저장될 유저 객체 생성하는데, DB가 자동으로 해주는 게 아니라서 테스트에서 사용하려면 ID도 직접 지정해야 함

given(userRepository.save(any(User.class))).willReturn(user);

any(User.class): 어떤 User 객체든 상관없이 매치하는 Mockito의 헬퍼
save()가 호출되면 위에서 만든 user를 반환하도록(실제로 성공하면 유저 반환하니깐) 설정



실패하는 회원가입 - 이메일 중복

<전체 코드>

    @Test
    @DisplayName("회원가입 실패 - 이메일 중복")
    void signUpDuplicateEmailFail() {
        // given
        String email = "test1@email.com";

        User existingUser = new User(username, password, email);
        given(userRepository.findByEmail(email)).willReturn(Optional.of(existingUser));

        // when & then
        assertThatThrownBy(() -> userService.signUp(username, password, email))
            .isInstanceOf(UnauthorizedActionException.class)
            .hasMessage("404 NOT_FOUND \"회원가입이 불가능한 이메일입니다.\"");
    }

설명 (내가 몰랐던거 위주)

//given

given(userRepository.findByEmail(email)).willReturn(Optional.of(existingUser));

userRepository.findByEmail(email) 이 함수를 사용하게되면
-> whillReturn 안의 내용이 반환될거야! (existingUser) << 반환됨 유저가 존재한다는 뜻


//when & then

assertThatThrownBy(() -> userService.signUp(username, password, email))
    .isInstanceOf(ResourceNotFoundException.class)
    .hasMessage("404 NOT_FOUND \"회원가입이 불가능한 이메일입니다.\"");

signUp()을 호출하면 UnauthorizedActionException이 발생해야 함 <<<<<<<<<<<<
예외 타입과 메시지를 둘 다 검증함 (AssertJ의 assertThatThrownBy)





문제점


잘보면 나와야하는 메시지에 " 가 한번씩 더 들어가잇어서 일치하지안흔ㄴㄷ내다 ㅡ,.ㅡ,.ㅡ,.ㅡ

해결


어케 메시지 다시 붙이지 고민하다가 그냥 정규식으로 " 넣어줬다.

profile
대충 데굴데굴 굴러가는 개발?자

0개의 댓글