
개요
우리는 왜 테스트 코드를 도입해야 할까요? 테스트 코드를 도입하는 이유 는 여러가지가 있지만,
주된 이유는 코드의 품질을 높이고 유지보수성을 향상 시키기 위해서입니다.
개발을 하다 보면 API를 개발하고 나서 Postman과 같은 도구를 사용해 수동으로 테스트할 수 있습니다.
그렇다면 많은 개발자들이 시간을 들여 테스트 코드를 작성하는 이유는 무엇일까요?
물론, API 개발만 놓고 본다면 개발하고 나서 직접 테스트하는 게 더 빠를 수도 있습니다. 하지만 장기적인 관점에서 보면
테스트 코드를 작성하는 것이 훨씬 더 효율적이고 안전한 방법입니다. 이를 구체적으로 설명드리겠습니다.
테스트 코드를 작성해야 하는 이유
1. 자동화 된 테스트로 시간 절약 가능
Postman을 통해 수동으로 API를 테스트할 경우, 매번 API를 호출하고 결과를 눈으로 확인해야 합니다.
코드가 복잡해지고, 수정할 부분이 생길 때마다 모든 API를 수동으로 테스트하는 것은 매우 비효율적입니다.
테스트 코드를 작성하면, 단 몇 초 만에 수백 개의 테스트를 자동으로 실행할 수 있습니다. 새로운 기능을 추가하거나 기존
코드를 수정할 때, 모든 기능이 제대로 동작하는지 일일이 수동으로 확인할 필요 없이 테스트 코드로 간단하게
확인할 수 있습니다. 초기에는 시간이 더 걸리지만, 프로젝트가 커질수록 오히려 더 많은 시간을 절약할 수 있습니다.
우려사항: 테스트 코드를 작성하는 데 많은 시간이 걸리고, 특히 초기 단계에서는 속도가 느리다고 느낄 수 있습니다.
해결방안: 테스트 코드는 시간이 지남에 따라 자동화된 검증 수단이 되어 장기적으로 개발 속도를 가속화시킵니다. 특히
프로젝트가 커질수록 자동 테스트는 시간을 절약하고, 빠르게 기능을 검증할 수 있습니다.
2. 회귀 테스트로 안정성 확보
프로젝트가 발전하면서 새로운 기능을 추가하거나 기존 기능을 수정하는 일이 빈번하게 발생합니다.
이때 새로운 코드를 추가하면, 이전 기능이 망가질 가능성이 있습니다. 이를 **회귀 오류(regression)**라고 합니다.
테스트 코드가 있으면 새로운 코드를 추가하거나 수정할 때 기존 기능이 정상적으로 동작하는지 즉시 확인할 수 있습니다. Postman과 같은 도구로 모든 기능을 수동으로 테스트하는 것은 비효율적이지만, 테스트 코드는 한 번의 실행으로 수백 가지 테스트를 검증할 수 있습니다.
우려사항: 새로운 기능을 추가할 때 이전 기능이 망가질까 두려워 테스트를 작성하지 않는 경우가 있습니다. 테스트가 없으면 리팩토링이나 기능 추가가 꺼려질 수 있습니다.
해결 방안: 회귀 오류를 막기 위해서라도 테스트 코드 작성은 필수적입니다. 테스트가 기존 기능을 보호하기 때문에
안정적인 리팩토링과 추가 개발이 가능해집니다.
3. 단위 테스트의 중요성
단위 테스트(Unit Test)는 소프트웨어의 작은 부분, 즉 하나의 메소드나 클래스를 독립적으로 테스트 하는 것 입니다. 단위 테스트는 API 전체의
동작을 검증하는 것이 아니라, 각 기능이 개별적으로 정확하게 동작하는지를 확인합니다.
**단위 테스트(Unit Test)**는 코드의 작은 부분, 즉 하나의 메서드나 클래스를 독립적으로 테스트하는 것입니다. 단위 테스트는 API 전체의 동작을 검증하는 것이 아니라, 각 기능이 개별적으로 정확하게 동작하는지를 확인합니다.
예를 들어, 사용자의 포인트를 관리하는 기능이 있다고 가정할 때, 포인트를 더하거나 뺄 때 제대로 동작하는지, 포인트가 부족할 때 예외 처리가
잘 되는지를 작은 단위로 테스트할 수 있습니다.
우려사항1: 단위 테스트는 독립적으로 실행되어야 하지만, 외부 의존성(예: 데이터베이스, 파일 시스템, 네트워크 등)이 존재할 경우 테스트가 느려지거나 실패할 가능성이 있습니다.
해결 방안:
- Mocking 사용: 외부 의존성을 모의(Mock) 객체로 대체하여 의존성 문제를 피합니다. Mockito, JMock 등의 라이브러리를 활용하면 됩니다.
- Stub 사용: 특정 데이터를 반환하는 간단한 객체를 사용하여 외부 시스템과의 상호작용을 대체합니다.
우려사항2: 너무 많은 테스트 코드를 작성하면, 기능 변경 시 테스트 코드도 함께 수정해야 하므로 유지보수가 어려워질 수 있습니다.
해결방안:
- 중복 최소화: 테스트 코드에서 중복을 줄이고, 공통 로직은 헬퍼 메소드로 추출하여 유지보수성을 높입니다.
- 테스트 목적에 맞는 테스트 작성: 불필요하게 많은 테스트를 작성하기보다는, 주요 로직과 경계 조건을 중심으로 테스트를 작성합니다.
우려사항3: 테스트 코드가 구현 세부 사항에 너무 의존할 경우, 로직이 조금만 변경되어도 테스트가 깨질 수 있습니다.
해결방안:
- 결과 기반 테스트: 테스트는 메서드의 출력이나 행동에 집중해야 하며, 내부 구현 방식에는 최대한 의존하지 않도록 합니다.
- 테스트 리팩토링: 테스트와 코드가 너무 강하게 결합되지 않도록 리팩토링을 고려해야 합니다.
4. TDD란 무엇인가?
TDD(Test-Driven Development, 테스트 주도 개발)란 비즈니스 로직을 완전히 구현하기 전에 테스트를 먼저 작성하는 개발 방식입니다. TDD는 Red-Green-Refactor라는 세 가지 사이클을 반복하며 코드를 작성하는 것이 특징입니다.
TDD의 Red-Green-Refactor 사이클
- Red: 실패하는 테스트를 먼저 작성합니다. 아직 비즈니스 로직이 구현되지 않았기 때문에 이 테스트는 당연히 실패하게 됩니다. 이는 의도된 것으로, 테스트가 실패하면서 어떤 기능이 필요한지를 명확히 정의하게 됩니다.
- Green: 테스트를 통과하는 최소한의 코드를 작성합니다. 이 단계에서는 테스트를 통과할 수 있도록 아주 단순한 코드를 작성하는 것이 목적입니다. 모든 기능을 완벽하게 구현할 필요는 없으며, 일단 테스트가 통과되면 됩니다.
- Refactor: 테스트가 통과된 후에는 코드를 리팩토링하여 중복을 제거하고, 효율적이며 가독성 좋은 구조로 개선합니다. 이 과정에서는 테스트가 이미 통과된 상태이기 때문에 기능을 깨뜨리지 않고 코드 품질을 높일 수 있습니다.
TDD에서 중요한 것은 구현보다 테스트를 먼저 작성한다는 것입니다. 로직이 없는 상태에서 테스트를 작성하고, 그 테스트가 실패하도록 유도한 뒤, 필요한 최소한의 코드를 작성해 테스트를 통과시킵니다. 마지막으로 코드를 최적화하면서 지속적으로 테스트를 기반으로 개선해 나가는 것이 TDD의 본질입니다.
TDD의 장점
- 코드 품질 개선: 테스트를 미리 작성함으로써 코드가 요구 사항을 정확히 충족하는지 확실히 검증할 수 있습니다. 이는 기능이 잘 동작하는지 확인하는 것뿐만 아니라, 코드의 안정성과 신뢰성을 높이는 데 도움이 됩니다.
- 안정적인 리팩토링: TDD는 리팩토링 과정에서 코드의 동작이 깨지지 않도록 보호합니다. 이는 코드 변경 시 발생할 수 있는 회귀 오류를 방지해주며, 장기적인 유지보수에 큰 도움이 됩니다.
- 자동화된 테스트: 테스트 코드가 자동으로 실행되기 때문에, 코드가 변경될 때마다 수동으로 확인할 필요 없이 빠르게 검증할 수 있습니다.
TDD 도입 시 우려 사항
- 초기 시간 소모
우려 사항: TDD를 처음 도입하면 테스트를 먼저 작성해야 하므로 개발 속도가 더딜 수 있습니다.
마감 기한이 촉박할 때는 테스트 작성이 부담스럽게 느껴질 수 있습니다.
해결 방안: 작은 단위로 시작하여 TDD를 적용하도록 합니다. 작은 기능부터 테스트를 작성하고 이를 기반으로
점진적으로 확장해 나가면, TDD에 대한 부담을 줄일 수 있습니다. - 테스트 설계의 어려움
우려 사항: 무엇을, 어떻게 테스트할지 결정하는 것이 어렵게 느껴질 수 있습니다. 특히 잘못된 테스트 설계는 코드 변경에 걸림돌이 될 수
있습니다
해결 방안: 장기적인 이점을 고려하고, 테스트 설계에 익숙해지기 위해서는 시간이 필요합니다. 단기적으로는 테스트 설계가 어렵더라도,
테스트가 장기적으로 코드 품질을 보호하고 유지보수성을 높여줍니다.
그럼에도 사람들이 TDD를 사용하지 않는 이유는 무엇일까?
1. 개발 속도를 높이기 위해 테스트를 건너뛰는 경우가 많습니다. 특히 촉박한 프로젝트나 일정이 있을 때 TDD의 초기 설정이 시간 낭비처럼 보일 수 있습니다.
2. TDD에 대한 경험 부족이나 익숙해지기까지의 시간 소모, 테스트 설계가 복잡하게 느껴지는 것도 주요 이유 중 하나입니다.
결론
- 테스트 코드를 작성하는 것은 처음에는 시간이 더 들고 불필요해 보일 수 있지만, 장기적으로 볼 때 프로젝트의
안정성, 유지보수성, 코드 품질을 높이는 필수적인 과정입니다. Postman 같은 도구를 통해 수동으로 테스트하는
것도 유효하지만, 테스트 코드가 제공하는 자동화, 회귀 테스트, 리팩토링의 안정성, 문서화 같은 이점을 생각해보면,
테스트 코드를 작성하는 것이 왜 중요한지 알 수 있을 것 입니다.
서론이 길었습니다. 본격적으로 테스트 코드를 작성해보도록 하겠습니다.
테스트 코드 작성 공통 준수 사항
- 보통 테스트를 위한 라이브러리로 Junit과 AssertJ 조합을 사용하여 테스트 진행을 합니다
- Given / When / Then 패턴
- Given : 어떠한 데이터가 주어질 때.
- When : 어떠한 기능을 실행하면
- Then : 어떠한 결과를 기대한다.
@Test
@DisplayName("Test")
void test() {
// Given
// When
// Then
}
Mockito를 사용한 단위 테스트
- 모키토는, 개발자가 동작을 직접적으로 제어할 수 있는 가짜 객체를 지원하는 테스트 프레임워크입니다.
- Spring 어플리케이션은 여러 객체들 간의 의존성이 생기는데 이러한 의존성을 모키토를 이용하여
단절 시킴으로 단위 테스트를 쉽게 작성하는 것을 도와줍니다.
// MemberController.java
@RestController
@RequestMapping("/point")
@RequiredArgsConstructor
public class PointController {
private static final Logger log = LoggerFactory.getLogger(PointController.class);
private final PointService pointService;
/**
* 특정 유저의 포인트를 충전하는 컨트롤러를 예로 들겠습니다.
*/
@PatchMapping("{id}/charge")
public UserPoint charge(
@PathVariable long id,
@RequestBody long amount
) {
return pointService.chargeUserPoint(id, amount);
}
}
public interface PointService {
UserPoint chargeUserPoint(long id, long amount);
}
- TDD 기 때문에 일단 비즈니스 로직을 완전히 구현하지 않고 (구현체 impl 클래스는 아직 작성하지 않음.)
테스트를 먼저 작성하도록 하겠습니다 - 이번 예제에서 PointService 인터페이스와 PointServiceImpl 구현체를 사용한 이유는,
테스트 코드의 유연성과 유지보수성을 높이기 위해서입니다. 구체적으로 설명하자면:
1. 인터페이스를 통한 유연한 설계
인터페이스를 사용함으로써, 우리는 구체적인 구현에 의존하지 않고 시스템을 설계할 수 있습니다. 테스트 코드에서
**구체적인 구현(Impl 클래스)**보다는 **인터페이스(Contract)**에 의존하는 것이 좋다고 생각했습니다. 그 이유는 다음과 같습니다
- 구현체를 나중에 교체하거나 확장할 때의 유연성: PointService 인터페이스를 사용하면, 다양한 구현체
(예: PointServiceImpl)를 자유롭게 바꿀 수 있습니다. 만약 포인트 충전 방식이 바뀌거나 다른 로직이 필요해진다면, 구현체만 새로 만들어서 교체할 수 있습니다. 테스트 코드도 이런 구조를 따라가게 되면, 테스트 코드 역시 쉽게 적응할 수 있습니다. 즉, 코드의 변경이 필요할 때, 테스트 코드 자체를 대폭 수정하지 않아도 된다는 장점이 있습니다.
2. 테스트의 독립성
구체적인 구현체를 사용하지 않고, 인터페이스를 통해 테스트를 구성하면 테스트 코드의 독립성이 강화됩니다.
테스트는 특정 비즈니스 로직만 검증해야지, 구현체의 복잡한 로직까지 다루면 안 된다고 생각했습니다.
인터페이스를 통해 구현체와 테스트 코드 간의 결합도를 낮추면, 테스트 코드가 더욱
유연하고 재사용 가능 할 수 있게 설계가 가능해집니다.
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.junit.jupiter.api.Assertions.*;
class PointServiceTest {
@Mock
private PointService pointService;
private UserPoint userPoint;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
// 초기 포인트를 100으로 설정된 유저로 세팅
userPoint = new UserPoint(1L, 100, System.currentTimeMillis());
}
@Test
void 포인트충전_성공케이스() {
long userId = 1L;
long amount = 50L;
// Red 단계: 테스트가 실패해야 함
// 테스트에서 검증하려는 조건이 구현되지 않거나 로직이 올바르게 동작하지 않을 때
// Red 단계의 목적은 테스트가 실패하는 것을 확인하는 것
// 실패 원인: 아직 chargeUserPoint 로직이 제대로 구현되지 않음
// Green 단계: 포인트 충전 후 새로운 상태 반환 설정
// 최소한의 코드로 테스트를 통과시키기 위한 로직 추가
when(pointService.chargeUserPoint(userId, amount))
.thenReturn(new UserPoint(userId, userPoint.point() + amount, System.currentTimeMillis()));
// 포인트 충전 후 결과 확인
UserPoint result = pointService.chargeUserPoint(userId, amount);
assertEquals(150, result.point());
// 메서드가 정확한 인자로 호출되었는지 검증
verify(pointService).chargeUserPoint(userId, amount);
}
@Test
void 포인트충전시_유저가존재하지_않을때() {
long userId = 999L;
long amount = 50L;
// Red 단계: 테스트가 실패해야 함
// 해당 유저가 존재하지 않는 경우 예외가 발생해야 함
// 실패 원인: 아직 해당 유저에 대한 처리 로직이 없기 때문에 실패
// Green 단계: 유저가 존재하지 않는 경우 예외 발생 설정
when(pointService.chargeUserPoint(userId, amount))
.thenThrow(new IllegalArgumentException("유저가 존재하지 않습니다."));
// 예외가 발생하는지 확인
Exception exception = assertThrows(IllegalArgumentException.class, () ->
pointService.chargeUserPoint(userId, amount));
// 예외 메시지 검증
assertEquals("유저가 존재하지 않습니다.", exception.getMessage());
}
@Test
void 포인트_충전금액이_0_이하일때() {
long userId = 1L;
long invalidAmount = 0L;
// Red 단계: 충전 금액이 0 이하일 때 예외가 발생해야 함
// 실패 원인: 아직 로직이 구현되지 않음
// 예외 발생을 확인하는 테스트 작성
// Green 단계: 충전 금액이 0 이하일 때 예외 발생 설정
when(pointService.chargeUserPoint(userId, invalidAmount))
.thenThrow(new IllegalArgumentException("충전 금액은 0보다 커야 합니다."));
// 예외가 발생하는지 확인
Exception exception = assertThrows(IllegalArgumentException.class, () ->
pointService.chargeUserPoint(userId, invalidAmount));
// 예외 메시지 검증
assertEquals("충전 금액은 0보다 커야 합니다.", exception.getMessage());
}
@Test
void 충전포인트가_음수일때_예외발생() {
long userId = 1L;
long chargeAmount = -200L;
// Red 단계: 충전 금액이 음수일 때 예외가 발생해야 함
// 실패 원인: 아직 해당 로직이 구현되지 않음
// Green 단계: 충전 금액이 음수일 때 예외 발생 설정
when(pointService.chargeUserPoint(userId, chargeAmount))
.thenThrow(new IllegalArgumentException("포인트 합계가 잘못되었습니다. 비정상적인 금액을 충전하려고 합니다."));
// 예외가 발생하는지 확인
Exception exception = assertThrows(IllegalArgumentException.class, () ->
pointService.chargeUserPoint(userId, chargeAmount));
// 예외 메시지 검증
assertEquals("포인트 합계가 잘못되었습니다. 비정상적인 금액을 충전하려고 합니다.", exception.getMessage());
}
@Test
void 포인트충전후_포인트합계조회() {
long userId = 1L;
long chargeAmount = 200L;
// Red 단계: 테스트가 실패해야 함
// 충전 후 올바른 포인트 합계가 반환되지 않으면 테스트 실패
// 아직 충전 로직이 구현되지 않아서 실패해야 함
// Green 단계: 포인트 충전 후 새로운 상태 반환 설정
when(pointService.chargeUserPoint(userId, chargeAmount))
.thenReturn(new UserPoint(userId, userPoint.point() + chargeAmount, System.currentTimeMillis()));
// 포인트 충전 후 결과 확인
UserPoint result = pointService.chargeUserPoint(userId, chargeAmount);
assertEquals(300L, result.point());
// 메서드 호출 검증
verify(pointService).chargeUserPoint(userId, chargeAmount);
}
@Test
void 포인트충전후_포인트히스토리저장확인() {
long userId = 1L;
long chargeAmount = 100L;
// Red 단계: 테스트가 실패해야 함
// 히스토리가 저장되지 않으면 테스트 실패
// 아직 히스토리 저장 로직이 구현되지 않아서 실패해야 함
// Green 단계: 충전 후 포인트 업데이트 상태 반환 설정
when(pointService.chargeUserPoint(userId, chargeAmount))
.thenReturn(new UserPoint(userId, userPoint.point() + chargeAmount, System.currentTimeMillis()));
// 포인트 충전 호출
pointService.chargeUserPoint(userId, chargeAmount);
// 포인트 충전 메서드 호출 확인
verify(pointService).chargeUserPoint(userId, chargeAmount);
// 여기서 추가적으로 포인트 히스토리 저장을 mock으로 확인할 수 있습니다.
// 히스토리 저장 로직이 PointService에 있는 경우라면, 추가적으로 아래와 같은 검증이 가능합니다.
// verify(pointHistoryRepository).save(eq(userId), eq(chargeAmount), eq(TransactionType.CHARGE), anyLong());
}
}
다음과 같이 어떤 실패케이스가 있을지 미리 생각 후 테스트 코드를 작성하도록 합니다.
**단일 책임 원칙(Single Responsibility Principle)**에 따라, 메서드를 역할별로 분리하여 가독성을 높여서
비즈니스 로직을 작성해보겠습니다.
public record UserPoint(
long id,
long point,
long updateMillis
) {
public static UserPoint empty(long id) {
return null;
}
// 포인트 충전 및 검증 로직을 UserPoint로 이동
public UserPoint addPoints(long amount) {
if (amount <= 0) {
throw new IllegalArgumentException("충전 포인트는 0보다 커야 합니다.");
}
long newPoint = this.point + amount;
if (newPoint < 0) {
throw new IllegalArgumentException("포인트 합계가 잘못되었습니다. 비정상적인 금액을 충전하려고 합니다.");
}
return new UserPoint(this.id, newPoint, System.currentTimeMillis());
}
// 포인트 차감 로직 (차감할 때 유효성 검증 포함)
public UserPoint subtractPoints(long amount) {
if (amount <= 0) {
throw new IllegalArgumentException("사용할 포인트는 0보다 커야 합니다.");
}
long newPoint = this.point - amount;
if (newPoint < 0) {
throw new IllegalArgumentException("포인트가 부족합니다.");
}
return new UserPoint(this.id, newPoint, System.currentTimeMillis());
}
}
public record PointHistory(
long id,
long userId,
long amount,
TransactionType type,
long updateMillis
) {
// 포인트 히스토리 생성 로직을 책임지도록 팩토리 메서드 추가
public static PointHistory create(long userId, long amount, TransactionType type) {
return new PointHistory(
System.currentTimeMillis(), // 고유 ID를 시간으로 임시 생성 (필요에 따라 수정 가능)
userId,
amount,
type,
System.currentTimeMillis()
);
}
// PointHistory를 저장하는 메서드
public void save(PointHistoryRepository pointHistoryRepository) {
pointHistoryRepository.save(this.userId, this.amount, this.type, this.updateMillis);
}
}
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class PointServiceImpl implements PointService {
private final UserPointRepository userPointRepository;
private final PointHistoryRepository pointHistoryRepository;
@Override
public UserPoint chargeUserPoint(long id, long amount) {
// 유저 포인트 조회를 UserPoint 객체로 위임
UserPoint userPoint = UserPoint.findById(id, userPointRepository);
// 포인트 충전 로직을 UserPoint 객체로 위임
UserPoint updatedUserPoint = userPoint.addPoints(amount);
// 포인트 히스토리 저장
PointHistory pointHistory = PointHistory.create(id, amount, TransactionType.CHARGE);
pointHistory.save(pointHistoryRepository);
// 업데이트된 포인트 저장
userPointRepository.saveOrUpdate(id, updatedUserPoint.point());
return updatedUserPoint;
}
}
이제 짜여진 비즈니스 로직 (실제구현체) 를 가지고 단위 테스트를 진행해보겠습니다.
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 static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(MockitoExtension.class) // Mockito 확장을 통해 Mockito가 테스트에서 사용할 목업 객체를 주입해줄 수 있도록 설정
class PointServiceImplTest {
@Mock
private UserPointRepository userPointRepository; // UserPointRepository를 목(mock) 객체로 만듦. 테스트에서 실제 구현이 아닌 목 객체를 사용하기 위함.
@Mock
private PointHistoryRepository pointHistoryRepository; // PointHistoryRepository도 목 객체로 만듦.
@InjectMocks
private PointServiceImpl pointService; // 목 객체들을 주입받을 구현체 객체를 생성. 실제 테스트할 대상 클래스.
@Test
public void 포인트충전_성공케이스() {
// given
long userId = 1L;
long currentAmount = 1000L;
long chargeAmount = 500L;
UserPoint userPoint = new UserPoint(userId, currentAmount, System.currentTimeMillis());
UserPoint updatedUserPoint = new UserPoint(userId, currentAmount + chargeAmount, System.currentTimeMillis());
// when
when(userPointRepository.findById(eq(userId))).thenReturn(userPoint); // Mock을 통해 기존 포인트 정보 반환
when(userPointRepository.saveOrUpdate(eq(userId), eq(currentAmount + chargeAmount)))
.thenReturn(updatedUserPoint); // Mock을 통해 업데이트된 포인트 정보 반환
// then
UserPoint result = pointService.chargeUserPoint(userId, chargeAmount);
// 충전 후 결과 검증
assertNotNull(result);
assertEquals(currentAmount + chargeAmount, result.point()); // 포인트 합계 검증
// saveOrUpdate와 findById가 올바르게 호출되었는지 검증
verify(userPointRepository).saveOrUpdate(eq(userId), eq(currentAmount + chargeAmount));
verify(userPointRepository, times(1)).findById(eq(userId));
// 포인트 히스토리가 올바르게 저장되었는지 검증
verify(pointHistoryRepository).save(eq(userId), eq(chargeAmount), eq(TransactionType.CHARGE), anyLong());
}
@Test
public void 유저존재하지않음_예외케이스() {
// given
long userId = 999L; // 존재하지 않는 유저 ID
long chargeAmount = 100L;
// when
when(userPointRepository.findById(eq(userId))).thenReturn(null); // 유저가 존재하지 않을 때 null 반환
// then
Exception exception = assertThrows(IllegalArgumentException.class, () -> pointService.chargeUserPoint(userId, chargeAmount));
assertEquals("유저가 존재하지 않습니다.", exception.getMessage());
// 유저가 존재하지 않을 때 saveOrUpdate는 호출되지 않아야 함
verify(userPointRepository, never()).saveOrUpdate(anyLong(), anyLong());
verify(pointHistoryRepository, never()).save(anyLong(), anyLong(), any(), anyLong());
}
@Test
public void 충전금액이_0_이하일때_예외케이스() {
// given
long userId = 1L;
long chargeAmount = 0L; // 유효하지 않은 충전 금액 (0 이하)
UserPoint userPoint = new UserPoint(userId, 1000L, System.currentTimeMillis());
// when
when(userPointRepository.findById(eq(userId))).thenReturn(userPoint); // 유저가 존재한다고 가정
// then
Exception exception = assertThrows(IllegalArgumentException.class, () -> {
pointService.chargeUserPoint(userId, chargeAmount);
});
assertEquals("충전 포인트는 0보다 커야 합니다.", exception.getMessage());
// 충전 금액이 0 이하일 때 saveOrUpdate는 호출되지 않아야 함
verify(userPointRepository, never()).saveOrUpdate(anyLong(), anyLong());
verify(pointHistoryRepository, never()).save(anyLong(), anyLong(), any(), anyLong());
}
@Test
public void 포인트합계가_음수일때_예외케이스() {
// given
long userId = 1L;
long currentAmount = -2L;
long chargeAmount = 1L; // 포인트 합계가 음수가 될 수 있는 금액
UserPoint userPoint = new UserPoint(userId, currentAmount, System.currentTimeMillis());
// when
when(userPointRepository.findById(eq(userId))).thenReturn(userPoint); // 유저 포인트 정보를 반환
// then
Exception exception = assertThrows(IllegalArgumentException.class, () -> {
pointService.chargeUserPoint(userId, chargeAmount);
});
// 음수가 되기 전에
assertEquals("포인트 합계가 잘못되었습니다. 비정상적인 금액을 충전하려고 합니다.", exception.getMessage());
// 포인트 합계가 음수일 때 saveOrUpdate는 호출되지 않아야 함
verify(userPointRepository, never()).saveOrUpdate(anyLong(), anyLong());
verify(pointHistoryRepository, never()).save(anyLong(), anyLong(), any(), anyLong());
}
}
마지막으로 실제 Spring 컨텍스트를 로드하여 통합테스트를 수행해보겠습니다.
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest(classes = TddApplication.class) // Spring 컨텍스트를 로드하여 통합 테스트 수행
@ExtendWith(SpringExtension.class)
public class PointServiceIntegrationTest {
@Autowired
private PointService pointService; // 실제 서비스 사용
@Autowired
private UserPointRepository userPointRepository; // 실제 리포지토리 사용
@Test
public void 포인트충전_성공케이스() {
// given
long userId = 1L;
long currentAmount = 1000L;
long chargeAmount = 500L;
// 미리 유저 포인트를 저장
userPointRepository.saveOrUpdate(userId, currentAmount);
// when
UserPoint updatedUserPoint = pointService.chargeUserPoint(userId, chargeAmount);
// then
assertNotNull(updatedUserPoint);
assertEquals(currentAmount + chargeAmount, updatedUserPoint.point());
// 실제 DB에 저장된 포인트 확인
UserPoint resultUserPoint = userPointRepository.findById(userId);
assertEquals(currentAmount + chargeAmount, resultUserPoint.point());
// 포인트 히스토리도 실제로 저장되었는지 확인
// 추가적인 히스토리 검증 로직 필요
}
@Test
public void 포인트충전시_유저가존재하지_않을때() {
// given
long saveUserId = 1L;
long saveChargeAmount = 1000L;
long userId = 999L;
long chargeAmount = 500L;
// 미리 임의 유저 포인트를 저장
userPointRepository.saveOrUpdate(saveUserId, saveChargeAmount);
// when
// 현재 저장되지 않은 유저를 조회 후 예외가 발생하는지 확인
Exception exception = assertThrows(IllegalArgumentException.class, () ->
pointService.chargeUserPoint(userId, chargeAmount));
// then
// 예외 메시지 검증
assertEquals("유저가 존재하지 않습니다.", exception.getMessage());
}
@Test
public void 충전금액이_0_이하일때_예외케이스() {
// given
long userId = 1L;
long chargeAmount = 0L; // 유효하지 않은 충전 금액 (0 이하)
// 미리 유저 포인트를 저장
userPointRepository.saveOrUpdate(userId, 1000L);
// when
Exception exception = assertThrows(IllegalArgumentException.class, () -> {
pointService.chargeUserPoint(userId, chargeAmount);
});
// then
assertEquals("충전 포인트는 0보다 커야 합니다.", exception.getMessage());
}
@Test
public void 포인트합계가_음수일때_예외케이스() {
// given
long userId = 1L;
long currentAmount = -2L;
long chargeAmount = 1L; // 포인트 합계가 음수가 될 수 있는 금액
// 미리 유저 포인트를 저장
userPointRepository.saveOrUpdate(userId, currentAmount);
// when
Exception exception = assertThrows(IllegalArgumentException.class, () -> {
pointService.chargeUserPoint(userId, chargeAmount);
});
// then
assertEquals("포인트 합계가 잘못되었습니다. 비정상적인 금액을 충전하려고 합니다.", exception.getMessage());
}
}
결론 및 느낀 점
이번 개발을 통해 TDD와 단위 테스트의 중요성에 대해 배우고 있지만, 아직 완벽히 이해하지 못한 부분이 있습니다.
테스트 주도 개발 방식으로 비즈니스 로직을 작성하면서 예외 상황과 경계 조건을 미리 검증하는 과정에서
코드의 안정성과 신뢰성을 높일 수 있다는 이론은 알게 되었지만, 실제로 이를 어떻게 적용해야 할지에 대해
좀 더 깊이 있는 공부가 필요하다는 것을 느꼈습니다. 또한, 모킹을 활용한 단위 테스트로 의존성 객체를 격리하고
각 메서드의 동작을 독립적으로 검증할 수 있다는 점도 배웠지만, 아직은 충분히 숙달되지 않은 것 같습니다.
- 참고차료
https://catsbi.oopy.io/b13326d7-7b96-4a33-a974-3024c64eff5f
TDD 개론, 간단한 예제
목차
catsbi.oopy.io
https://tech.kakaopay.com/post/implementing-tdd-in-practical-applications/
실전에서 TDD하기 | 카카오페이 기술 블로그
TDD가 무엇인지 모르는 사람은 없습니다. 그런데 왜 하는 사람은 얼마 없을까요?
tech.kakaopay.com
'개발 > Spring' 카테고리의 다른 글
서킷브레이커는 또 무엇일까... (1) | 2024.11.28 |
---|---|
Spring과 Kafka를 활용한 트랜잭셔널 아웃박스 패턴 구현 (0) | 2024.11.21 |
MSA 아키텍처로의 전환을 고려한 트랜잭션 처리 및 이벤트 기반 설계 (3) | 2024.11.15 |
Spring 애플리케이션에서 로깅 구현하기 (feat. SLF4J) (0) | 2024.07.12 |
스프링 간단한 커스텀 인증 필터 구현하기 (0) | 2024.06.26 |
깨굴딱지의 코드연못입니다
올챙이가 개구리로 거듭나듯, 끊임없는 노력으로 진화하는 개발자의 길을 걷습니다. 🐸