Spring
Spring Boot
Junit과 Test

JUnit

JUnit은 자바에서 단위 테스트를 수행하는 프레임워크이다.

LifeCycle Annotation

Test의 FIRST 원칙

  • Fast : 테스트는 빨라야 한다.
  • Independent : 테스트는 서로 의존하면 안된다.
  • Repeatable : 테스트는 매번 같은 결과를 만들어야 한다.
  • Self-Validating : 테스트는 그 자체로 실행하여 결과를 확인할 수 있어야 한다.
  • Timely : 단위 테스트는 비즈니스 코드가 완성되기 전에 구성하고 테스트가 가능해야 한다.
  • 코드가 완성되기 전에 테스트가 따라와야 한다는 TDD의 원칙을 담고 있다. (반드시 지켜야 하는 방식은 아니다.)

Test 용어

더미 객체

테스트 대상 클래스에 전달되지만 절대 사용되지 않는 객체이다.

페이크 객체

실제로 동작하는 구현체를 가지지만 훨씬 단순한 방법으로 동작한다.

스텁

테스트 과정에서 수행된 호출에 대해 하드 코딩된 응답을 제공한다. 페이크 객체와는 달리 스텁은 실제로 동작하는 구현체가 없다.

모의 객체

모의 객체는 모든 상호작용을 저장해서 나중에 단언문에 활용할 수 있도록 한다. getAllInvoices 메서드가 한 번만 호출되기 바라면 그것을 단언문으로 검증할 수 있다.

스파이

의존성을 감시한다.

AssertThrow

에러가 발생하는지 테스트한다.

    @Test
    @DisplayName("동일한 닉네임으로 두 번 저장하면 예외 발생하는지 테스트")
    void alreadyExistNickName() {
        // given
        Member member = MemberDummy.create();
        memberRepository.save(member);
        String nickname = "우하하";
 
        // when
        memberService.updateNickNameOrThrow(member, nickname);
 
        // then
        assertThatThrownBy(() -> memberService.updateNickNameOrThrow(member, nickname))
                .isInstanceOf(ApiException.class)
                .hasMessageContaining(MemberStatus.ALREADY_EXIST_NICKNAME.getMessage());
    }

Mocking

Mockito

mock(<class>)

: 주어진 클래스의 스텁을 생성한다.

when(<mock>.<method>(<args>)).thenReturn(<value>)

: 스텁화된 메서드의 동작을 정의하는 연속된 메서드 호출이다.

verify(<mock>).<method>(<args>)

: 단언문을 생성한다. 예를 들어 issuedInvoices의 all() 메서드가 호출되었는지 확인하고 싶은 경우 verify(invoiceService).all()을 호출한다.

예시

@SpringBootTest
public class AppleServiceTest {
 
  @Autowired
  MemberRepository memberRepository;
 
  @Autowired
  AppleService appleService;
 
  @Autowired
  AppleIDTokenValidator appleIDTokenValidator;
 
  /**
   * 모의개체 생성
   */
   @Mock
    private AppleIDTokenValidator mockAppleIDTokenValidator;
   @InjectMocks
  private AppleService mockAppleService;
 
 
    private AppleCredential mockAppleUserInfo;
    private AppleSignUpRequestDto validSignUpRequest;
    private AppleSignUpRequestDto invalidNameSignUpRequest;
 
    @BeforeEach
    void setUp() {
        // 테스트 실행 전 필요한 객체를 초기화
        mockAppleUserInfo = new AppleCredential();
 
        validSignUpRequest = new AppleSignUpRequestDto("validToken", "John Doe"); // 유효한 가입 요청 객체
        invalidNameSignUpRequest = new AppleSignUpRequestDto("validToken", null); // 이름이 유효하지 않은 가입 요청 객체
 
        // AppleIDTokenValidator의 extractAppleUserinfoFromIDToken 메서드가 "validToken"을 받으면 mockAppleUserInfo를 반환하도록 설정
        when(mockAppleIDTokenValidator.extractAppleUserinfoFromIDToken("validToken")).thenReturn(mockAppleUserInfo);
    }
 
