TDD , 테스트 팁
Intro
- 헷갈리는 여러 테스트 이론 정리
- “구글 엔지니어는 이렇게 일한다” 를 읽고 개발자의 생산성을 높여주는 테스트에 관한 인사이트를 공유
1. 테스트 이론
단위 테스트와 통합 테스트
- 단위 테스트 : 가장 작은 단위의 테스트 방식. 메서드 단위로 테스트를 수행하고, 의도한 결과값이 나오는지 확인하는 수준 (ex: controller, service, repository… 격리해서 따로따로)
- 통합 테스트 : 어플리케이션이 정상적으로 동작하는지 확인. 여러모듈을 함께 테스트 (ex: controller 메소드를 호출하면 controller → service→ repository 모두 수행)
TDD (Test Driven Development)
- 코드 유지보수 및 운영 환경에서의 에러를 방지하기 위해 단위별로 메소드를 검증하는 개발방법
- TDD를 하면 버그가 없나요? No!
- TDD는 모두한테 필요한가요? No!
F.I.R.S.T 원칙
💡 좋은 테스트를 위한 F.I.R.S.T 원칙
- Fast : 단위 테스트는 빨라야 한다.
- Independent : 단위 테스트는 각각의 테스트가 연관되지 않고, 고립되어야 한다. 즉 독립적 이어야 한다.
- Repeatable : 단위테스트는 반복 가능해야한다. 반복적으로 시행해도 결과는 같아야 한다.
- Self-validating : 자체적으로 테스트 결과가 도출되야한다. (개발자가 print 찍어서 비교하는게 아니고 자동으로 수행되며 성공/실패 의 결과가 나오는 것)
- Timely : 단위 테스트는 실제 코드보다 먼저 구현해야한다. (TDD일 경우에만 해당)
Given-When-Then 패턴
💡 Test 실행을 위한 Given-When-Then 패턴
- Given : 테스트에 필요한 변수를 정의하거나 Mock 객체를 통해 특정 상황 또는 행동을 정의합니다.
- When: 실제 테스트를 하는 메소드가 호출되며 테스트를 통한 결과값을 가져옵니다.
- Then: When 단계에서 나온 결과값을 검증하는 작업을 수행합니다.
JUnit + Mockito
- JUnit : Java 에서 사용되는 대표적인 테스트 프레임워크. 자바 개발자 93% 가 사용한다는 통계가 있습니다. Assertion을 이용하여 예상하는 값과 실제 도출된 값을 검증할 수 있는 기능을 지원합니다.
- Mockito : Mock 객체를 쉽게 만들고, 관리하고, 검증할 수 있는 방법을 제공하는 프레임워크 입니다. Mock 객체는 진짜 객체와 비슷하게 동작하지만 프로그래머가 직접 행동을 관리하는 객체입니다.
💡 Mockito에서 Mock(가짜) 객체의 의존성 주입을 위해서는 크게 3가지 어노테이션이 사용됩니다.
- @Mock: Mock 객체를 만들어 반환해주는 어노테이션
- @Spy: Stub하지 않은 메소드들은 원본 메소드 그대로 사용하는 어노테이션
- @InjectMocks: @Mock 또는 @Spy로 생성된 가짜 객체를 자동으로 주입시켜주는 어노테이션
- @MockBean: 기존에 사용되던 Bean의 껍데기만 가져오고 내부의 구현 부분은 모두 사용자에게 위임 (Spring 에 자동으로 Bean 으로 주입됨)
- @SpyBean: given에서 선언한 코드 외에는 전부 실제 객체의 것을 사용합니다.
- @MockBean은 given에서 선언한 코드 외에는 전부 사용할 수 없습니다.
예를 들어 UserController에 대한 단위 테스트를 작성하고자 할 때, UserService를 사용하고 있다면 @Mock 어노테이션을 통해 가짜 UserService를 만들고, @InjectMocks를 통해 UserController에 이를 주입시킬 수 있습니다.
테스트를 작성해야 하는 이유?
- 디버깅 감소
- 테스트를 한 번 작성해두면 프로젝트가 살아 있는 내내 값비싼 결함을 예방해주고, 짜증 나는 디버깅에서 해방시켜준다.
- 자신 있는 변경
- 좋은 테스트로 무장한 프로젝트는 자신감을 갖고 변경하고 리팩토링 할 수 있다.
- 더 나은 문서자료
- 하나의 행위만 집중해 검증하는 명확한 테스트는 마치 실행 가능한 문서와 같다.
- 더 단순한 리뷰
- 리뷰어가 변경된 코드가 제대로 작동하는지 검증하는 시간을 줄여준다.
- 사려 깊은 설계
- 새로 작성한 코드의 테스트를 작성하는 일은 실질적으로 해당 코드의 API가 잘 설계되었는지를 시험하는 행위이다.
- 고품질의 릴리스를 빠르게
- 건실한 자동 테스트 스위트를 갖춘 팀은 새로운 버전을 릴리즈하며 불안에 떨지 않는다.
Test Suite 설계하기
구글에서 정의 하는 테스트
모든 테스트 케이스에는 두 가지 독립된 요소가 있습니다. 바로 크기와 범위입니다.
- 크기 : 테스트 케이스 하나를 실행하는데 필요한 자원. (메모리, 프로세스, 시간 등..)
- 범위 : 검증하는 코드량
- 크기 관점에서 테스트 종류 (구글은 전통적인 용어인 “단위 테스트”와 “통합 테스트”대신 이 정의를 사용합니다.)
- 작은 테스트
- 테스트를 느려지게 하거나 비결정적으로 만드는 주요 원인을 제거한 테스트
- 제약사항
- 하나의 프로세스에서 실행되어야 한다. (언어에 따라 하나의 스레드까지 좁히는 경우도 많다.)
- sleep, I/O 연산 같은 블로킹 호출을 사용해서는 안 된다.
- 블로킹 호출이 필요하다면, 테스트 대역을 사용해야 한다.
- 중간 크기 테스트
- 작은 테스트보다 느리고 비결정적이지만 더 다양한 케이스를 테스트 할 수 있다.
- 제약사항
- 여러 프로세스와 스레드를 활용할 수 있다.
- 로컬 호스트로의 네트워크 호출 같은 블로킹 호출도 이용할 수 있다.
- 외부 시스템과의 통신은 불가능하다.
- 큰 테스트
- 더 유연해지는 만큼 테스트 속도가 느려지고 비결정성이 커진다. 주로 종단간 테스트에 사용
- 제약사항 없음
- 팁
- 구글은 큰 테스트를 작은 테스트나 중간 테스트와 분리하여, 빌드나 릴리스 때만 수행되도록 함.
- 작은 테스트
- 범위 관점에서 테스트 종류
- 좁은 범위 테스트
- 독립된 클래스나 메서드같이 코드베이스 중 작은 일부 로직 검증
- 중간 범위 테스트
- 적은 수의 컴포넌트들 사이의 상호작용을 검증하도록 설계
- 넓은 범위 테스트
- 시스템의 서로 다른 부분들 사이의 상호작용 검증
- 좁은 범위 테스트
이상적인 Test Suite
구글은 되도록 작은 테스트를 추구하며, 마찬가지로 좁은 범위 테스트를 추구한다. 실제로 구글은 비지니스 로직 대부분을 검증하는 좁은 범위의 단위 테스트가 80%, 둘 이상의 구성요소 간 상호작용을 검증하는 중간 범위의 통합 테스트가 15%, 전체 시스템을 검증하는 종단간 테스트가 5% 정도가 되도록 합니다.
이상적인 test suite

