작성자 : 김정호
실습 환경
- Java 17
- Spring Boot 3.0.1
- wsl 2
TDD란?
TDD는 테스트부터 시작한다.
우선 테스트를 작성하고, 그 후에 구현을 진행하게 된다.
책에 나온 예시로 함께 살펴보도록 하겠다.
CalculatorTest
...
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class CalculatorTest {
@Test
void plus() {
assertEquals(2, Calculator.plus(1, 1)); // 현재 정적 메서드로 설계함.
}
}
TDD로 개발할 때 가장 먼저 해야 할 일은
테스트 코드를 작성하는 일이다.
Calculator 클래스와 메서드를 구현하기 전에 기능을 검증할 테스트 코드부터 작성해 주었다.
현재 Calculator 클래스를 작성하지 않은 상태이므로
Cannot resolve symbol 'Calculator'라는 에러가 발생하는 것을 볼 수 있으며
당연하게도 테스트 실행 시 에러가 발생한다.
Calculator
package com.hou27.chap02;
public class Calculator {
public static int plus(int a, int b) {
return a + b;
}
}
이제 Calculator 클래스를 작성하고, plus라는 이름으로 정적 메서드를 구현해 주었다.
테스트 통과 ^^
구현 로직이 plus 메서드와 같이 단순하지 않다면 점진적으로 구현해 나가는데,
지금은 많이 단순한 예시였기 때문에 테스트를 통과하는 로직을 바로 완성해 주었다.
이번엔 조금 더 현실적인 예시를 통해 TDD를 더 맛보도록 하겠다.
TDD 예 : 암호 검사기
검사기의 규칙은 다음과 같다.
- 길이가 8글자 이상
- 0부터 9 사이의 숫자 포함
- 대문자 포함
- 규칙 3개 모두 충족 시 암호는 강함
- 규칙 2개 충족 시 암호는 보통
- 1개 이하일 시 암호는 약함
'약함', '보통', '강함' : PasswordStrength (암호 강도)
검사기 클래스명 : PasswordStrengthMeter (암호 강도 측정기)
중요한 이름을 정해주고, 이제 테스트를 작성해보도록 하겠다.
1. 모든 규칙을 충족하는 경우
첫 번째 테스트를 정하는 건 중요한 부분이라고 한다.
가장 쉽거나 예외적인 상황을 선택해야만 순탄하게 다음 과정도 진행할 수 있다.
지금은 2가지를 고려해볼 수 있다.
- 모든 규칙을 충족하는 경우
- 모든 조건을 충족하지 않는 경우
이 중 각 조건을 충족하지 않는 코드가 모두 필요한
두 번째 경우보단 첫 번째가 시작하기에 좋은 경우이다.
PasswordStrengthMeterTest
...
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class PasswordStrengthMeterTest {
@Test
void meetsAllCriteria_Then_Strong() {
PasswordStrengthMeter meter = new PasswordStrengthMeter();
PasswordStrength result = meter.meter("123ABCDE456");
assertEquals(PasswordStrength.STRONG, result);
}
}
PasswordStrengthMeterTest
위와 같이 테스트 코드를 작성하였으며,
열거타입을 통해 암호 강도를 PasswordStrength.STRONG과 같이 나타내었다.
이제 컴파일 에러를 없애기 위해
열거타입 PasswordStrength와 클래스 PasswordStrengthMeter를
테스트를 통과시킬 수 있을 만큼 작성하도록 하겠다.
PasswordStrength
public enum PasswordStrength {
STRONG
}
STRONG 뿐만 아니라 NORMAL, WEEK를 지금 추가할 수 있지만
TDD는 테스트를 통과시킬 만큼의 코드를 작성하는 것이라고 한다.
PasswordStrengthMeter
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
return PasswordStrength.STRONG;
}
}
지금 테스트를 통과시키는 것이 목적이므로 STRONG을 반환하도록 구현하였다.
테스트 통과 ^^
2. 길이만 8글자 미만이고 나머지 조건은 충족하는 경우
이번엔 암호의 강도가 보통인 경우이다.
PasswordStrengthMeterTest
...
public class PasswordStrengthMeterTest {
...
@Test
void meetsOtherCriteria_except_for_Length_Then_Normal() {
PasswordStrengthMeter meter = new PasswordStrengthMeter();
PasswordStrength result = meter.meter("12345A");
assertEquals(PasswordStrength.NORMAL, result);
}
}
0부터 9 사이의 숫자를 포함하고 대문자도 포함하지만 길이가 8글자 미만인 경우를 테스트하도록 하였다.
PasswordStrength
public enum PasswordStrength {
STRONG, NORMAL
}
NORMAL을 추가하고,
PasswordStrengthMeter
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
return PasswordStrength.NORMAL;
}
}
NORMAL을 반환하도록 해주었다.
그런데 반환값을 NORMAL로 바꿔준 탓에 첫 번째 테스트는 실패하는 것을 확인할 수 있었다.
PasswordStrengthMeter
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
if (s.length() < 8) {
return PasswordStrength.NORMAL;
}
return PasswordStrength.STRONG;
}
}
그래서 코드를 위와 같이 수정해 주었다.
테스트 통과 ^^
3. 숫자를 포함하지 않고 나머지 조건은 충족하는 경우
이번에도 암호 강도는 보통인 경우이다.
PasswordStrengthMeterTest
public class PasswordStrengthMeterTest {
...
@Test
void meetsOtherCriteria_except_for_Number_Then_Normal() {
PasswordStrengthMeter meter = new PasswordStrengthMeter();
PasswordStrength result = meter.meter("ABCDEexceptNumber");
assertEquals(PasswordStrength.NORMAL, result);
}
}
길이 조건과 대문자 조건을 만족하지만 숫자를 포함하지 않은 경우를 테스트하도록 하였다.
PasswordStrengthMeter
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
if (s.length() < 8 || !s.matches(".*[0-9].*")) {
return PasswordStrength.NORMAL;
}
return PasswordStrength.STRONG;
}
}
정규식을 통해 문자열에 숫자가 포함되어있는지도 판단하도록 조건문을 수정해 주었다.
테스트 통과 ^^
리팩토링
지금까지 작성한 테스트 코드를 정리하고 넘어가도록 하겠다.
PasswordStrengthMeterTest
public class PasswordStrengthMeterTest {
@Test
void meetsAllCriteria_Then_Strong() {
PasswordStrengthMeter meter = new PasswordStrengthMeter();
PasswordStrength result = meter.meter("123ABCDE456");
assertEquals(PasswordStrength.STRONG, result);
}
@Test
void meetsOtherCriteria_except_for_Length_Then_Normal() {
PasswordStrengthMeter meter = new PasswordStrengthMeter();
PasswordStrength result = meter.meter("12345A");
assertEquals(PasswordStrength.NORMAL, result);
}
@Test
void meetsOtherCriteria_except_for_Number_Then_Normal() {
PasswordStrengthMeter meter = new PasswordStrengthMeter();
PasswordStrength result = meter.meter("ABCDEexceptNumber");
assertEquals(PasswordStrength.NORMAL, result);
}
}
코드를 확인해 보면, 각 메서드 내에 중복된 코드가 상당하다는 것을 알 수 있다.
public class PasswordStrengthMeterTest {
private PasswordStrengthMeter meter = new PasswordStrengthMeter();
private void assertStrength(String password, PasswordStrength expStr) {
PasswordStrength result = meter.meter(password);
assertEquals(expStr, result);
}
...
}
중복되는 객체를 필드로 관리하고,
반복되는 작업은 하나의 메서드로 분리해서 리팩토링 해주었다.
public class PasswordStrengthMeterTest {
private PasswordStrengthMeter meter = new PasswordStrengthMeter();
private void assertStrength(String password, PasswordStrength expStr) {
PasswordStrength result = meter.meter(password);
assertEquals(expStr, result);
}
@Test
void meetsAllCriteria_Then_Strong() {
assertStrength("123ABCDE456", PasswordStrength.STRONG);
}
@Test
void meetsOtherCriteria_except_for_Length_Then_Normal() {
assertStrength("12345A", PasswordStrength.NORMAL);
}
@Test
void meetsOtherCriteria_except_for_Number_Then_Normal() {
assertStrength("ABCDEexceptNumber", PasswordStrength.NORMAL);
}
}
정리한 후 변경된 코드이다.
4. 값이 없는 경우
이번에는 놓칠 수 있었던 null 또는 빈 문자열에 대한 경우이다.
PasswordStrengthMeterTest
...
public class PasswordStrengthMeterTest {
...
@Test
void nullInput_Then_Invalid() {
assertStrength(null, PasswordStrength.INVALID);
assertStrength("", PasswordStrength.INVALID);
assertStrength(" ", PasswordStrength.INVALID);
}
}
Exception을 발생시킬 수 있지만
INVALID 값을 리턴하는 방식을 선택했다.
PasswordStrength
public enum PasswordStrength {
STRONG, NORMAL, INVALID
}
타입을 추가하고,
PasswordStrengthMeter
package com.hou27.chap02;
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
if(s == null || s.isBlank()) {
return PasswordStrength.INVALID;
}
...
}
}
빈 문자열뿐만 아니라 공백으로만 이루어진 문자열도 잡아내기 위해 isBlank 메서드를 사용하여
새로운 조건을 작성해 주었다.
테스트 통과 ^^
5. 대문자를 포함하지 않고 나머지 조건을 충족하는 경우
6. 8글자 이상 조건만 충족하는 경우
7. 숫자 포함 조건만 충족하는 경우
8. 대문자 포함 조건만 충족하는 경우
지금까지 진행한 과정과 유사한 위 4가지 경우는 자세한 설명을 생략하도록 하겠다.
아래는 8번째 경우까지 진행한 후의 코드이다.
PasswordStrengthMeterTest
...
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class PasswordStrengthMeterTest {
private PasswordStrengthMeter meter = new PasswordStrengthMeter();
private void assertStrength(String password, PasswordStrength expStr) {
PasswordStrength result = meter.meter(password);
assertEquals(expStr, result);
}
@Test
void meetsAllCriteria_Then_Strong() {
assertStrength("123ABCDE456", PasswordStrength.STRONG);
}
@Test
void meetsOtherCriteria_except_for_Length_Then_Normal() {
assertStrength("12345A", PasswordStrength.NORMAL);
}
@Test
void meetsOtherCriteria_except_for_Number_Then_Normal() {
assertStrength("ABCDEexceptNumber", PasswordStrength.NORMAL);
}
@Test
void nullInput_Then_Invalid() {
assertStrength(null, PasswordStrength.INVALID);
assertStrength("", PasswordStrength.INVALID);
assertStrength(" ", PasswordStrength.INVALID);
}
@Test
void meetsOtherCriteria_except_for_Uppercase_Then_Normal() {
assertStrength("abcde123", PasswordStrength.NORMAL);
}
@Test
void meetsOnlyLengthCriteria_Then_Weak() {
assertStrength("abcdefghi", PasswordStrength.WEAK);
}
@Test
void meetsOnlyNumCriteria_Then_Weak() {
assertStrength("12345", PasswordStrength.WEAK);
}
@Test
void meetsOnlyUpperCriteria_Then_Weak() {
assertStrength("ABCDE", PasswordStrength.WEAK);
}
@Test
void meetsNoCriteria_Then_Weak() {
assertStrength("abc", PasswordStrength.WEAK);
}
}
PasswordStrength
public enum PasswordStrength {
STRONG, NORMAL, INVALID, WEAK
}
PasswordStrengthMeter
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
if (s == null || s.isBlank()) {
return PasswordStrength.INVALID;
}
int criteriaCnt = getCriteriaCnt(s);
if (criteriaCnt == 2) {
return PasswordStrength.NORMAL;
} else if (criteriaCnt == 3) {
return PasswordStrength.STRONG;
} else {
return PasswordStrength.WEAK;
}
}
private int getCriteriaCnt(String s) {
int criteriaCnt = 0;
if (s.length() >= 8) {
criteriaCnt++;
}
if (s.matches(".*[0-9].*")) {
criteriaCnt++;
}
if (s.matches(".*[A-Z].*")) {
criteriaCnt++;
}
return criteriaCnt;
}
}
모든 테스트 통과 ^^
TDD 흐름
지금까지 암호 검사기까지 구현해 보면서
테스트 -> 구현 -> 리팩토링의 단계를 반복적으로 거치는
전형적인 TDD의 흐름을 이해할 수 있었다.
이 TDD 사이클을
Red - Green - Refactor라고 부른다고 한다.
Red는 실패하는 테스트를,
Green은 성공한 테스트를,
Refactor는 말 그대로 코드를 리팩토링 하는 과정을 의미한다.
테스트를 먼저 작성하고 해당 테스트를 통과시킬 만큼 기능을 구현했으며
이런 흐름 덕분에 테스트 코드에 따른 다음 개발 범위도 정해지게 된다.
즉 테스트가 개발을 주도하게 되는 것이다.
코드 수정에 대한 피드백이 빠르며 코드를 수정하거나 추가했을 때
즉각적으로 테스트 코드를 통해 피드백받을 수 있다는 점도 또 다른 장점이다.
https://github.com/GDSC-MYONGJI/22-23-TDD-Study
참고용 도서
http://www.yes24.com/Product/Goods/89145195
'Group Study (22-23) > TDD' 카테고리의 다른 글
[TDD] 6. 대역 (1) | 2023.02.24 |
---|---|
[TDD] 5. 테스트 코드의 구성 (1) | 2023.02.02 |
[TDD] 4. JUnit 5 기초 (3) | 2023.01.27 |
[TDD] 3. TDD ∙ 기능 명세 ∙ 설계 (3) | 2023.01.21 |
[TDD] 2. 테스트 코드 작성 순서 (1) | 2023.01.17 |