[Spring Boot] 좋은 테스트 코드 : 필요성 및 실천 방법론 개요
테스트 코드의 필요성
수동 테스트의 문제점
수동으로 테스트 한다는 것은 곧 사람이 직접 테스트를 수행한다는 것이다. 특정한 케이스를 직접 넣어보고, 출력이나 로그 따위를 살펴보거나 디버깅을 해보는 행위, 우리가 늘상 해오던 과정이다.
그러나 사람이란 누구든 실수를 하며, 편협된 시각을 가지고 있다. 이로 인해 커버하지 못하는 영역이 있을 수 있으며, 이는 늦은 피드백, 유지보수의 난이도 상승, 소프트웨어 신뢰성 하락으로 이어진다.
테스트 코드의 필요성
테스트 코드의 정체성은 "빠른 피드백", "자동화", "안정성", "공유"로 압축할 수 있다.
빠른 피드백 : 개발자는 Production Code(실제 코드)와 Test Code 사이의 피드백을 통해 품질을 개선해 나갈 수 있다.
- 개발자가 코드를 작성하고 수정할 때 즉각적으로 피드백을 제공해 준다.
- 코드에 버그가 있는지, 새로운 변경 사항이 기존 기능을 망가뜨리지 않았는지 빠르게 확인 가능하다.
자동화 : 테스트 케이스의 수행에 대한 루틴을 정립하여 자동화할 수 있다.
- 새로운 요구사항으로 인해 기존 코드가 수정되더라도 테스트 코드가 작성되어 있으므로 처음부터 다시 테스트를 할 필요가 없다.
- 이는 빠른 시간 안에 버그를 발견할 수 있게 하고, 수동 테스트에 비해서 비용을 절약하고 누락되는 케이스를 방지할 수 있다.
안정성: 다양한 시나리오에서 코드가 예상대로 작동하는지 확인하여 소프트웨어의 품질을 유지하고 향상시킬 수 있다.
- 사람의 수동적인 테스트의 불안정함을 보완할 수 있다.
- 코드의 변경이 의도하지 않은 문제를 야기하지 않도록 한다.
공유: "테스트 코드는 곧 문서이다"
- 결국 프로젝트는 "팀"이 한다. 개인이 작성한 코드의 개발 의도를 팀에게 공유할 수 있다는 것은 매우 큰 이점이다.
- 테스트 코드에 의도와 기대하는 결과를 명확히 정의하여 개발의 의도를 명확히 전달하는 수단이 될 수 있다.
- 다양한 테스트 케이스를 통해 프로덕션 코드를 이해하는 시각과 관점을 보완할 수 있다.
- 개인의 고민의 결과물을 팀 차원으로 승격시켜서 모두의 자산으로 공유할 수 있다.
테스트의 종류
단위 테스트(Unit Test)
- 작은 코드 단위를 독립적으로 검증하는 테스트
- 소프트웨어의 가장 작은 단위인 모듈, 함수, 클래스 등의 개별적인 기능을 테스트하는 것
- 주로 프로그래머가 작성하며, 코드의 동작을 검증하고 예상대로 동작하는지 확인한다.
- 코드 변경 시에 기존 코드의 충돌 및 오류를 발견 가능하며, 새로운 코드의 동작의 성공 여부를 확인할 수 있다.
- TDD에서의 테스트 케이스는 주로 단위 테스트 작성을 의미한다. 그리고 주로 자동화되어 사용된다(CI).
- CI, Continuous Integration (지속적 통합): 개발자들이 작업한 코드를 지속적으로 통합하여 통합 문제를 최소화하고, 코드 변경이 일어날 때마다 자동으로 빌드 및 테스트가 실행되며, 코드베이스에 통합시키도록 한다.
- CD, Continuous Deployment (지속적 배포) : CI를 통과한 이후, 자동화된 프로세스를 통해 테스트를 통과한 코드를 실제 환경으로 자동으로 프로덕션 환경에 배포하는 파이프라인 구축을 목적으로 한다.
통합 테스트(Integration Test)
- 단위 테스트에서 독립적인 기능을 확인했다면, 통합 테스트에서는 모듈 간의 연계된 동작을 확인한다.
- 각각의 모듈이 제대로 상호작용하는지, 시스템의 구성 요소들이 올바르게 통합되어 동작하는지를 확인한다.
부하 테스트(Stress Test)
- 소프트웨어가 예상되는 최대 부하를 견딜 수 있는지를 검증한다.
- 시스템에 과도한 부하를 가하여 응답 시간, 처리량, 자원 사용량 등을 측정하고, 성능 문제를 파악 및 최적화할 수 있다.
좋은 테스트 코드
좋은 테스트 코드에는 사실 정답이 없다고 생각한다. 우리 조직에게 가장 잘 맞는 방식이 좋은 방식일 테니 말이다.
그렇지만 "비효율적인 테스트 코드"는 오히려 프로젝트의 병목이 된다. 프로덕션 코드의 안정성을 제공하기 힘들어지고, 테스트 코드 자체가 유지보수가 어려운 새로운 짐이 될 수 있다.
그래도 일반적으로 "좋은 테스트 코드"가 되기 위해서 고려되고 있는 포인트들을 한번 짚고 넘어가면 좋을 것 같다.
1. FIRST 원칙
2. 한 가지 테스트에는 한 가지의 개념만을 테스트한다.
3. 단일 모듈을 독립적으로 검증하고 의존성을 격리한다.
4. 테스트의 이름과 설명은 명확하고 구체적으로 작성한다.
FIRST 원칙
일반적으로 좋은 테스트 코드로 평가받는 테스트들은 FIRST 원칙을 따른다.
- Fast (빠르게): 빠르게 실행되어야 한다. 테스트가 느리면 개발자들이 자주 실행하지 않을 수 있다.
- Isolated (독립적으로): 한 테스트의 실패가 다른 테스트에 영향을 주어서는 안 된다.
- Repeatable (반복 가능하게): 동일한 환경에서 여러 번 실행해도 일관된 결과를 보여야 한다.
- Self-Validating (자가 검증적으로): 테스트는 성공 또는 실패를 명확하게 보여줘야 하며, 자동화되어야 한다.
- Timely (적시에): 테스트하려는 코드를 구현하기 이전에 구현해야 한다.
한 가지 테스트에는 한 가지의 개념만을 테스트한다.
단일 검증: 각 테스트는 하나의 개념에 대해 집중하여 단일 검증을 수행한다. 여러 검증을 하나의 테스트에 포함시키지 않는다. 여러 개념을 한 테스트에서 검증하면 문제가 발생했을 때 원인을 파악하기 어려워진다.
- 테스트 코드 안에서 반복문, 분기문 등을 사용하는 것은 여러가지의 테스트 케이스를 하나의 테스트 코드에 넣은 것은 지양해야 한다.
- @ParameterizedTest : 테스트 케이스는 1개지만 값을 여러개로 두고 테스트가 필요한 경우 사용할 수 있다.(ex : 모든 상품 타입에 대해서 검증)
의미 있는 시나리오: 각 테스트는 특정한 입력과 기대 결과를 명확히 설정한다.
- 특히 given에서 최대한 다른 기능 사용을 최소화하여야 한다. → 간결하고 명확하게, 주어진 데이터가 무엇인지 코드만 보고 알아보기 쉽도록 작성해야 한다.
- 생성자나 Builder 기반으로 데이터 생성하는 것이 좋다. 팩토리 메서드도 지양하는 것이 좋다.
테스트 이름: 테스트 함수의 이름을 통해 테스트의 목적을 명확하게 드러내야 한다. 테스트 코드는 개발의 의도를 공유할 수 있는 "문서"의 역할을 할 수 있다고 하였다.
- 기준 : DisplayName을 한 문장으로 정의할 수 있는가?
아래의 예시를 한번 살펴보자.
class Calculator {
public int add(int a, int b) {
return a + b;
}
public int subtract(int a, int b) {
return a - b;
}
public int multiply(int a, int b) {
return a * b;
}
public int divide(int a, int b) {
if (b == 0) throw new ArithmeticException("Cannot divide by zero");
return a / b;
}
}
public class CalculatorTest {
@Test
void testCalculatorOperations() {
Calculator calculator = new Calculator();
// 덧셈 검증
int result = calculator.add(2, 3);
assertEquals(5, result);
// 음수 덧셈 검증
result = calculator.add(-2, -3);
assertEquals(-5, result);
// 뺄셈 검증
result = calculator.subtract(10, 5);
assertEquals(5, result);
// 곱셈 검증
result = calculator.multiply(2, 3);
assertEquals(6, result);
// 나눗셈 검증
result = calculator.divide(10, 2);
assertEquals(5, result);
// 0으로 나누었을 때의 나눗셈 검증
assertThrows(ArithmeticException.class, () -> calculator.divide(10, 0));
}
}
각 테스트는 여러 가지 연산과 케이스를 동시에 검증하고 있다. 이러한 구조는 테스트가 실패했을 때 원인을 정확히 파악하기 어렵게 만든다. 만약 해당 테스트가 실패했을 경우 어떤 연산이 실패했는지 명확히 알기 어렵다.
또한 해당 테스트 코드는 어떤 기능을 수행하는 것인지, 시나리오가 명확하지 않아 개발자의 의도를 파악할 수 있는 문서의 역할을 수행하기 어렵다.
개선
public class CalculatorTest {
private final Calculator calculator = new Calculator();
@Test
void testAdditionWithPositiveNumbers() {
assertEquals(5, calculator.add(2, 3));
}
@Test
void testAdditionWithNegativeNumbers() {
assertEquals(-5, calculator.add(-2, -3));
}
@Test
void testSubtraction() {
assertEquals(5, calculator.subtract(10, 5));
}
// ...
}
이런 식으로 하나의 시나리오 케이스에 대한 테스트를 정의하여 각 테스트가 하나의 개념만을 검증하며, 실패 시 원인 파악이 용이하고 유지보수성 향상을 도모한다.
단일 모듈을 독립적으로 검증하고 의존성을 격리한다.
위의 이야기와 "단일 책임 원칙"의 유사한 개념이지만 이번에는 모듈 간의 개념이다.
테스트의 독립성 보장 : 하나의 테스트가 다른 테스트의 결과에 영향을 미쳐서는 안 된다. 특히 공유 자원 사용은 지양하는 것이 좋다. (given 데이터를 전역 변수로 선언하여 사용 → X)
- @BeforeAll, @BeforeEach(setUp)의 사용? : 사용을 최소화하는 것이 좋다.
- 주로 Given Data가 겹치는 경우 중복을 제거하는 목적으로 사용되고는 한다. → 독립성을 보장하는 것과는 Trade-off
- 테스트 코드를 보았을 때 Before 메서드의 내용을 보지 않고 이해하기 어렵다면 사용을 지양하는 것이 좋다.
- 세팅 과정에서 각각의 테스트 케이스에서 필요없는 데이터까지 준비해야 하므로 비용이 증가한다.
- 사용해도 좋은 경우
- 각 테스트 입장에서 아예 몰라도 테스트 내용을 이해하는 데 문제가 없는 경우
- 값이 바뀌어도 다른 테스트에 영향이 없는 경우
단일 책임 원칙 : 하나의 테스트 단위는 하나의 테스트 케이스에 집중해야 한다.
문제 식별 용이: 문제가 발생했을 때 해당 코드 단위에서 쉽게 원인을 찾을 수 있어야 한다.
외부 의존성 격리 : 하나의 테스트 단위가 하나의 모듈에 집중할 수 있도록 외부 의존성을 격리해야 한다.
아래의 예시를 한번 살펴보자.
public class UserService {
// ...
public User createUser(String username, String password) {
User user = new User(username, password);
User savedUser = userRepository.save(user);
boolean success = emailService.sendWelcomeEmail(user); // 외부 시스템에 의존
if(success){
return savedUser;
}
else{
throw new IllegalArgumentException("이메일 전송에 실패했습니다");
}
}
}
public class EmailService {
public boolean sendWelcomeEmail(User user) {
// 이메일 전송 로직
return true;
}
}
위의 코드를 회원을 생성하면, 데이터베이스에 저장하고 외부 시스템을 활용하여 이메일로 가입 메시지를 전송하는 역할을 하는 코드를 작성했다고 가정하자.
// ...
@SpringBootTest
public class UserServiceTest {
// ...
@Test
public void testCreateUser() {
// given
String username = "testUser";
String password = "testPass";
User user = new User(username, password);
// when
User createdUser = userService.createUser(username, password);
// then
assertNotNull(createdUser);
}
}
- 우리가 "회원 서비스 모듈" 대해서 단위 테스트 코드를 작성할 때 해당 테스트 코드의 결과는 오로지 "회원 서비스 모듈"의 작동에 집중하여 좌지우지 되어야 유지보수하기 편리해진다.
- 그렇지만 userService의 createUser()를 호출할 때, 외부 시스템과 연동된 모듈인 EmailService의 이메일 발송 성공 여부에 UserService에 대한 테스트 결과가 영향을 받는다.
- 테스트 코드의 결과가 "외부 시스템"에 의해서 영향을 받는다면 테스트 코드의 실패 시 우리는 고쳐야 할 대상이 "회원 모듈"이라고 단정할 수 없다.
개선
// ...
public class UserServiceTest {
@Mock
private EmailService emailService;
@InjectMocks
private UserService userService;
// ...
@Test
public void testCreateUser() {
// given
String username = "testUser";
String password = "testPass";
User user = new User(username, password);
// when
when(emailService.sendWelcomeEmail(any(User.class))).thenReturn(true); //mocking
User createdUser = userService.createUser(username, password);
// then
assertNotNull(createdUser);
}
}
테스트 코드가 하나의 모듈에 대해서만 "단일 책임 원칙"을 가지게 하기 위해서는 외부 의존성에 대한 격리가 필요하다는 것을 알 수 있다.
- Mocking과 Stubbing : 외부 의존성을 격리하기 위해 Mock 객체나 Stub을 사용하여 독립적인 테스트 환경을 만들 수 있다.
- Mockito는 Spring 진영에서 단위 테스트를 작성할 때 사용되는 인기 있는 Mock 라이브러리이다.
외부 의존성에 대해서 실제 Bean을 주입받는 대신 Mock 객체를 사용하여 해당 외부 의존성에 대해 모방하여 사용할 수 있도록 한다. 즉 외부 요인이 아니라, 테스트 주체에 집중할 수 있도록 한다. - 위의 코드의 예시에서는 userRepository에 대한 의존성을 Mock 객체로 대체하여, 의존하는 모듈의 메서드에 대한 행동을 정의해두어 사용하였다. 이렇게 하여 의존성을 격리시킬 수 있다.
<함께 보기> Mock을 사용한 테스트 의존성 분리
https://sjh9708.tistory.com/219
테스트의 이름과 설명은 명확하고 구체적으로 작성한다.
테스트 코드는 문서의 역할, 즉 의도와 기대하는 결과를 명확히 정의하여 개발의 의도를 명확히 전달하는 수단이라고 하였다.
다양한 입력 및 상황을 처리하여 프로덕션 코드의 동작을 보완적으로 설명한다. 이 때 명확한 테스트의 이름과 설명은 이를 전달하기 위해 중요하다.
DisplayName
테스트의 이름과 설명을 DisplayNam에 명확하게 기술한다.
- 명사의 나열보다 문장으로 표현하기
음료 1개 추가 테스트-> 음료 1개를 추가할 수 있다.
- 테스트 행위에 대한 결과 기술
음료 1개를 추가할 수 있다-> 음료 1개를 추가하면 주문 목록에 담긴다
- 도메인 용어를 사용하여 추상화된 내용을 담기
특정 시간 이전에 주문을 생성하면 실패한다→ 영업 시작 시간 이전에는 주문을 생성할 수 없다- 테스트 결과가 실제 요구 사항과 어떻게 연결되는지를 표현하기 위해 도메인 레벨의 추상화를 권장한다(특정 시간 -> 영업 시작 시간).
- 테스트의 현상을 중점으로 기술하지 않는다("실패한다"는 테스트에 집중된 언어이다)
BDD 스타일 (Behavior-Driven Development)
TDD에서 파생된 방법이며 테스트를 시나리오 중심으로 작성하여 원활한 커뮤니케이션을 목표로 한다.
함수 단위의 테스트에 집중하기보다, 시나리오에 기반한 테스트 케이스(TC) 자체에 집중하여 테스트한다.
개발자가 아닌 사람도 이해할 수 있을 정도의 정보 추상화 레벨을 권장한다. 즉 비기술적인 사람까지도 이해할 수 있는 형식으로 테스트를 작성하여, 팀 전체가 테스트를 잘 이해하고 활용할 수 있게 하는 것이다.
Given-When-Then 형식을 사용하여 명확한 행위의 프로세스를 정의할 수 있다.
- Given: “어떤 환경에서”
- When: “어떤 행동을 진행했을 때”
- Then: “어떤 상태 변화가 일어난다”
@DisplayName("주문 목록에 담긴 상품들의 총 금액을 계산할 수 있다.")
@Test
void calculateTotalPrice(){
// given
CafeKiosk cafeKiosk = new CafeKiosk();
Americano americano = new Americano();
Latte latte = new Latte();
cafeKiosk.add(americano);
cafeKiosk.add(latte);
// when
int totalPrice = cafeKiosk.calculateTotalPrice();
// then
assertThat(totalPrice).isEqualTo(7500);
}
테스트 코드 환경 통합하기
테스트 수행도 곧 비용이다. Spring Context를 로드 횟수가 증가한다는 것은 곧 전체 테스트의 시간이 증가한다는 것이다.
- 테스트 코드에서 사용되는 공통 환경, @ActiveProfiles (프로파일 지정) 등을 통합해야 한다.
- Layer 별로 공통 테스트 환경으로 사용할 수 있는 추상 클래스를 정의하여 사용한다.
@WebMvcTest(controllers = {
OrderController.class,
ProductController.class
})
public abstract class ControllerTestSupport {
@Autowired
protected MockMvc mockMvc;
@Autowired
protected ObjectMapper objectMapper;
@MockBean
protected OrderService orderService;
@MockBean
protected ProductService productService;
}
class ProductControllerTest extends ControllerTestSupport {
@DisplayName("신규 상품을 등록한다.")
@Test
void createProduct() throws Exception {
// given
ProductCreateRequest request = ProductCreateRequest.builder()
.type(ProductType.HANDMADE)
.sellingStatus(ProductSellingStatus.SELLING)
.name("아메리카노")
.price(4000)
.build();
// when
// then
mockMvc.perform(
post("/api/v1/products/new")
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(print())
.andExpect(status().isOk());
}
}
TDD(Test-Driven Development, 테스트 주도 개발)
전통적 방식의 한계
기존에는 프로젝트 전체에 대한 철저한 계획을 구상한 뒤, 장기간에 걸쳐 많은 비용을 투입하여 개발을 완수하였다. 이러한 계획주도 개발 방식은 소프트웨어의 규모가 커지고 복잡해짐에 따라서 한계에 봉착하게 되었다. 개발하는 인간은 언제나 완벽할 수 없었으며, 소프트웨어는 예측 불가능하며, 요구사항은 점차 유동적이고 복잡해져 갔다.
애자일 프로그래밍(Agile programming)과 익스트림 프로그래밍(eXtream Programming, XP)
소프트웨어 개발 방법론 중 하나로, 빠르게 변화하는 요구 사항에 대응하기 위해 유연하고 적응성 있는 접근 방식을 제공한다. 애자일 프로그래밍은 전통적인 방식의 소프트웨어 개발에서 발생하는 비효율성과 불필요한 문서 작성, 엄격한 계획에 대한 의존을 줄이려고 했다.
미래에 대한 예측 대신, 지속적으로 프로토타입을 완성하고, 이 때, 소단위 요구사항을 덧붙여서 점차 확장해나가는 수평적으로 개발하는 방법론이다.
이 때, 작은 실험과 피드백이라는 개념이 생겨났으며, 이는 작은 단위의 실험을 통해서 가설을 검증하고 피드백을 통해 프로토타입을 지속적으로 개선하는 방법이다.
익스트림 프로그래밍은 애자일 프로그래밍 개발 방법론 중 하나이다.
주요 특징으로는 짧은 개발 주기, 간단한 설계, 테스트 주도 개발(TDD), 지속적 통합(CI) 등이 있을 수 있다.
TDD(Test-Driven Development, 테스트 주도 개발)
TDD는 익스트림 프로그래밍의 주요 특징 중 하나이다. 기존에는 개발이 이루어진 다음 테스트 케이스 작성 및 테스트를 작성했다.
반면 테스트 주도 방법은 테스트 케이스를 먼저 작성한 다음 테스트 케이스에 따라서 실제 개발을 수행하는 방법이다.
작은 단위의 기능을 테스트하고 작성하기 때문에 피드백 주기가 짧으며 코드가 변경되어도 테스트를 수행하여 기존 요구사항에 대한 영향을 파악하고, 오류를 방지할 수 있다. 따라서 코드의 품질이 향상되고, 유지보수성이 향상된다.
RED-GREEN-REFACTOR 방식은 테스트 주도 개발의 핵심 프로세스이다.
1. RED: 실패하는 테스트 작성
- 현재 요구 사항에 대한 테스트를 작성하고 그 테스트가 실패하도록 한다.
- 현재로서는 기능이 아직 구현되지 않았거나 요구 사항이 충족되지 않았기 때문에 작성한 테스트가 실패하게 된다.
- 실패하는 테스트는 개발할 기능의 명확한 기준을 제공하는 역할을 하고 개발자는 이 기준을 충족시키기 위해 코드를 작성한다.
2. GREEN: 테스트를 통과시키기 위해 코드 작성
- 작성한 테스트를 통과시키기 위해 최소한의 코드를 작성한다.
- 이때 코드의 품질이나 최적화는 고려하지 않고 단순히 기능이 동작하도록 하는 데 집중한다.
3. REFACTOR: 코드 리팩토링
- 코드가 테스트를 통과하는 것을 확인한 후, 코드의 구조와 품질을 개선한다.
- 기존의 테스트가 여전히 통과하는지 확인하여 리팩토링 과정에서 기능이 손상되지 않도록 한다.
4. 반복
- 해당 사이클을 반복시키면서 새로운 테스트를 작성하고 그 테스트를 통과시키기 위해 코드를 작성하며, 코드를 개선한다.
- 이 때 개발자는 Production Code와 Test Code 사이에서의 반복되는 피드백을 통해 소프트웨어의 품질 향상을 기대할 수 있다.
테스트 케이스의 결정
요구사항 탐색
- 암묵적인 요구사항이나 문서화되지 않은 요구사항이 있을 수 있다. 테스트 케이스를 산정할 때 이러한 요구사항을 발견하고 명확히 하는 과정이 필요하다.
- 개발 과정에서 자연스럽게 나타나는 규칙이나 예외 사항을 인지해야 한다.
- 이를 위해서 개발관점 뿐만 아니라 도메인 관점에서 요구사항을 분석하는 것이 중요하다.
해피 케이스와 예외 케이스 구분
- 해피 케이스 (Happy Path): 시스템이 예상대로 동작하는 정상적인 시나리오를 검증한다.
- 예외 케이스 (Edge Cases): 시스템의 경계 조건 또는 예외 상황에서의 동작을 검증한다.
- 경계값 분석: 테스트 케이스를 결정할 때 유용한 방법이다. 데이터의 범위나 구간에서의 경계값을 중심으로 케이스를 나누어 시스템이 모든 경계에서 올바르게 동작하는지 확인할 수 있다. (범위, 구간, 날짜 등)
public class CafeKiosk {
// ...
public void add(Beverage beverage, int count){
if(count <= 0){
throw new IllegalArgumentException("음료는 1잔 이상 주문하실 수 있습니다");
}
for(int i = 0; i < count; i++){
beverages.add(beverage);
}
}
}
@Test
// 음료 여러개 주문 : Happy Case(Path)
void addSeveralBeverage(){
CafeKiosk cafeKiosk = new CafeKiosk();
Americano americano = new Americano();
cafeKiosk.add(americano, 2);
assertThat(cafeKiosk.getBeverages().get(0)).isEqualTo(americano);
assertThat(cafeKiosk.getBeverages().get(1)).isEqualTo(americano);
}
@Test
// 음료 여러개 주문 : Edge Case (경계값 테스트)
void addZeroBeverage(){
CafeKiosk cafeKiosk = new CafeKiosk();
Americano americano = new Americano();
assertThatThrownBy(() -> cafeKiosk.add(americano, 0))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("음료는 1잔 이상 주문하실 수 있습니다");
}
테스트하기 쉬운 영역과 어려운 영역의 구분
순수 함수 (Pure Functions): 동일한 입력에 대해 항상 동일한 출력을 반환하고, 함수 외부의 상태를 변경하지 않는 함수이다. 이러한 함수는 테스트하기 쉽다.
테스트하기 어려운 영역
- 외부 상태 변경 : 표준 출력, 메시지 발송, 데이터베이스에 기록하기 등
- 관측할 때마다 다른 값에 의존하는 코드 : 현재 날짜/시간, 랜덤 값, 전역 변수/함수, 사용자 입력 등
아래의 코드를 예시로 살펴보자.
public static final LocalTime SHOP_OPEN_TIME = LocalTime.of(10, 0);
public static final LocalTime SHOP_CLOSE_TIME = LocalTime.of(22, 0);
public Order createOrder(){
LocalDateTime currentDateTime = LocalDateTime.now();
LocalTime currentTime = currentDateTime.toLocalTime();
if(currentTime.isBefore(SHOP_OPEN_TIME) || currentTime.isAfter(SHOP_CLOSE_TIME)){
throw new IllegalArgumentException("주문 시간이 아닙니다. 관리자에게 문의하세요.");
}
return new Order(LocalDateTime.now(), beverages);
}
해당 비즈니스 로직은 10시부터 22시 사이에만 주문이 가능하게 하고 그 외 시간대에서는 주문이 불가능하도록 구현된 코드이다.
@Test
// 주문 생성 : 10시부터 22시 사이에만 주문 가능 (테스트가 어려운 케이스, 테스트 시점에 따라 성공여부가 갈린다.)
void createOrder(){
CafeKiosk cafeKiosk = new CafeKiosk();
Americano americano = new Americano();
cafeKiosk.add(americano);
Order order = cafeKiosk.createOrder();
assertThat(order.getBeverages()).hasSize(1);
assertThat(order.getBeverages().get(0).getName()).isEqualTo("아메리카노");
}
만약 위와 같이 테스트 코드를 작성했다고 가정해보자. 이는 테스트 코드를 수행하는 시점에 따라서 성공 OR 실패 여부가 갈릴 것이다.
즉 관측할 때 마다 다른 값에 의존하는 코드를 테스트 할 때 발생할 수 있는 문제라고 볼 수 있다.
개선
// 시간에 대한 의존성을 외부에서 주입
public Order createOrder(LocalDateTime currentDateTime){
LocalTime currentTime = currentDateTime.toLocalTime();
if(currentTime.isBefore(SHOP_OPEN_TIME) || currentTime.isAfter(SHOP_CLOSE_TIME)){
throw new IllegalArgumentException("주문 시간이 아닙니다. 관리자에게 문의하세요.");
}
return new Order(LocalDateTime.now(), beverages);
}
@Test
// 주문 생성 리팩토링 (예외 케이스) : 테스트가 어려운 영역을 구분하여 외부로 분리
void createOrderWithCurrentTime(){
CafeKiosk cafeKiosk = new CafeKiosk();
Americano americano = new Americano();
cafeKiosk.add(americano);
Order order = cafeKiosk.createOrder(LocalDateTime.of(2024, 7, 22, 10, 0));
assertThat(order.getBeverages()).hasSize(1);
assertThat(order.getBeverages().get(0).getName()).isEqualTo("아메리카노");
}
위와 같이 관측 때 마다 변경될 수 있는 값을 외부에서 주입받도록 비즈니스 로직을 개선하는 방법이 있을 수 있다. (위에서 말한 Mock을 활용할 수도 있다)
어느 외부 세계까지 분리해야 할까에 대한 명확한 답은 없다. 그렇지만 외부로 분리할수록 작성할 수 있는 테스트 가능한 코드는 많아진다는 점은 명확하다. 외부 의존성을 최소화하고 비즈니스 로직을 독립적으로 테스트할 수 있도록 설계하는 것이 중요하다.
Practical Testing : 실용적인 테스트 가이드 (박우빈님)
테스트 코드를 작성하지 않을 경우
변화가 생기는 매순간마다 발생할 수 있는 모든 Case를 고려해야 한다.
변화가 생기는 매순간마다 모든 팀원이 동일한 고민을 해야 한다.
빠르게 변화하는 소프트웨어의 안정성을 보장할 수 없다.
비효율적인 테스트 코드
프로덕션 코드의 안정성을 제공하기 힘들어진다.
테스트 코드 자체가 유지보수하기 어려운, 새로운 짐이 된다.
잘못된 검증이 이루어질 가능성이 생긴다
올바른 테스트 코드
자동화 테스트로 비교적 빠른 시간 안에 버그를 발견할 수 있고,
수동 테스트에 드는 비용을 크게 절약할 수 있다.소프트웨어의 빠른 변화를 지원한다.
팀원들의 집단 지성을 팀 차원의 이익으로 승격시킨다.
가까이 보면 느리지만, 멀리 보면 가장 빠르다.
<함께 보기> Spring Boot : 테스트 코드 시작
https://sjh9708.tistory.com/195
References
'Backend > Spring' 카테고리의 다른 글
[Spring Boot] 테스트 코드 : 계층별 테스트 코드 작성 전략 (Controller, Service, Repository) (0) | 2024.08.05 |
---|---|
[Spring Boot] 레이어드(Layerd) 아키텍쳐 : 계층별 주요 책임 및 고려할 점 (0) | 2024.08.04 |
[Spring Boot] 테스트 : Test Double : 모듈 의존성 격리(Mocking) (0) | 2024.05.16 |
[Spring Boot] 테스트 코드 : 시작하기 (JUnit) (0) | 2024.03.05 |
[Spring Boot/JPA] QueryDSL : 동적 쿼리 작성하기 (2) | 2024.02.13 |