1. TDD 실습으로 손 풀기 by happyprogfrog · Pull Request #1 · happyprogfrog/mallang
작업 내용 개발할 애플리케이션의 요구사항과 테스트 주도 개발 접근법을 알아봄 TDD로 간단한 스프링 부트 애플리케이션을 만들어봄 TIL TDD, BDD, Mock 객체 개념 복습 블로그 글 링크 [개인프로젝
github.com
요구사항
- 사용자가 매일 계산 능력을 훈련하는 애플리케이션
- 사용자가 페이지에 접속할 때마다 두 자릿수 곱셈을 보여준다.
- 사용자가 계산한 결과와 닉네임을 입력한 후 데이터를 전송하면, 화면에 결과(성공 또는 실패)가 나타남
- 간단한 게임 요소
- 계산 결과가 맞을 때마다 얻는 점수로 사용자들의 순위를 결과 페이지에 보여줌
개발방식
애자일 방식 기반으로 사용자 스토리(User story)*에 따라 개발
* 사용자 스토리
사용자 스토리는 해당기능을 실제 사용자의 관점에서 본 짧고 간단한 설명이다. 보통 사용자가 누구인지(As a), 무엇을 원하는지(I want), 그래서 어떤 동작을 하는지(so that)에 대한 내용을 작성한다.
사용자 스토리 1
온라인에서 무작위로 생성되는 곱셈 문제를 풀고 싶어요! 매일 암산으로 두뇌 훈련을 해야 하니까 문제는 너무 쉽지 않았으면 좋겠어요 ㅎㅎ
- 비즈니스 로직을 제공하는 기본적인 서비스 만들기
- 해당 서비스를 사용할 수 있는 REST API 엔드포인트(endpoint) 만들기
- 사용자에게 문제를 보여주는 간단한 웹 페이지 만들기
애플리케이션 구성
TDD 실습
알고가기
@RunWith(SpringRunner.class)
- 스프링 컨텍스트 로딩: 테스트 클래스에서 스프링 애플리케이션 컨텍스트를 로드하고, 빈 주입 및 기타 스프링 기능을 사용할 수 있다.
- 의존성 주입: @Autowired 애노테이션을 통해 테스트 클래스에 필요한 빈을 주입할 수 있다.
- 트랜잭션 관리: @Transactional 애노테이션을 통해 테스트 메서드에서 트랜잭션을 관리할 수 있다. 테스트가 완료되면 트랜잭션을 롤백하여 데이터베이스 상태를 원래대로 유지한다.
- 테스트 프로파일 설정: @ActiveProfiles 애노테이션을 사용하여 테스트 중에 특정 스프링 프로파일을 활성화할 수 있다.
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
@RunWith(SpringRunner.class)
@SpringBootTest
public class MyServiceTest {
@Autowired
private MyService myService;
@Test
public void testMyService() {
String result = myService.performAction();
assertEquals("expectedResult", result);
}
}
@ExtendWith(SpringExtension.class)
- JUnit 5는 JUnit Platform, JUnit Jupiter, JUnit Vintage 세 가지 모듈로 구성되어 있으며, 새로운 애노테이션과 기능이 추가됨
- JUnit 4에서 사용되던 @RunWith(SpringRunner.class)는 JUnit 5에서 @ExtendWith(SpringExtension.class)로 대체됨
- 주요 변경 사항
- @BeforeAll, @BeforeEach, @AfterEach, @AfterAll
- JUnit 5의 테스트 코드는 주로 org.junit.jupiter.api 패키지에 존재
- @ExtendWith(SpringExtension.class)를 사용하여 스프링 테스트 컨텍스트를 통합할 수 있음
- 스프링 부트 2.1에서는 @SpringBootTest 애노테이션이 SpringExtension을 자동으로 포함하기 때문에 명시적으로 추가하지 않아도 된다.
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;
@ExtendWith(SpringExtension.class)
@SpringBootTest
public class MyServiceTest {
@Autowired
private MyService myService;
@Test
public void testMyService() {
String result = myService.performAction();
assertEquals("expectedResult", result);
}
}
@MockBean
- 이 애노테이션은 스프링이 인터페이스에 맞는 구현 클래스를 찾아서 주입하는 대신 목(mock) 객체*를 주입한다는 것을 의미
- MockitoBDD로 RandomGeneratorService를 호출할 때 어떤 값을 반환할지 미리 정의하고 있음
- 이는 행위 주도 개발(behavior-driven development; BDD)* 방식으로 테스트를 더 읽기 쉽게 해주고, 요구사항을 정의하는 데 도움이 된다.
package me.progfrog.mallang.service;
import me.progfrog.mallang.domain.Multiplication;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
@SpringBootTest
class MultiplicationServiceTest {
@MockBean
private RandomGeneratorService randomGeneratorService;
@Autowired
private MultiplicationService multiplicationService;
@Test
@DisplayName("랜덤한 인수에 대한 계산 결과가 잘 나오는 지 확인")
void createRandomMultiplication() {
// given
given(randomGeneratorService.generateRandomFactor()).willReturn(50, 30);
// when
Multiplication multiplication = multiplicationService.createRandomMultiplication();
// then
assertThat(multiplication.getFactorA()).isEqualTo(50);
assertThat(multiplication.getFactorB()).isEqualTo(30);
assertThat(multiplication.getResult()).isEqualTo(1500);
}
}
* 목 객체
목 객체는 실제 모듈과 비슷하게 동작하도록 정의한 가짜 객체다. 진짜 객체가 아직 구현되기 전이나 진짜 객체로 테스트하기 어려운 경우 목 객체로 테스트를 작성할 수 있다.
* BDD
BDD는 TDD에서 파생된 개발 방법으로 테스트 작성 시 사용자가 원하는 행위에 중점을 둔다. 따라서 팀원 또는 고객과의 의사소통 과정에서 모호한 내용을 줄일 수 있다.
아직 MultipicationService의 구현체를 만들지 않았기 때문에 테스트 결과는 실패한다. 이게 바로 TDD의 여점이다! 먼저 요구사항을 테스트로 작성하고 스크럼(Scrum)의 프로덕트 오너(Product Owner) 같은 비즈니스 분석가와 함께 요구사항을 검증한다. 요구사항을 확인 한 후 다음과 같이 코드 내용을 작성한다.
What Is a Product Owner in Scrum?
The role of a product owner is to work with stakeholders to create a vision of the product they wish to create and communicate that product vision.
www.mountaingoatsoftware.com
Multiplication
package me.progfrog.mallang.domain;
import lombok.Getter;
@Getter
public class Multiplication {
// 인수
private int factorA;
private int factorB;
// A * B의 결과
private int result;
public Multiplication(int factorA, int factorB) {
this.factorA = factorA;
this.factorB = factorB;
this.result = factorA * factorB;
}
@Override
public String toString() {
return "Multiplication{factorA=%d, factorB=%d, result(A*B)=%d}".formatted(factorA, factorB, result);
}
}
MultiplicationService
package me.progfrog.mallang.service;
import me.progfrog.mallang.domain.Multiplication;
public interface MultiplicationService {
/**
* 두 개의 무작위 인수를 담은 {@link Multiplication} 객체를 생성
* 무작위로 생성되는 숫자의 범위는 11 ~ 99
*
* @return
*/
Multiplication createRandomMultiplication();
}
RandomGeneratorService
package me.progfrog.mallang.service;
public interface RandomGeneratorService {
/**
* @return 무작위로 만든 11 이상 99 이하의 인수
*/
int generateRandomFactor();
}
MultiplicationServiceImpl
테스트 케이스 작성 후...요구사항 검증 완료 후...
package me.progfrog.mallang.service;
import lombok.RequiredArgsConstructor;
import me.progfrog.mallang.domain.Multiplication;
import org.springframework.stereotype.Service;
@RequiredArgsConstructor
@Service
public class MultiplicationServiceImpl implements MultiplicationService {
private final RandomGeneratorService randomGeneratorService;
@Override
public Multiplication createRandomMultiplication() {
int factorA = randomGeneratorService.generateRandomFactor();
int factorB = randomGeneratorService.generateRandomFactor();
return new Multiplication(factorA, factorB);
}
}
TDD 이점
- 테스트를 작성하면서 요구사항을 코드로 바꿀 수 있다. 요구사항을 살펴보면서 필요한 것과 필요하지 않은 것에 대해 생각해볼 수 있는데, 지금까지는 우리의 첫 번째 요구사항인 무작위로 곱셈을 생성하는 서비스만 있으면 된다!
- 테스트 가능한 코드를 만들게 된다. 테스트 코드 없이 바로 코딩한다고 상상해보자! MultiplicationService를 구현하는 클래스 안에서 무작위 생성 로직을 바로 작성한다면, 개발할 때는 쉽겠지만 그 이후에 테스트하기는 굉장히 어렵다. 서비스에서 말 그대로 숫자를 무작위로 생성하기 때문에 예측해서 테스트를 할 수가 없다. 하지만 사전에 테스트 코드를 작성한 덕분에 미리 로직을 분리(RandomGeneratorService)할 수 있었다.
- 중요한 로직에 초점을 맞추고 나머지는 나중에 구현할 수 있다. 무작위 숫자 생성을 개발하고 테스트할 때 RandomGeneratorService의 구현체를 작성할 필요가 없다. 이는 다음 장에서 다룬다!