TDD, 즉 테스트 주도 개발(Test-driven Development)
테스트부터 시작한다. 구현을 먼저하고 나중에 테스트하는 것이 아니라 먼저 테스트를 하고 그 다음에 구현한다.
TDD 예: 암호 검사기
검사할 규칙
- 길이가 8글자 이상
- 0부터 9사이의 숫자를 포함
- 대문자 포함
등급
- 3개의 규칙을 충족하면 암호는 강함
- 2개의 규칙을 충족하면 암호는 보통
- 1개의 규칙을 충족하면 암호는 약함
assertEquals(기댓값,결과값);
테스트 코드 작성
첫 번째 테스트 - 모든 규칙을 충족하는 경우
public enum PasswordStrength {
INVALID,NORMAL,STRONG
}
@Test
void meetsAllCriteria_Then_Strong(){
PasswordStrengthMeter meter = new PasswordStrengthMeter();
PasswordStrength result = meter.meter("ab12!@AB");
assertEquals(PasswordStrength.STRONG, result);
PasswordStrength result2 = meter.meter("abc1!Add");
assertEquals(PasswordStrength.STRONG, result2);
}
두 번째 테스트 - 길이만 8글자 미만이고 나머지 조건은 충족하는 경우
@Test
void meetsOtherCriteria_except_for_Length_Then_Normal(){
PasswordStrengthMeter meter = new PasswordStrengthMeter();
PasswordStrength result = meter.meter("ab12!@A");
assertEquals(PasswordStrength.NORMAL,result);
PasswordStrength result2 = meter.meter("Ab12!c");
assertEquals(PasswordStrength.NORMAL,result2);
}
세번째 테스트 - 숫자를 포함하지 않고 나머지 조건은 충족하는 경우
@Test
void meetsOtherCriteria_except_for_number_The_Normal(){
PasswordStrengthMeter meter = new PasswordStrengthMeter();
PasswordStrength result = meter.meter("ab!@ABqwer");
assertEquals(PasswordStrength.NORMAL,result);
}
코드 정리 - 중복 제거
위의 중복코드를 제거하면 첫 번째 예시 기준으로 다음과 같은 코드가 된다.
public class PasswordStrengthMeterTest {
private PasswordStrengthMeter meter = new PasswordStrengthMeter();
@Test
void meetsAllCriteria_Then_Strong(){
PasswordStrength result = meter.meter("ab12!@AB");
assertEquals(PasswordStrength.STRONG, result);
PasswordStrength result2 = meter.meter("abc1!Add");
assertEquals(PasswordStrength.STRONG, result2);
}
}
다음 중복 코드를
PasswordStrength result = meter.meter("ab12!@AB");
assertEquals(PasswordStrength.STRONG, result);
아래와 같이 메서드를 생성해
private void assertStrength(String password, PasswordStrength expStr){
PasswordStrength result = this.meter.meter(password);
assertEquals(expStr,result);
}
아래와 같이 중복 코드를 제거한다.
@Test
void meetsAllCriteria_Then_Strong(){
assertStrength("ab12!@AB",PasswordStrength.STRONG);
assertStrength("abc1!Add",PasswordStrength.STRONG);
}
@Test
void meetsOtherCriteria_except_for_Length_Then_Normal(){
assertStrength("ab12!@A",PasswordStrength.NORMAL);
assertStrength("Ab12!c",PasswordStrength.NORMAL);
}
@Test
void meetsOtherCriteria_except_for_number_The_Normal(){
assertStrength("ab!@ABqwer",PasswordStrength.NORMAL);
}
주의할 점 : 중복을 제거 이후에도 꼭 테스트 코드를 실행해보고 무분별한 제거는 가독성을 떨어뜨릴 수 있다.
네 번째 테스트: 값이 없는 경우
@Test
void nullInput_Then_Invalid(){
assertStrength(null,PasswordStrength.INVALID);
}
@Test
void emptyInput_Then_Invalid(){
assertStrength("",PasswordStrength.INVALID);
}
아래와 같은 검증을 추가해준다.
if (s == null || s.isEmpty()) return PasswordStrength.INVALID;
다섯번째 테스트: 대문자를 포함하지 않고 나머지 조건을 충족하는 경우
@Test
void meetsOtherCriteria_except_for_Uppercase_Then_Normal(){
assertStrength("ab12!@df",PasswordStrength.NORMAL);
}
아래와 같은 검증을 추가해준다.
private boolean meetsContainingUppercaseCriteria(String s) {
for (char ch : s.toCharArray()){
if (Character.isUpperCase(ch)){
return true;
}
}
return false;
}
여섯 번째 테스트: 길이가 8글자 이상인 조건만 충족하는 경우
@Test
void meetsOnlyLengthCriteria_Then_Weak(){
assertStrength("abdefghi",PasswordStrength.WEAK);
}
meter함수의 내용을 다음과 같이 수정한다.
public PasswordStrength meter(String s) {
if (s == null || s.isEmpty()) return PasswordStrength.INVALID;
boolean lengthEnough = s.length() >= 8;
boolean containsNum = meetsContainingNumberCriteria(s);
boolean containsUpp = meetsContainingUppercaseCriteria(s);
if(!lengthEnough){
return PasswordStrength.NORMAL;
}
if (!containsNum) return PasswordStrength.NORMAL;
if (!containsUpp) return PasswordStrength.NORMAL;
return PasswordStrength.STRONG;
}
위의 코드를 보면 if 절의 위치를 두 로직을 구분하기 위해 이동하였다.
- 개별 규칙을 검사하는 로직
- 규칙을 검사한 결과에 따라 암호 강도를 계산하는 로직
일곱 번째 테스트: 숫자 포함 조건만 충족하는 경우
@Test
void meetsOnlyNumCriteria_Then_Weak(){
assertStrength("12345",PasswordStrength.WEAK);
}
meter함수의 내용을 다음과 같이 수정한다.
if (!lengthEnough && containsNum && !containsUpp)
return PasswordStrength.WEAK;
여덟 번째 테스트: 대문자 포함 조건만 충족하는 경우
@Test
void meetsOnlyUpperCriteria_Then_Weak(){
assertStrength("ABZEF", PasswordStrength.WEAK);
}
meter함수의 내용을 다음과 같이 수정한다.
if (!lengthEnough && !containsNum && containsUpp)
return PasswordStrength.WEAK;
코드정리: meter() 메서드 리팩토링
전체 리팩토링 이전
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
if (s == null || s.isEmpty()) return PasswordStrength.INVALID;
boolean lengthEnough = s.length() >= 8;
boolean containsNum = meetsContainingNumberCriteria(s);
boolean containsUpp = meetsContainingUppercaseCriteria(s);
if (lengthEnough && !containsNum && !containsUpp)
return PasswordStrength.WEAK;
if (!lengthEnough && containsNum && !containsUpp)
return PasswordStrength.WEAK;
if (!lengthEnough && !containsNum && containsUpp)
return PasswordStrength.WEAK;
if(!lengthEnough){
return PasswordStrength.NORMAL;
}
if (!containsNum) return PasswordStrength.NORMAL;
if (!containsUpp) return PasswordStrength.NORMAL;
return PasswordStrength.STRONG;
}
private boolean meetsContainingUppercaseCriteria(String s) {
for (char ch : s.toCharArray()){
if (Character.isUpperCase(ch)){
return true;
}
}
return false;
}
private boolean meetsContainingNumberCriteria(String s) {
for (char ch : s.toCharArray()){
if(ch >= '0' && ch <= '9'){
return true;
}
}
return false;
}
}
WEAK 리팩토링
리팩토링 이전
if (lengthEnough && !containsNum && !containsUpp)
return PasswordStrength.WEAK;
if (!lengthEnough && containsNum && !containsUpp)
return PasswordStrength.WEAK;
if (!lengthEnough && !containsNum && containsUpp)
return PasswordStrength.WEAK;
리팩토링 이후
int metCounts = 0;
boolean lengthEnough = s.length() >= 8;
if (lengthEnough) metCounts++;
boolean containsNum = meetsContainingNumberCriteria(s);
if (containsNum) metCounts++;
boolean containsUpp = meetsContainingUppercaseCriteria(s);
if (containsUpp) metCounts++;
if (metCounts == 1) return PasswordStrength.WEAK;
NORMAL 리팩토링
리팩토링 이전
if(!lengthEnough){
return PasswordStrength.NORMAL;
}
if (!containsNum) return PasswordStrength.NORMAL;
if (!containsUpp) return PasswordStrength.NORMAL;
리팩토링 이후
if (metCounts == 2) return PasswordStrength.NORMAL;
전체 리팩토링
public PasswordStrength meter(String s) {
if (s == null || s.isEmpty()) return PasswordStrength.INVALID;
int metCounts = 0;
if (s.length() >= 8) metCounts++;
if (meetsContainingNumberCriteria(s)) metCounts++;
if (meetsContainingUppercaseCriteria(s)) metCounts++;
if (metCounts == 1) return PasswordStrength.WEAK;
if (metCounts == 2) return PasswordStrength.NORMAL;
return PasswordStrength.STRONG;
}
아홉 번째 테스트: 아무 조건도 충족하지 않는 경우
@Test
void meetsNoCriteria_Then_Weak(){
assertStrength("abc",PasswordStrength.WEAK);
}
meter함수의 내용을 다음과 같이 수정한다.
if (metCounts <= 1) return PasswordStrength.WEAK;
코드정리: 코드 가독성 개선
int metCounts = 0;
if (s.length() >= 8) metCounts++;
if (meetsContainingNumberCriteria(s)) metCounts++;
if (meetsContainingUppercaseCriteria(s)) metCounts++;
public PasswordStrength meter(String s) {
if (s == null || s.isEmpty()) return PasswordStrength.INVALID;
int metCounts = getMetCriteriaCounts(s);
if (metCounts <= 1) return PasswordStrength.WEAK;
if (metCounts == 2) return PasswordStrength.NORMAL;
return PasswordStrength.STRONG;
}
private int getMetCriteriaCounts(String s) {
int metCounts = 0;
if (s.length() >= 8) metCounts++;
if (meetsContainingNumberCriteria(s)) metCounts++;
if (meetsContainingUppercaseCriteria(s)) metCounts++;
return metCounts;
}
이제 다른 개발자가 보더라도 다음과 같은 코드를 읽을 수 있다.
- 암호가 Null이거나 빈 문자열이면 암호 강도는 INVALID이다.
- 충족하는 규칙 개수를 구한다.
- 충족하는 규칙 개수가 1개 이하면 암호 강도는 WEAK이다.
- 충족하는 규칙 개수가 2개면 암호 강도는 NORMAL이다.
- 이 외 경우(즉 충족하는 규칙 개수가 2개보다 크면) 암호 강도는 STRONG이다.
TDD 흐름
테스트 - 코딩 - 리팩토링 - 테스트를 반복한다.
TDD는 테스트를 먼저 작성하고 테스트를 통과시킬 만큼 코드를 작성하고 리팩토링으로 마무리 하는 과정을 반복한다.
아래의 1번부터 4번을 반복한다.
- 기능을 검증하는 테스트를 작성
- 작성한 테스트를 통과하지 못하면 통과하는 코드를 작성
- 작성한 코드를 리팩토링
- 리팩토링 후 다시 테스트 실행
레드-그린-리팩터
TDD 사이클을 레드(Red)-그린(Green)-리팩터(refactor)로 부르기도 한다.
레드는 실패하는 테스트를 의미한다. 레드는 테스트 코드가 실패하면 빨간색을 이용해서 실패한 테스트를 보여주는데서 비롯했다.
그린은 성공한 테스트를 의미한다.
리팩터는 리팩토링 과정을 의미한다.
Reference.