테스트
비즈니스 로직과 제약 사항을 제대로 준수하고 있는지 확인하기 위해 테스트를 ‘필수적으로’ 작성해야 한다.
테스트는 시간이 남으면 작성하는 것이 아닌, 우리의 소프트웨어가 제대로 작동하는 것인지 보증해주는 문서와 동일하게 간주하여 꼼꼼하게 작성해야 한다.
특히, `예외가 발생하는 테스트`는 반드시 작성해야 한다.
도메인 테스트 작성하기
경계값이나 (ex. 0), 실패하는 값을 넣고 예외가 제대로 발생하는지 확인한다.
public class Point {
private int amount;
public Point(int amount) {
validateAmount(amount);
this.amount = amount;
}
public void plus(int amount) {
this.amount += amount;
}
public void minus(int amount) {
validateAmount(this.amount - amount);
this.amount -= amount;
}
private void validateAmount(int amount) {
if (amount < 0)
throw new IllegalArgumentException("포인트는 음수가 될 수 없습니다.");
}
}
도메인 클래스 `Point`에는 유효성을 검증하는 `validateAmout` 메서드가 있다.
이렇게 예외가 발생할 수 있는 메서드는 테스트 코드를 작성하는 것이 좋다.
intellij에서는 클래스 이름을 클릭한 상태로 `ctrl + shift + t` 를 누르면 테스트 클래스를 생성할 수 있다.
class PointTest {
@Test
@DisplayName("Point 값에 음수 값을 넣으면 예외가 발생한다.")
void point() throws Exception {
//given
int wrongValue = -1000;
//when
//then
assertThatThrownBy(() -> new Point(wrongValue))
.isInstanceOf(IllegalArgumentException.class);
}
}
`@Test` 어노테이션은 JUnit에게 이 메서드가 테스트 메서드임을 알려준다.
`@DisplayName` 어노테이션은 테스트 메서드의 설명을 지정한다. 테스트 결과 보고서에 이 설명이 표시된다.
`throws Exception`은 이 메서드가 예외를 던질 수 있음을 나타낸다.
`//given` 은 테스트의 준비 단계임을 나타내며 여기서는 잘못된 값(-1000)을 변수에 저장한다.
`//when` 은 테스트의 실행 단계를 나타내지만, 이 테스트에서는 when 부분이 생략되어 있다!
대신 `//then` 부분에서 곧바로 실행과 검증이 이루어진다.
이 테스트는 Point 클래스가 음수 값을 생성자로 받았을 때 `IllegalArgumentException`을 던지는지 확인한다. 만약 예외가 발생하지 않거나 다른 예외가 발생하면 테스트는 실패한다. `assertThatThrownBy`는 주어진 람다 표현식을 실행했을 때 던져지는 예외를 검증하는 AssertJ의 메서드이다. `assertThatThrownBy` 메서드를 통해 정확한 예외가 발생했는지 확인할 수 있다. `isInstanceOf()`로 예외의 타입이 예상한 것과 일치하는지 검증한다.
여기서 Java의 람다 표현식이 사용되는데 람다 표현식은 기존의 익명 내부 클래스를 간결하게 표현할 수 있으며, 함수를 하나의 메서드로 표현하는 간단한 방법을 제공한다. 함수를 매개변수로 전달할 때, 특히 함수형 인터페이스를 사용하는 경우, 람다 표현식을 사용한다.
`함수형 인터페이스`는 하나의 추상 메서드만을 가진 인터페이스를 말한다. Java에서는 이러한 인터페이스를 함수형 인터페이스라고 부르며, 람다 표현식을 이용해 이 인터페이스의 추상 메서드를 구현할 수 있다.
public static AbstractThrowableAssert<?, ? extends Throwable> assertThatThrownBy(ThrowingCallable shouldRaiseThrowable) {
return assertThat(catchThrowable(shouldRaiseThrowable)).hasBeenThrown();
}
`assertThatThrownBy` 메서드를 살펴보면 매개변수로 `ThrowingCallable` 인터페이스를 받고 있다.
public interface ThrowingCallable {
void call() throws Throwable;
}
그리고 해당 인터페이스는 함수형 인터페이스이다! 위에서 말한 경우인 것!!! 따라서 해당 인터페이스를 람다 표현식을 통해 구현한다.
람다 표현식의 기본 구조는 다음과 같다.
(parameter1, parameter2, ...) -> { body }
`parameter`: 람다 표현식이 사용할 매개변수 목록이다. 매개변수가 없을 시 빈 괄호를 사용한다.
`->`: 람다 화살표로, 매개변수 선언과 표현식의 몸체를 구분 짓는다.
`{body}`: 람다 표현식의 실행 코드 블록이다.
결국 () 안의 파라미터를 가지고 { } 안의 코드를 실행시켜 이름 없는 함수를 만들어 사용할 수 있다.
assertThatThrownBy(() -> new Point(wrongValue))
.isInstanceOf(IllegalArgumentException.class);
위의 테스트 코드에서는 예외를 발생시키는 메서드를 람다 표현식을 통해 매개변수로 전달하고 있으며 기존 매개변수 함수형 인터페이스를 람다 표현식을 통해 구현하고 있다.
서비스 테스트 작성하기
package com.arin.togetherlion.copurchasing.service;
import com.arin.togetherlion.copurchasing.domain.ProductTotalCost;
import com.arin.togetherlion.copurchasing.domain.ShippingCost;
import com.arin.togetherlion.copurchasing.domain.dto.CopurchasingCreateRequest;
import com.arin.togetherlion.copurchasing.repository.CopurchasingRepository;
import com.arin.togetherlion.user.domain.User;
import com.arin.togetherlion.user.repository.UserRepository;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import java.time.LocalDateTime;
@DataJpaTest
class CopurchasingServiceTest {
@Autowired
private CopurchasingRepository copurchasingRepository;
@Autowired
private UserRepository userRepository;
private CopurchasingService copurchasingService;
@BeforeEach
void setUp() {
copurchasingService = new CopurchasingService(copurchasingRepository, userRepository);
}
@Test
@DisplayName("존재하지 않는 사용자가 게시물을 작성할 시 예외가 발생한다.")
void testName() throws Exception {
//given
Long notExistedUserId = 0L;
final CopurchasingCreateRequest request = CopurchasingCreateRequest.builder()
.title("title")
.productMinNumber(1)
.productTotalCost(new ProductTotalCost(10000))
.purchasePhotoUrl("url")
.tradeDate(LocalDateTime.of(2024, 6, 25, 0, 0, 0))
.deadlineDate(LocalDateTime.of(2024, 6, 23, 0, 0, 0))
.productMaxNumber(5)
.content("content")
.productUrl("url")
.shippingCost(new ShippingCost(3000))
.writerId(notExistedUserId)
.build();
//when
//then
Assertions.assertThatThrownBy(() -> copurchasingService.create(request))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("사용자는 공동구매 게시물을 작성할 수 있다.")
void create() throws Exception {
//given
final User user = User.builder()
.email("email")
.password("password")
.nickname("nickname")
.build();
Long userId = userRepository.save(user).getId();
final CopurchasingCreateRequest request = CopurchasingCreateRequest.builder()
.title("title")
.productMinNumber(1)
.productTotalCost(new ProductTotalCost(10000))
.purchasePhotoUrl("url")
.tradeDate(LocalDateTime.of(2024, 6, 25, 0, 0, 0))
.deadlineDate(LocalDateTime.of(2024, 6, 23, 0, 0, 0))
.productMaxNumber(5)
.content("content")
.productUrl("url")
.shippingCost(new ShippingCost(3000))
.writerId(userId)
.build();
//when
final Long copurchasingId = copurchasingService.create(request);
//then
Assertions.assertThat(copurchasingRepository.existsById(copurchasingId)).isTrue();
}
}
`@DataJpaTest`: JPA 레포지토리와 관련된 빈들을 모두 활성화하는 테스트를 작성할 수 있다.
우리는 기존에 생성자를 이용한 주입 방식을 사용해왔는데 여기 테스트에서는 `@Autowried`를 사용하여 주입하고 있다.
`CopurchasingServiceTest`는 스프링에서 `Bean`으로 간주하고 있지 않기 때문에 굳이 생성자 주입으로 빈을 주입해줄 필요가 없다. (물론 생성자 주입을 사용할 수 있다.) 그래서 비교적 편리하고 간단한 `Autowried`를 통해 생성자를 주입한다.
`@BeforeEach`: 각 테스트 전에 실행되는 메서드를 만든다. `setUp` 메서드는 서비스 빈을 초기화 시킨다.
@Test
@DisplayName("존재하지 않는 사용자가 게시물을 작성할 시 예외가 발생한다.")
void testName() throws Exception {
//given
Long notExistedUserId = 0L;
final CopurchasingCreateRequest request = CopurchasingCreateRequest.builder()
.title("title")
.productMinNumber(1)
.productTotalCost(new ProductTotalCost(10000))
.purchasePhotoUrl("url")
.tradeDate(LocalDateTime.of(2024, 6, 25, 0, 0, 0))
.deadlineDate(LocalDateTime.of(2024, 6, 23, 0, 0, 0))
.productMaxNumber(5)
.content("content")
.productUrl("url")
.shippingCost(new ShippingCost(3000))
.writerId(notExistedUserId)
.build();
//when
//then
Assertions.assertThatThrownBy(() -> copurchasingService.create(request))
.isInstanceOf(IllegalArgumentException.class);
}
해당 메서드는 존재하지 않는 사용자가 게시물을 작성할 때 예외가 정상적으로 발생하는 지 확인한다.
@Test
@DisplayName("사용자는 공동구매 게시물을 작성할 수 있다.")
void create() throws Exception {
//given
final User user = User.builder()
.email("email")
.password("password")
.nickname("nickname")
.build();
Long userId = userRepository.save(user).getId();
final CopurchasingCreateRequest request = CopurchasingCreateRequest.builder()
.title("title")
.productMinNumber(1)
.productTotalCost(new ProductTotalCost(10000))
.purchasePhotoUrl("url")
.tradeDate(LocalDateTime.of(2024, 6, 25, 0, 0, 0))
.deadlineDate(LocalDateTime.of(2024, 6, 23, 0, 0, 0))
.productMaxNumber(5)
.content("content")
.productUrl("url")
.shippingCost(new ShippingCost(3000))
.writerId(userId)
.build();
//when
final Long copurchasingId = copurchasingService.create(request);
//then
Assertions.assertThat(copurchasingRepository.existsById(copurchasingId)).isTrue();
}
`Assertions.assertThat`은 AssertJ 라이브러리에서 제공하는 메서드로, 특정 조건을 검증하는 데 사용된다.
`Assertions.assertThat`은 `copurchasingRepository.existsById(copurchasingId)`가 `true`를 반환하는지를 검증한다.
`isTure()/isFalse()` 외에도 다양한 메서드가 존재한다.
`isEqualTo()`: 결과가 기대값과 일치하는지 검증한다.
`isNull()`: 결과가 null인지 검증한다.
`hasSize()`: 결과의 크기가 기대값과 일치하는지 검증한다.
`contatins()`: 결과가 특정 요소를 포함하는지 검증한다.
예전에 인강을 들으며 테스트 코드를 작성한 적이 있었는데, 그때는 단순히 따라치기만 해서 코드의 로직을 정확히 이해하지 못했다.
하지만 이번에 블로그에 정리해 보니 이해가 확실히 되는 것 같다.. 아직 외워서 치지는 못하지만 알고 치는 것과 모르고 치는 것은 다르니까! 의미가 있다고 생각한다!!
'공부 > Project' 카테고리의 다른 글
Springboot JPA + QueryDSL 사용 시 no property found for type 오류 해결 (0) | 2024.08.31 |
---|---|
JPA 게시물 조회 시 N+1 문제 해결방법 (feat. FetchType.LAZY) (0) | 2024.07.16 |
@Builder 패턴을 사용하는 이유 (0) | 2024.06.21 |
어노테이션을 사용하는 방법 vs 별도의 유효성 검사 메서드를 만드는 방법 (0) | 2024.06.20 |
같이사자 프로젝트 다시 시좍 (0) | 2024.06.20 |