    @Test
    @DisplayName("유효한 토큰으로 Apple User Info 받아올 수 있는지를 검증한다.")
    void validateRequestWithValidTokenReturnsUserInfo() {
        // 유효한 토큰과 이름으로 validateRequest 메서드를 호출하고 결과를 검증
        AppleCredential result = mockAppleService.validateRequest(validSignUpRequest);
 
        // 반환된 AppleUserInfo 객체가 기대한 이름과 이메일을 가지고 있는지 확인
        assertEquals("John Doe", result.getName());
    }
 
    @Test
    @DisplayName("이름이 없는 경우 회원가입 ApiException이 발생하는지 검증한다.")
    void validateRequestWithInvalidNameThrowsApiException() {
        // 이름이 유효하지 않을 때 validateRequest 메서드를 호출하면 ApiException이 발생하는지 확인
        assertThrows(ApiException.class, () -> {
            mockAppleService.validateRequest(invalidNameSignUpRequest);
        });
    }
 
 
  @Test
  @DisplayName("signInOrUp 메서드를 통해서 생성될 수 있는 회원의 이메일은 중복되지 않아야 한다.")
  public void signInOrUp() {
    // given
    AppleCredential appleUserInfo = AppleCredential.builder()
        .issuer("yourIssuer")
        .name("yourName")
        .sub("yourUniqueIdentifier")
        .clientId("yourClientId")
        .expiryTime("yourExpiryTime")
        .issuingTime("yourIssuingTime")
        .nonce("yourNonce")
        .email("yourEmail")
        .emailVerified(true)
        .build();
 
    // when
    appleService.signInOrUp(appleUserInfo);
    appleService.signInOrUp(appleUserInfo);
    List<Member> members = memberRepository.findByEmail(appleUserInfo.getEmail());
 
    // then
    assertEquals(members.size(), 1);
  }
}

verify

책으로 공부할 때는 무심코 넘겼던 주제 중에서 verify라는 메서드가 있었다.
원하는 메서드가 몇 번 수행했는지를 추적할 수 있는 메서드이다. 지금 프로젝트가 초기단계인데 벌써 쓸 일이 두 번이나 생겼으니 이에 대해서 기록해볼려고 한다.

사용법

  • Mockito.verifyNoMoreInteractions: 더 이상 메서드 호출이 없음을 검증합니다.
  • Mockito.verifyZeroInteractions: 메서드 호출이 없었음을 검증합니다.
  • Mockito.verifyInOrder: 메서드 호출 순서를 검증합니다.
  • Mockito.verify(mock, Mockito.atLeast(n)).method(arg1, arg2) : method 메서드가 최소 n 번 호출되었는지 검증합니다.

예시

일단 해당 메서드는 Mockito의 static Method로

public static <T> T verify(T mock) {
        return MOCKITO_CORE.verify(mock, times(1));
}

이렇게 정의가 되어 있다. 즉, 인자로 mocking한 class를 넘겨줘야 하는 것이다.

    private LolSearchAdapter mockLolSearch = mock(LolSearchAdapter.class);

위와 같이 mock 객체를 생성하고

    @Test
    @DisplayName("이미 저장된 게임이 있을 때 lolSearchAdapter를 통해서 검색하지 않는다")
    void searchAlreadySavedGame() {
        // given
        MatchRecord vo = MatchDummy.create();
        MatchGame game = matchGameService.save(vo);
        boardService = new BoardService(matchGameService, matchUserService, mockLolSearch);
        List<ParticipantRecord> participantVO = vo.info().participants();
        matchUserService.saveAll(participantVO, game);
 
        // when
        boardService.searchMatch(game.getGameId());
 
        // then
        verify(mockLolSearch, never()).searchMatch(anyString());
    }

