실무에 근거하여 Test-Driven Development 의 탄생과 개념을 본다.
📂 목차
📚 본문
고전 소프트웨어 개발 방식
- 문제 영역 설정
- 요구사항 발생
- 기능 구현
- 검증 및 테스팅
고전 소프트웨어 개발 방식의 문제점
작성된 코드의 문제 유무 판단을 개발자 자신의 두뇌에 상당히 의존하게 된다.
하지만 이런 개발자 한 명의 환경에 맞추다 보면 여러 다른 상황들이 무시되며,
이는 작성자의 판단에 근거한 개발이며 여러 환경에 대해서 테스트 한 경우는 아니다.
가벼운 개발의 경우는 위의 방식으로 전부 퉁칠 수 있다. 하지만 대게로
코드의 크기가 커지기 때문에 커질수록 버그 수정에 필요한 부분을 찾아내기 어려워진다.
코드의 크기가 커질수록 다음 상황을 야기한다:
- 틀정 모듈의 개발 기간이 길어질수록 개발자의 목표의식이 흐려진다.
- 작업 분량이 늘어날수록 확인이 어려워진다.
- 개발자의 집중력이 필요해진다.
- 논리적인 오류를 찾기 어렵다.
- 코드의 사용법과 변경 이력을 개발자의 기억력에 의존하게 되는 경우가 많다.
- 테스트 케이스가 적혀 있는 엑셀 파일을 보며 매번 테스트를 실행하는 게 점차 귀찮아져 점차 간소화하는 항목들이 늘어난다.
- 코드 수정 시에 기존 코드의 정상 동작에 대한 보장이 어렵다.
- 테스트를 해보려면 소스코드에 변경을 가하거나 번거로운 선행 작업이 필요하다.
- 소스코드를 변경할때 해야하는 회귀 테스트는 곧잘 희귀 테스트가 되기 쉽다.
- 테스트는 개발자의 귀중한 노동력을 많이 소모한다.
이를 해결하기 위해 XP 프로그래밍 기법에서 TDD 라는 개념이 나온다.
TDD의 정의
TDD 정의
프로그램을 작성하기 전에 테스트 먼저 작성하라.
Test the program before you write it. - Kent Beck
TDD 는 메소드나 함수 같은 프로그램 모듈을 작성할 때 ‘작성 종료조건을 미리 정해놓고 코딩을 시작하라’는 의미다. 작성 종료조건을 만족했을때 비로소 코딩이 끝나게 된다.
메서드 이름 | sum |
---|---|
argument | int a, int b |
return | int |
종료 조건 | a와 b를 더한 값을 결과로 돌려줌 |
간단한 테스트 문서이다. 하지만 TDD와 설계문서의 차이점은 “문서로 만들어 머리로 생각하고 눈으로 확인할 것인지”, “예상 결과를 코드로 표현해놓고 해당 코드가 자동으로 판단하게 할 것인지”가 차이다.
TDD의 목표
잘 동작하는 깔끔한 코드
Clean code that works - Ron Jeffries
제대로 동작(works)하며, 명백함(clean, 작성한 코드가 명확한 의미를 전달하냐)이 함유되어 있어야 한다.
개발에서 TDD의 위치
TDD에서 말하는 단위 테스트는 일반적인 메소드 단위의 테스트를 뜻하며,
전통적인 테스트 방법론에서 이야기하는 단위 테스트는 사용자 측면에서 제품의 기능을 테스트하는 쪽에 가깝다.
따라서 여기서 나오는 단위 테스트는 전부 메소드 단위의 테스트 임을 기억하자.
TDD의 진행 방식
ARRR
- Ask(Red): 테스트 결과가 실패하거나 코드가 멈추는 코드를 작성한다
- Respond(Green): 테스트를 통과하는 코드를 작성해서 질문에 대답한다(테스트 결과는 성공)
- Refine(Refactor): 아이디어를 통합하고, 불필요한 것은 제거하고, 모모한 것은 명확히 해서 대답을 정제한다(Refactoring)
- Repeat: 다음 질문을 통해 대화를 계속한다.
질문-응답-정제가 계속 반복되어야 한다.
JUnit5 실습
테스트 환경 구성
Spring 에서 사용할 테스트 라이브러리인 JUnit을 불러오고 간단한 은행 계좌를 만드는 것을 테스트 해보자.
dependencies {
testImplementation('org.springframework.boot:spring-boot-starter-test') {
exclude group: 'junit', module: 'junit' // JUnit 4 제외
exclude group: 'org.mockito', module: 'mockito-core' // 필요 시
exclude group: 'org.junit.vintage', module: 'junit-vintage-engine' // JUnit 3/4 호환 제거
}
testImplementation 'org.junit.jupiter:junit-jupiter-api'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine'
}
질문
다음 비어있는 계좌 인터페이스
(설계도) 를 생성하자.
src/main/entity/account/AbstractAccount.class
src/main/entity/account/Account.class
public interface IAccount {
}
public class Account implements IAccount {
}
구현된 Account 클래스에서 <command + shift + t> 를 통해 test class 를 자동 생성시킨다. 이때 Junit5
로 설정후 생성한다.
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
class AccountTest {
}
이제 생성하는 Unit Test
를 작성한다. 여기서 @Test
애너테이션을 붙여야 테스트 수행중에 해당 메서드를 테스트로 인식하여 수행한다.
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
class AccountTest {
@Test
void createAccount() {
IAccount account = new Account(1234);
}
}
이처럼 계좌를 생성시킬 수 있다. 지금은 실패한다.
응답
성공하는 테스트케이스를 작성한다.
class Account implements IAccount {
public Account(int number) {
}
}
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
class AccountTest {
@Test
void createAccount() {
IAccount account = new Account(1234);
assertInstanceOf(Account.class, account);
assertNotNull(account);
}
}
정제
- 리팩토링을 적용할 부분이 있는지 살핀다.
- To-do 목록에서 완료된 부분을 지운다.
사람이 좀 더 이해하기 쉽고, 변경용이한 구조로 바꾸는 것이다.
가독성이 적절한지, 중복된 코드가 없는지, 이름이 명확하지 않거나, 오버 구현을 하지 않았는지, 구조의 개선이 필요한 부분이 있는지 본다.
살펴보면 계좌번호는 인자로 따로 빼낼 수 있는 부분이고, 여기서 테스트 케이스가 갈라지게 된다. 따라서 다음과 같이 고쳐주며, 테스트 케이스를 여러개 넣을 수 있도록 @ParameterizedTest
와 @ValueSource
를 넣어준다.
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;
class AccountTest {
@ParameterizedTest
@ValueSource(ints = {1, 1234, 9999})
void createAccount(
int number
) {
IAccount account = new Account(number);
assertInstanceOf(Account.class, account);
assertNotNull(account);
}
}
반복
다음 단위 테스트 작성으로 넘어간다.
이후에는 계좌번호 검증, 계좌번호 조회, 계좌 유일성 검증 등의 추가적인 기능에 대한 테스팅 할 수 있을 것이다.
다음은 필자가 최종적으로 작성한 테스트 코드이다.
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;
class AccountTest {
@ParameterizedTest
@ValueSource(ints = {
Integer.MIN_VALUE, -1, 0,
1, 1234, 9999,
Integer.MAX_VALUE
})
void createAccount1(
int number
) {
IAccount account = new Account(number);
assertInstanceOf(Account.class, account, "계좌 종속정 테스트");
assertNotNull(account, "계좌 존재성 테스트");
}
@ParameterizedTest
@ValueSource(ints = {
Integer.MIN_VALUE, -1, 0,
Integer.MAX_VALUE
})
void createAccount2(
int number
) {
assertThrows(IllegalArgumentException.class,
() -> new Account(number),
"계좌 번호 유효성 검증");
}
@Test
void createAccount3() {
IAccount account1 = new Account(1234);
assertThrows(IllegalArgumentException.class,
() -> new Account(1234),
"계좌 번호 유일성 검증");
}
}
아직 위 테스트 작성은 부족한게 많다. 메서드 명이 명확하지 않고, TDD의 기본적인 것인 Red-Green-Refactor
에서 Red 만 정상적으로 작성한 코드이다. 여기서는 TDD 이기에 Test만을 잘 작성하는 쪽으로 구현했다. 이를 토대로 이제 개발을 해나가면 된다.
⭐️ 처음 Red 를 잘 작성해놓으면 Repeat이 거의 필요없게 되는 수준에 이를 수 있다.