일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- openAI API
- 생활코딩
- 캡스톤디자인프로젝트
- 전국대학생게임개발동아리연합회
- CICD
- 스프링부트
- NAT gateway
- 게임개발동아리
- spring ai
- Spring boot
- Redis
- bastion host
- 체크인미팅
- Route53
- 개발공부
- 프롬프트엔지니어링
- UNICON
- UNIDEV
- 42서울
- 백엔드개발자
- 티스토리챌린지
- 라피신
- AWS
- EC2
- 프로그래밍
- 프리티어
- 인프라
- UNICON2023
- 오블완
- 도커
- Today
- Total
Hyun's Wonderwall
"실전에서 TDD하기(카카오페이 기술 블로그)"를 읽고 정리 본문
"실전에서 TDD하기(카카오페이 기술 블로그)"를 읽고 정리한 내용입니다.
https://tech.kakaopay.com/post/implementing-tdd-in-practical-applications/
실전에서 TDD하기 | 카카오페이 기술 블로그
TDD가 무엇인지 모르는 사람은 없습니다. 그런데 왜 하는 사람은 얼마 없을까요?
tech.kakaopay.com
QA, PM 뿐 아니라 개발자도 품질 보장을 위한 노력을 해야 한다.
릴리즈된 애플리케이션에는 지속적으로 새로운 기능 추가가 이루어지게 된다.
기존 코드를 추가/삭제/수정할 때, 기존에 있던 동작이 변하지 않았음을 증명해야 한다. 어떻게?
매번 애플리케이션을 구동시켜 직접 모든 기능을 일일이 확인하기는 쉽지 않다. -> 테스트 코드 작성이 중요.
TDD(Test Driven Development):동작하는 코드를 작성하기 이전에 테스트를 먼저 작성하고, 그 테스트를 통과하는 코드를 작성함으로써 테스트된 동작하는 코드를 얻는 개발 방법.
TDD의 "red-green-refactoring cycle" - 세 가지 단계를 한 사이클로 돈다.
- 테스트를 작성한다(빨간 막대)
- 컴파일조차 되지 않는 코드를 대상으로 먼저 테스트를 작성. - 실행 가능하게 만든다(초록 막대)
- 테스트코드가 컴파일되고 실행까지 되도록 코드를 작성. - 올바르게 만든다
- 테스트가 통과되면 테스트의 보호아래 코드를 다듬음.
TDD로 xUnit 만들기
junit은 테스트 메서드를 어떻게 실행할까? 테스트 클래스와 메서드명을 전달받아 실행한다. 그리고 실행결과 예외가 발생하지 않으면 테스트가 성공했다고 간주한다. 클래스와 메서드명을 전달받아 해당 클래스에서 메서드를 실행시키는 클래스를 개발해 보겠다.
public class EachTestRunnerTest {
@Test
void 전달받은_클래스의_메서드를_실행한다() {
// arrange
var runner = new EachTestRunner(Dummy.class, "run");
// act
runner.run();
// assert
assertThat(runner.wasRun()).isTrue();
}
static class Dummy {
public void run() {}
}
}
3A Pattern: arrange, act, assert
GWT pattern: given, when, then (더 유명)
EachTestRunner 클래스는 생성자로 클래스와 메서드명을 주입받고, run 메서드를 통해 해당 메서드를 실행한다. 그리고 wasRun 메서드를 통해 실행여부를 확인할 수 있다. (아직 클래스를 안 만들었다!! 컴파일이 되지 않을 거지만 실행해보자.)
실행 -> 테스트 실패 (red) 확인.
클래스 만들자.
public class EachTestRunner {
public EachTestRunner(Class<?> dummyClass, String run) {
}
public void run() {
}
public boolean wasRun() {
return false;
}
}
생성자 만듦. 메서드도 선언함. wasRun은 boolean의 기본값 false를 리턴하도록 함.
다시 테스트 메서드 실행. -> 테스트는 여전히 실패.
true를 기대했는데 false를 리턴했다는 내용.
wasRun에서 false를 리턴하고 있던 걸 true로 바꾸면 성공한다. (green)
이제 리팩터링 차례.
켄트백의 테스트 주도 개발에서는 테스트에 있는 데이터와 코드에 있는 데이터의 중복도 제거하라고 얘기한다.
// test 코드에 있는 true
assertThat(runner.wasRun()).isTrue();
// 코드에 있는 true
public boolean wasRun() {
return true;
}
현재는 wasRun()이 항상 true를 반환하도록 하드코딩되어 있어, assertThat(...).isTrue()로 검증하는 것이 중복이다.
테스트의 기대값과 실제 코드의 결과가 같은 근거(=하드코딩된 true)에서 나옴 => 테스트가 코드의 올바름을 검증하는 게 아니라 복사해서 보고 있다. 이러한 테스트는 아무것도 검증하지 못한다. (테스트 항상 통과 → 잘못된 코드 잡지 못함) (코드의 결과를 그대로 믿고 있기 때문에, 테스트가 코드와 독립적이지 않음)
따라서 wasRun이 실제 실행 결과에 따라 바뀌도록, 진짜 로직을 검증하도록 변경해야 한다.
class EachTestRunner {
private boolean wasRun; // 인스턴스 필드 추가
public EachTestRunner(Class<?> clazz, String methodName) {
}
public void run() {
this.wasRun = true; // 인스턴스 필드에 값 할당
}
public boolean wasRun() {
return this.wasRun; // 하드코딩된 값에서 인스턴스 필드를 반환하도록 변경
}
}
wasRun 메서드가 하드코딩된 값이 아니라 객체 내부의 상태를 반환하도록 코드를 변경했다. (여전히 green)
아래처럼 테스트를 하나 더 추가해 보겠다.
@Test
void 전달받은_클래스의_메서드가_존재하지_않으면_실행하지_못한다() {
// arrange
var runner = new EachTestRunner(Dummy.class, “no_run");
// act
runner.run();
// assert
assertThat(runner.wasRun()).isFalse(); // assertThat(runner.wasRun()).isEqualTo(false);
}
테스트 실패. (red)
(true를 할당하는 코드를 변경하면 새로 추가한 테스트는 통과할 수 있지만 그렇게 하면 이전에 작성한 테스트가 실패. 하나씩 축적되고 있는 테스트가 코드 변경에 대한 자신감을 더해주게 되는 것.)
class EachTestRunner {
private final Class<?> clazz;
private final String methodName;
private boolean wasRun;
public EachTestRunner1(Class<?> clazz, String methodName) {
this.clazz = clazz;
this.methodName = methodName;
}
public void run() {
try {
Object object = clazz.getDeclaredConstructor().newInstance();
Method method = clazz.getDeclaredMethod(methodName);
method.invoke(object);
this.wasRun = true;
} catch(Exception e) {
this.wasRun = false;
}
}
public boolean wasRun() {
return this.wasRun;
}
}
=> EachTestRunner 클래스의 내부를 대폭 변경했다.
생성자를 통해 class와 methodName 넘어오면 파라미터들을 내부 상태로 할당했고, 리플렉션을 통해 실제 메서드를 호출하게 함. 메서드 실행 여부에 따라 wasRun에 t/f를 할당. Dummy에 no_run 없어 wasRun이 false. => (green)
"고작 하나의 클래스를 정의해 가는 과정이지만 컴파일이 안될 거라는 걸 알면서도 실행버튼을 눌렀고, 컴파일만 될 뿐 테스트는 당연히 깨질 거라는 걸 알면서도 실행버튼을 눌렀습니다. 지루한 과정이지만, 한편 내가 생각한 대로 코드가 동작한다는 확신을 얻었습니다. 존재하지 않는 메서드를 전달했을 때는 wasRun이 false가 된다는 확신을 가질 수 있게 되었습니다."
필요한 경우에 이런 작은 스텝으로 진행할 줄 알아야. 그를 위해선 필요하지 않을 때부터 작은 스텝 적응 필요.
"로직을 어디에, 어떻게 작성해야 할까?"
"최근 송금 계좌 리스트를 조회해 오는 API를 개발. 다만 카카오페이에서는 즐겨찾기에 등록된 계좌는 최근 송금 계좌 리스트에서 노출하지 않기 때문에 즐겨찾기 계좌를 제외해야 함. 송금 기록은 BankAccountRemittanceHistory, 즐겨찾기는 BankAccountBookmark 엔터티로 표현."
BankAccountRemittanceHistory의 컬렉션을 조회하고, 조회 결과에서 중복을 제거하고 즐겨찾기에 저장이 안 된 엔터티들만 필터링해서 리턴하는 로직을 작성.
@Service
class RecentBankAccountRemittanceService {
private BankAccountRemittanceHistoryRepository bankAccountRemittanceHistoryRepository;
private BankAccountBookmarkRepository bankAccountBookmarkRepository;
// 생성자 생략
public List<BankAccountRemittanceHistory> recentBankAccounts(UserId userId) {
List<BankAccountRemittanceHistory> histories = bankAccountRemittanceHistoryRepository.findBy(userId);
List<BankAccountBookmark> bookmarks = bankAccountBookmarkRepository.findBy(userId);
List<String> bankAccountNumbers = bookmarks.stream().map(BankAccountBookmark::getBankAccountNumber).toList();
List<BankAccountRemittanceHistory> result = histories.stream()
.distinct()
.filter(history -> !bankAccountNumbers.contains(history.getBankAccountNumber()))
.collect(Collectors.toList());
return result;
}
}
이 코드의 테스트 코드를 아래와 같이 TLD(Test Last Development) 방식으로 작성할 수 있음.
@ExtendWith(MockitoExtension.class)
class RecentBankAccountRemittanceServiceTest {
@InjectMocks
private RecentBankAccountRemittanceService recentBankAccountRemittanceService;
@Mock
private BankAccountRemittanceHistoryRepository bankAccountRemittanceHistoryRepository;
@Mock
private BankAccountBookmarkRepository bankAccountBookmarkRepository;
@Test
void 송금기록을_조회한다() {
// arrange
var histories = List.of(
new BankAccountRemittanceHistory("11111111"),
new BankAccountRemittanceHistory("11112222"),
new BankAccountRemittanceHistory("11113333")
);
given(bankAccountRemittanceHistoryRepository.findBy(any())).willReturn(histories);
given(bankAccountBookmarkRepository.findBy(any())).willReturn(List.of());
// act
var result = recentBankAccountRemittanceService.recentBankAccounts(new UserId());
// assert
assertThat(result).hasSize(3);
}
@Test
void 즐겨찾기_등록된_계좌는_포함하지_않는다() {
// arrange
var histories = List.of(
new BankAccountRemittanceHistory("11111111"),
new BankAccountRemittanceHistory("11112222"),
new BankAccountRemittanceHistory("11113333")
);
var bookmarks = List.of(
new BankAccountBookmark("11111111"),
new BankAccountBookmark("11114444")
);
given(bankAccountRemittanceHistoryRepository.findBy(any())).willReturn(histories);
given(bankAccountBookmarkRepository.findBy(any())).willReturn(bookmarks);
// act
var result = recentBankAccountRemittanceService.recentBankAccounts(new UserId());
// assert
assertThat(result).hasSize(2);
}
}
- DB에 접근하는 부분은 mockito 같은 mock 라이브러리를 이용하고, service에 있는 로직을 테스트.
- 테스트를 작성하기에 앞서 동작하는 코드를 먼저 작성했고, 그 이후에 이미 구현을 다 숙지하고 있는 상태로 테스트를 작성.
- 코드가 어떻게 돌아가는지, 어떤 클래스를 이용하고 어떤 메서드를 호출하는지 다 알고 있기 때문에 필요한 지점마다 목킹을 할 수 있었음.
(이런 방식의 테스트 코드는 테스트를 먼저 작성하더라도 구현 코드가 바뀌면 지속적으로 영향을 받을 수 밖에 없을 것.)
어떻게 작성해야 TDD를 할 수 있을까?
class BankAccountRemittanceHistoryCollectionTest {
@Test
void 즐겨찾기에_등록된_계좌는_제외한다() {
// arrange
var bookmarks = List.of(
new BankAccountBookmark("11111111"),
new BankAccountBookmark("11114444")
);
var histories = new BankAccountRemittanceHistoryCollection(
List.of(
new BankAccountRemittanceHistory("11111111"),
new BankAccountRemittanceHistory("11112222"),
new BankAccountRemittanceHistory("11113333")
)
);
// act
List<BankAccountRemittanceHistory> result = histories.excludeBookmark(bookmarks);
// assert
assertThat(result).hasSize(2);
}
}
class BankAccountRemittanceHistoryCollection {
private List<BankAccountRemittanceHistory> histories;
public BankAccountRemittanceHistoryCollection(List<BankAccountRemittanceHistory> histories) {
this.histories = histories;
}
public List<BankAccountRemittanceHistory> excludeBookmark(List<BankAccountBookmark> bookmarks) {
List<String> bankAccountNumbers = bookmarks.stream().map(BankAccountBookmark::getBankAccountNumber).toList();
return this.histories.stream().filter(history -> !bankAccountNumbers.contains(history.getBankAccountNumber())).toList();
}
}
@Service
class RecentBankAccountRemittanceService {
private BankAccountRemittanceHistoryRepository bankAccountRemittanceHistoryRepository;
private BankAccountBookmarkRepository bankAccountBookmarkRepository;
// 생성자 생략
public List<BankAccountRemittanceHistory> recentBankAccounts(UserId userId) {
List<BankAccountRemittanceHistory> histories = bankAccountRemittanceHistoryRepository.findBy(userId);
List<BankAccountBookmark> bookmarks = bankAccountBookmarkRepository.findBy(userId);
BankAccountRemittanceHistoryCollection collection = new BankAccountRemittanceHistoryCollection(histories);
return collection.excludeBookmark(bookmarks);
}
}
일급컬렉션(First Class Collection) 활용.
- BankAccountRemittanceHistory의 컬렉션을 의미하는 BankAccountRemittanceHistoryCollection이라는 클래스를 정의하고 해당 클래스의 객체가 로직을 처리하도록 함.
- 목킹 라이브러리의 힘을 빌리지 않고, 컬렉션에 대한 로직을 응집하는 클래스가 정의되어 테스트하기가 쉬워짐.
- service에 로직을 작성하지 않고, 실제 로직을 수행하는 객체들을 사용하도록 코드를 변경한다면 객체는 더 작은 객체로써 활용될 수 있고, 테스트하기도 훨씬 용이해진다.
(추가 공부한 내용)
1. 일급 컬렉션이란
- 정의: 컬렉션(List, Set 등)을 그대로 쓰지 않고, 그 컬렉션을 감싸는 별도의 클래스로 의미와 행위를 함께 관리하는 패턴.
- 의도: 단순 데이터 모음이 아니라, 도메인 개념을 가진 객체로 만들기 위함.
- 예시: BankAccountRemittanceHistoryCollection은 “송금 내역들의 모음”이라는 의미를 가진 객체로, “즐겨찾기 제외”라는 도메인 규칙을 스스로 수행.
class BankAccountRemittanceHistoryCollection {
private List<BankAccountRemittanceHistory> histories;
public List<BankAccountRemittanceHistory> excludeBookmark(List<BankAccountBookmark> bookmarks) { ... }
}
- 효과: 일급 컬렉션은 도메인 규칙을 객체 내부로 응집시켜 테스트 분리로 중복을 줄이고(도메인은 로직의 정확성, 서비스는 조합의 정확성을 검증) 코드 품질을 높인다.
2. BankAccountRemittanceHistoryCollectionTest (도메인 테스트)
- 목적: 도메인 객체의 로직 자체를 검증.
- 검증 대상: excludeBookmark()가 “즐겨찾기된 계좌를 제외한다”는 규칙을 정확히 수행하는지 확인.
- 특징: 외부 의존성 없음 (순수 단위 테스트), 비즈니스 규칙이 정확한지만 확인, 테스트가 빠르고 독립적
- 효과: 도메인 로직의 정확성을 보증하므로, 상위(Service) 계층 테스트는 이 로직을 신뢰하고 조합만 검증하면 됨. (도메인 로직을 검증. 서비스 로직은 검증 x. 서비스 테스트는 그 도메인을 신뢰하고 조합만 확인하면 됨.)
3. RecentBankAccountRemittanceServiceTest (서비스 테스트)
[1] 일급 컬렉션이 없던 경우
- 목적: 서비스 내부에서 직접 비즈니스 로직(즐겨찾기 제외 등)을 수행하므로, 로직과 흐름을 함께 검증해야 한다.
- 검증 대상: 리포지토리에서 데이터를 올바르게 가져오는가, 서비스 내부의 필터링 로직(즐겨찾기 제외)이 올바르게 동작하는가
- 특징: 서비스가 도메인 규칙까지 포함하므로 테스트 범위가 넓음. 로직 검증을 위해 다양한 입력 케이스를 서비스 테스트에서 직접 작성해야 함/ 리포지토리는 보통 @Mock으로 대체
- 결과: 서비스 테스트가 복잡. 도메인 규칙 변경 시 테스트도 함께 수정 필요. 계층 간 역할 구분이 모호해짐 (로직 중복 발생 가능)
[2] 일급 컬렉션을 도입한 경우
- 목적: 도메인 객체(BankAccountRemittanceHistoryCollection)가 비즈니스 로직을 담당하므로, 서비스는 조합과 흐름만 검증한다.
- 검증 대상: 리포지토리에서 데이터를 올바르게 조회하는가, 조회한 데이터를 일급 컬렉션에 전달하고 결과를 반환하는 흐름이 올바른가
- 특징: 리포지토리는 여전히 @Mock으로 대체하지만, 비즈니스 규칙 자체(excludeBookmark)는 도메인 테스트에서 이미 보증됨. 서비스 테스트는 도메인 호출 여부와 반환 흐름만 검증
- 결과: 서비스 테스트가 단순해지고 유지보수 용이. 도메인 규칙 변경 시 서비스 테스트는 영향받지 않음. 계층별 책임이 명확해져 테스트 중복이 사라짐
"mock을 적극적으로 활용하는 게 좋을까?"
mocking - 스펙에 대한 변경 없이 내부 구현이 변경된 리팩터링에도 테스트가 쉽게 깨지는 단점.
"단위테스트에서는 좋은 단위테스트의 특성 중 하나로 리팩터링 내성을 이야기합니다. 즉 스펙이 변한 게 아니라 리팩터링으로 내부구조만 변했다면 테스트는 여전히 통과해야 한다는 것입니다. 그리고 개발자는 통과하는 테스트를 기반으로 마음 놓고 코드구조를 변경할 수 있습니다. 변경도중 자신도 모르게 스펙에 영향을 끼쳤다면 그때는 테스트가 실패할 테니까요."
"전 개인적으로 목킹 라이브러리는 개발자가 컨트롤할 수 없는 외부 라이브러리의 코드, 혹은 인프라에 관련된 코드에만 사용하려 노력하는 편이고 최대한 자제하려 합니다."
"private method는 어떻게 테스트해야 할까?"
보통 이 3가지 방안 중에 하나를 선택 - 접근제어자를 default로 변경 / 리플렉션 사용 / 해당 private method를 사용하는 public method를 테스트 (셋 다 이런저런 어려움 존재)
왜 프라이빗 메서드를 테스트하고 싶을까? 프라이빗 메서드를 테스트하려는 행위 자체가 TLD로 코드를 작성했기 때문. 기존 코드에 프라이빗으로 구현코드를 추가하고 그 부분을 테스트하고 싶은 욕구가 생기기 때문.
TDD 방식으로 코드를 작성하면 프라이빗 메서드의 테스트에 대한 고민이 쉽게 사라짐. 리팩터링 과정에서 추출되는 프라이빗 메서드들은 그 자체로 이미 테스트되고 있는 퍼블릭 메서드의 파생 메서드들이기 때문에 별도의 테스트가 필요하지 않기 때문. (프라이빗 메서드의 테스트는 해당 메서드가 다른 클래스의 퍼블릭 메서드가 되어야 하는 건 아닌지를 고민해야 하는 지점이라고 생각한다.)
TDD만으로는 좋은 설계를 얻기는 힘들다고 생각하지만, 나쁜 설계를 피할 수 있게 해 준다.
'Study > Java, Spring' 카테고리의 다른 글
[Spring Boot] TDD, 단위 테스트, JUnit (0) | 2024.10.06 |
---|---|
구글, 카카오 소셜 로그인 Spring Security, OAuth2, Redis에 일반 로그인까지 대응 가능하기.. (0) | 2024.10.06 |
[Spring Boot] 백엔드 서버 HTTPS 배포하기 - AWS EC2, RDS + Docker + GitHub Actions CI/CD 총정리🌿 (5) | 2024.09.22 |
[Spring TIL] nullable=false, @NotNull, @NonNull의 차이는? (+@Nonnull은 또 뭐지?) (0) | 2024.07.04 |
[도서리뷰] 자바/스프링 개발자를 위한 실용주의 프로그래밍 (0) | 2024.07.03 |