다음과 같이 verify 키워드를 통해서 BoardService에 DI된 mocking된 객체가 호출되었는지를 확인할 수 있다.

Test Basic

아래와 같은 Repository를 테스트한다고 가정하자.

public class MemoryMemberRepository implements MemberRepository {
  private static Map<Long, Member> store = new HashMap<>();
  private static long sequence = 0L;
 
  @Override
  public Member save(Member member) {
    member.setId(++sequence);
    store.put(member.getId(), member);
    return member;
  }
 
  @Override
  public Optional<Member> findById(Long id) {
    return Optional.ofNullable(store.get(id));
  }
 
  @Override
  public Optional<Member> findByName(String name) {
    return store.values().stream()
            .filter(member -> member.getName().equals(name))
            .findAny();
  }
 
  @Override
  public List<Member> findAll() {
    return store.values().stream().collect(Collectors.toList());
  }
}

save() 테스트

이를 테스트하기 위해서 2가지 방법을 사용할 수 있다.
첫 째로 org.junit.test를 사용해서 테스트하는 방법이 있는데 이는 가독성이 떨어져서 잘 사용하지 않는다.

import org.junit.Assert;
 
/// 테스트 클래스가 퍼블릭일 이유는 없음
class MemoryMemberRepositoryTest {
  MemoryMemberRepository repository = new MemoryMemberRepository();
 
  @Test
  public void save() {
    Member member = new Member();
    member.setName("spring");
 
    repository.save(member);
 
    Member result = repository.findById(member.getId()).get();
 
    /// expected : 기대되는 값 / actual : 실제 값
    Assertions.assertEquals(member, result);
  }
}

org.assertj.core.api.Assertions라는 라이브러리를 사용해서 테스트를 진행하는데 이는 assertThat(검증할거).isEqualTo(기대하는거)와 같은 형식으로 사용할 수 있다. 개발하는 입장에서 좀 더 가독성이 좋다.

import org.assertj.core.api.Assertions;
class MemoryMemberRepositoryTest {
  MemoryMemberRepository repository = new MemoryMemberRepository();
 
  @Test
  public void save() {
    Member member = new Member();
    member.setName("spring");
 
    repository.save(member);
 
    Member result = repository.findById(member.getId()).get();
 
    Assertions.assertThat(member).isEqualTo(result);
  }
}

Test시 데이터 중복

아래 두 개의 테스트가 있다고 가정할 때

  @Test
  public void findByName() {
    Member member1 = new Member();
    member1.setName("spring1");
    repository.save(member1);
 
    Member member2 = new Member();
    member2.setName("spring2");
    repository.save(member2);
 
    Member result = repository.findByName("spring1")
        .get();
 
    assertThat(result).isEqualTo(member1);
  }
 
  @Test
  public void findAll() {
    Member member1 = new Member();
    member1.setName("spring1");
    repository.save(member1);
 
    Member member2 = new Member();
    member2.setName("spring1");
    repository.save(member2);
 
    List<Member> result = repository.findAll();
 
    assertThat(result.size()).isEqualTo(2);
  }

위 테스트를 동시에 실행하면 다음과 같은 문제가 발생한다.

이는 테스트를 실행하고나서 초기화 로직을 넣어주지 않아서 발생한다.

ℹ️

테스트는 순서와 상관없이 실행되어야 한다.

@AfterEach

@AfterEach Annotation은 각 테스트가 실행되고 종료될 때마다 실행될 메서드에 붙이는 Annotation이다.
이 Annotation을 이용해서 테스트가 끝날 때마다 repository.clearStore()를 실행하도록 하면 된다.

class MemoryMemberRepositoryTest {
  MemoryMemberRepository repository = new MemoryMemberRepository();
 
  @AfterEach
  public void afterEach() {
    repository.clearStore();
  }
}

TDD(Test-Driven Development)

테스트 주도 개발이란 Test Case를 먼저 만들고 이를 통과하는 코드를 작성하는 방식이다.

Tip

Reference