안 좋은 test suite

- 테스트 스위트 품질 높이기
- 테스트 스위트 품질 지표로 코드 커버리지를 기준으로 삼는건 바람직하지 않다.
- 다음의 질문을 던져보자.
- 고객이 이용할 모든 기능이 제대로 동작한다고 확신하나요?
- 의존하는 외부 시스템이나 모듈에 파괴적인 변경이 일어났을, 즉 호환성이 깨졌을 때 바로 인지하고 대응할 수 있을까요?
- 작성해둔 테스트는 안정적이고 믿을만한가요?
2. 생산성을 높여주는 단위 테스트
단위 테스트
- 일반적으로 작고, 범위가 좁은 테스트를 의미한다.
- 테스트의 가장 중요한 목적
- 버그 예방
- 엔지니어의 생산성 개선
유지보수하기 쉬운 단위 테스트 작성
테스트는 개발자가 믿을 수 있을 때 의미있습니다. 자주 깨져 생산성을 저하시키는 테스트는 오히려 독이 됩니다.
유지보수 하기 어려운 테스트(엔지니어의 생산성을 저하시키는 테스트)는 다음 2가지 특징을 가지고 있습니다.
- 깨지기 쉬운 테스트 : 검증 대상과 관련 없는 변경 때문에 실패하는 테스트
- 불명확한 테스트 : 무엇이 잘못되어 실패했는지, 어떻게 고쳐야 하는지를 파악하기 어려운 테스트
깨지기 쉬운 테스트 예방하기
- 이상적인 테스트
- 한 번 작성한 후로는 대상 시스템의 요구사항이 바뀌지 않는 한 절대 수정할 일이 없어야 한다.
- ex)
- 순수 리팩터링(외부 인터페이스는 놔두고 내부만 리팩터링하는 경우) → 테스트는 변경되지 않아야한다.
- 새로운 기능 추가 → 테스트는 변경되지 않아야 한다. & 새로운 테스트 추가
- 버그 수정 → 기존 테스트 변경 X & 누락된 테스트 추가
- 행위 변경 → 기존 테스트 변경
- 공개 API를 이용해 테스트하기
- 테스트도 시스템을 다른 사용자 코드와 똑같은 방식으로 호출하기
public void processTransaction(Transaction transaction) {
if (isValid(transaction) {
saveToDatabase(transaction);
}
}
private boolean isValid(Transactiont t) {
return t.getAmount() < t.getSender().getBalance();
}
private void saveToDatabase(Transaction t) {
String s = t.getSender() + "," + t.getReceipient() + "," + t.getAmount();
database.put(t.getId(), s);
}
public void setAccountBalance(String accountName, int balance) {
// 잔고를 데이터베이스에 직접 기록한다.
}
public void getAccountBalance(String accountName) {
// 계좌 잔고를 확인하기 위해 데이터베이스로부터 거래 정보를 읽어온다.
}
Bad Practice (내부 구현에 의존적인 테스트)
@Test
public void emptyAccountShouldNotBeValid() {
assertThat(processor.isValid(newTransaction().setSender(EMPTY_ACCOUNT)
.isFalse();
}
@Test
public void shouldSaveSerializeData() {
processor.saveDatabase(new Transaction()
.setId(123)
.setSender("me)
.setRecipient("you)
.setAmount(100));
assert(database.get(123)).isEqualTo("me,you,100");
}
- 깨지기 쉬운 테스트다.
- 내부 메서드의 이름을 바꾸거나 일부 로직을 도우미 클래스로 빼거나 직렬화 포맷을 바꾸는 등. 리팩토링을 할 때마다 매번 실패할 것이다. → 생산성 저하..
Best Practice (공개 API로 테스트)
@Test
public void shouldTransferFunds() {
processor.setAccountBalance("me", 150);
processor.setAccountBalance("you", 20);
processor.processTransaction(newTransaction()
.setSender("me")
.setRecipient("you")
.setAmount(100));
assertThat(processor.getAccountBalance("me")).isEqualTo(50);
assertThat(processor.getAccountBalance("you")).isEqualTo(120);
}
@Test
public void shouldNotPerformInvalidTransactions() {
processor.setAccountBalance("me", 50);
processor.setAccountBalance("you", 20);
processor.processTransction(newTransaction()
.setSender("me")
.setReceipient("you")
.setAmount(100));
assertThat(processor.getAccountBalance("me").isEqualTo(50);
assertThat(processor.getAccountBalance("you").isEqualTo(20);
}
- 현실적이라 잘 깨지지 않는 테스트다.
- 실제로 행위가 변경될 경우에만 테스트가 깨진다.
- 공개 API 정하는 Tip
- 소수의 다른 클래스를 보조하는 용도가 다인 메서드나 클래스라면 독립된 단위로 생각하지 않는게 좋다. 즉, 직접 테스트하지 말고 이들이 보조하는 클래스를 통해 우회적으로 테스트하는 것이 좋다.
- 소유자의 통제 없이 누구도 접근할 수 있게 설계된 패키지나 클래스라면 거의 예외 없이 직접 테스트해야 하는 단위이다.
- 소유자만이 접근할 수 있지만 다방면으로 유용한 기능을 제공하도록 설계된 패키지나 클래스는 직접 테스트해야 하는 단위로 봐야 한다.
- 상호작용이 아니라 상태를 테스트하기
- 상태 테스트 : 메소드 호출 후 시스템 자체를 관찰한다.
- 상호작용 테스트 : 호출을 처리하는 과정에서 시스템이 다른 모듈들과 협력하여 기대한 일련의 동작을 수행하는지를 확인 한다.
- 대체로 상호작용 테스트는 상태 테스트보다 깨지기 쉽습니다. 왜냐하면 내부 구현에 의존하기 때문입니다.
- example
- 상호 작용 테스트 (깨지기 쉽다.)
@Test
public void shouldWriteToDatabase() {
account.createUser("foobar");
verify(database).put("foobar");
}
- 테스트가 잘 동작하지 않는 케이스
- 시스템에 버그가 있어 레코드가 쓰인 직후 삭제돼도 테스트는 성공할 것이다. (실패해야 하는 상황)
- 시스템을 리팩터링하여 같은 기능을 다른 API를 호출해 수행하도록 바꿨다면 이 테스트는 실패합니다. (성공해야 하는 케이스)
- 상태 테스트
@Test
public void shouldCreateUser() {
accounts.createUser("foobar");
assertThat(account.getUser("foobar").isNotNull);
}
명확한 테스트 작성하기
테스트가 실패한다는 건 엔지니어에게 유용한 신호를 주는 것이며, 테스트의 존재 가치를 증명하는 가장 주요한 수단 중 하나입니다.
- 테스트 실패하는 케이스
- 대상 시스템에 문제가 있거나 불완전함. (테스트가 자신의 역할을 하고 있는 상황)
- 테스트 자체에 결함이 있는 경우. 이 때 대상 시스템에는 아무런 문제가 없다. (테스트가 깨지기 쉬운 테스트의 경우)
따라서 테스트가 깨지면 위 2 케이스 중 어떤 케이스인지 빠르게 판단해야 합니다. 그리고 이 일을 얼마나 빠르게 마치느냐는 테스트의 명확성에 달렸습니다.
- 명확한 테스트 : 존재 이유와 실패 원인을 엔지니어가 곧바로 알아 차릴 수 있는 테스트
- 완전하고 간결하게 만들기
- 완전한 테스트 : 결과에 도달하기까지의 논리를 읽는 이가 이해하는 데 필요한 모든 정보를 본문에 담고 있는 테스트
- 간결한 테스트 : 코드가 산만하지 않고, 관련 없는 정보는 포함하지 않은 테스트
- 테스트의 구조는 행위가 부각되도록 구성
- given : 시스템의 설정
- when : 시스템이 수행할 작업
- then : 결과를 검증
- 테스트에 논리를 넣지 말자
- 테스트 코드에서는 스마트한 로직보다 직설적인 코드를 고집해야 합니다.
- 서술적이고 의미 있는 테스트를 만들기 위한 약간의 중복은 허용하는 것이 좋습니다.
실패 메시지를 명확하게 작성하자
테스트와 코드 공유
- DRY가 아니라 DAMP
- 테스트는 DRY(Don’t Repeat Yourself)를 고집하는 대신 DAMP(Descriptive And Meaningful Phrase)가 되도록 노력해야 한다.
- 테스트에서는 DRY가 주는 혜택이 그리 크지 않습니다.
- 테스트는 DRY(Don’t Repeat Yourself)를 고집하는 대신 DAMP(Descriptive And Meaningful Phrase)가 되도록 노력해야 한다.
💡 JUnit 생명주기를 이용해 테스트 소스코드 품질을 향상시켜 보기.
- @BeforeAll : 테스트를 시작하기 전에 호출되는 메서드
- @BeforeEach : 각 테스트 메서드가 실행되기 전에 동작하는 메서드
- @AfterAll : 테스트를 종료하면서 호출되는 메서드
- @AfterEach : 각 테스트 메서드가 종료되면서 호출되는 메서드 </aside>
4. 결론
- 안정적으로 서비스를 개발하기 위해선 테스트는 필수
- 테스트를 작성할 때 가장 중요한 것은 개발자가 믿을 수 있는 테스트, 개발자의 생산성을 증가시켜주는 테스트를 작성해야 한다.
- 깨지지 않는 테스트 & 명확한 테스트를 작성해야 한다.
- Repository, Service, Controller에 작성되어 있는 다른 메소드들에 대한 성공 테스트 코드를 작성하기.
- 실패하는 테스트 시나리오를 작성해 테스트를 만들기.