간단한 덧셈 기능을 TDD로 구현해보았다.
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
public class CalculatorTest {
@Test
void plus() {
int result = Calculator.plus(1, 2);
assertEquals(3, result);
assertEquals(5, Calculator.plus(4, 1));
}
}
int result = Calculator.plus(1, 2);
코드를 만들기 위해 몇 가지 고민을 해야 한다.
위처럼 덧셈 기능을 제공할 클래스, 메서드, 반환 타입 등에 대해 고민하고 그 결과에 맞게 작성해야 한다.
public class Calculator {
public static int plus(int a1, int a2) {
return a1 + a2;
}
}
Calculator 클래스를 생성한다.
처음에는 return 3;
, return 5;
처럼 점진적으로 구현을 완성해 나가야 한다.
최종적으로 return a1 + a2;
라는 코드가 되었고,
src/test/java 소스 폴더에 있던 Calculator 클래스를 src/main/java 소스 폴더로 이동시켜서 배포 대상에 포함시킨다.
src/test/java 소스 폴더는 배포 대상이 아니므로 src/test/java 폴더에 코드를 만들면 완성되지 않은 코드가 배포되는 것을 방지하는 효과가 있다.
✔ 위의 덧셈 예제에서는,
return 3
처럼 테스트를 통과할 만큼만 코드를 구현하고 테스트에 통과했다.return a1+a2;
라는 코드로 기능을 완성하였다.이 과정이 실제 코드를 설계하는 과정과 유사하다.
암호 검사기를 구현해보았다.
문자열을 검사해서 규칙을 준수하는지에 따라 암호를 '약함', '보통', '강함'으로 구분한다.
세 규칙을 모두 충복하면 '강함', 2개의 규칙을 충족하면 '보통', 1개 이하의 규칙을 충적하면 '약함' 이라고 한다. (PasswordStrength)
'암호가 모든 조건을 충족하면 암호 강도는 강함이어야 함'
ublic class PasswordStrengthMeterTest {
@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);
}
}
public enum PasswordStrength {
STRONG
}
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
return PasswordStrength.STRONG;
}
}
PasswordStrengthMeter
가 PasswordStrength.STRONG
를 리턴한다. 따라서 위 테스트는 통과하게 된다.
PasswordStrength
에서 WEAK
나 NORMAL
을 미리 추가할 수 있겠지만, TDD는 테스트를 통과시킬 만큼의 코드를 작성한다.
@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);
}
public enum PasswordStrength {
NORMAL, STRONG
}
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
if (s.length() < 8) {
return PasswordStrength.NORMAL;
}
return PasswordStrength.STRONG;
}
}
첫 번째 테스트와 이번 두 번째 테스트를 모두 통과하기 위해서는 PasswordStrengthMeter
에서 if문을 통해 NORMAL
과 STRONG
으로 구분해주었다.
@Test
void meetsOtherCriteria_except_for_number_Then_Normal() {
PasswordStrengthMeter meter = new PasswordStrengthMeter();
PasswordStrength result = meter.meter("ab!@ABqwer");
assertEquals(PasswordStrength.NORMAL, result);
}
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
if (s.length() < 8) {
return PasswordStrength.NORMAL;
}
boolean containsNum = meetsContainingNumberCriteria(s);
if (!containsNum) return PasswordStrength.NORMAL;
return PasswordStrength.STRONG;
}
private boolean meetsContainingNumberCriteria(String s) {
for (char ch : s.toCharArray()) {
if (ch>='0' && ch<='9') {
return true;
}
}
return false;
}
}
코드 가독성을 위해 숫자 포함 여부를 확인하는 코드를 따로 메서드로 추출하였다.
세 번째 코드도 마찬가지로 NORMAL
이다.
기존 PasswordStrengthMeterTest
의 테스트 메서드를 보면 PasswordStrengthMeter
를 생성하는 코드가 중복된다.
public class PasswordStrengthMeterTest {
@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);
}
@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_Then_Normal() {
PasswordStrengthMeter meter = new PasswordStrengthMeter();
PasswordStrength result = meter.meter("ab!@ABqwer");
assertEquals(PasswordStrength.NORMAL, result);
}
}
각 메서드에서 생성하고 있던 코드를 필드에서 생성하도록 수정할 수 있다.
또한 암호 강도 측정 기능을 실행하고 이를 확인하는 코드도 assertStrength
메서드를 추가하여 중복을 제거할 수 있다.
public class PasswordStrengthMeterTest {
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("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_Then_Normal() {
assertStrength("ab!@ABqwer", PasswordStrength.NORMAL);;
}
}
중복 제거 후 코드는 위와 같다.
테스트 코드의 중복을 무턱대고 제거하면 안 된다. 중복을 제거한 뒤에도 테스트 코드의 가독성이 떨어지지 않고 수정이 용이한 경우에만 중복을 제거해야 한다.
null을 입력할 경우 암호 강도 측정기는 어떻게 반응할지?
두 번째 방법을 선택하여 구현해보았다.
@Test
void nullInput_Then_Invalid() {
assertStrength(null, PasswordStrength.INVALID);;
}
@Test
void emptyInput_Then_Invalid() {
assertStrength("", PasswordStrength.INVALID);;
}
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
if (s == null || s.isEmpty()) return PasswordStrength.INVALID;
if (s.length() < 8) {
return PasswordStrength.NORMAL;
}
boolean containsNum = meetsContainingNumberCriteria(s);
if (!containsNum) return PasswordStrength.NORMAL;
return PasswordStrength.STRONG;
}
...생략
null인 경우와 빈 문자열인 경우도 고려하여 테스트를 추가한다.
@Test
void meetsOtherCriteria_except_for_Uppercase_Then_Normal() {
assertStrength("ab12!@df", PasswordStrength.NORMAL);;
}
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
if (s == null || s.isEmpty()) return PasswordStrength.INVALID;
if (s.length() < 8) {
return PasswordStrength.NORMAL;
}
boolean containsNum = meetsContainingNumberCriteria(s);
if (!containsNum) return PasswordStrength.NORMAL;
boolean containsUpp = meetsContainingUppercaseCriteria(s);
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;
}
}
세 번째 테스트와 마찬가지로, 메서드 추출을 이용해서 대문자 포함 여부를 확인하는 메서드를 따로 작성하였다.
이제 한 가지 조건만 충족하거나 모든 조건을 충족하지 않는 경우를 테스트 해본다.
이 경우에는 암호 강도가 WEAK
이다.
@Test
void meetsOnlyLengthCriteria_Then_Weak() {
assertStrength("abdefghi", PasswordStrength.WEAK);;
}
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) {
return PasswordStrength.NORMAL;
}
if (!containsNum) return PasswordStrength.NORMAL;
if (!containsUpp) return PasswordStrength.NORMAL;
return PasswordStrength.STRONG;
}
... 생략
if (lengthEnough && !containsNum && !containsUpp)
코드를 통해 길이만 충족할 경우를 만족시켜주었다.
여섯 번째 테스트와 같이, if (!lengthEnough && containsNum && !containsUpp)
코드를 통해 숫자 포함 조건만 충족하는 경우를 만들어주면 된다.
// PasswordStrengthMeterTest 클래스
@Test
void meetsOnlyNumCriteria_Then_Weak() {
assertStrength("12345", PasswordStrength.WEAK);;
}
...생략
// PasswordStrengthMeter 클래스
if (!lengthEnough && containsNum && !containsUpp) {
return PasswordStrength.WEAK;
}
여섯 번째, 일곱 번째 테스트 경우와 같다.
// PasswordStrengthMeterTest 클래스
@Test
void meetsOnlyUpperCriteria_Then_Weak() {
assertStrength("ABZEF", PasswordStrength.WEAK);;
}
...생략
// PasswordStrengthMeter 클래스
if (!lengthEnough && !containsNum && containsUpp) {
return PasswordStrength.WEAK;
}
PasswordStrengthMeter
클래스를 확인해보았다.
코드가 꽤 복잡해보여 리팩토링을 진행하였다.
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) { // 길이 8글자 이상 조건만 만족
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 meetsContainingNumberCriteria(String s) {
for (char ch : s.toCharArray()) {
if (ch>='0' && ch<='9') {
return true;
}
}
return false;
}
private boolean meetsContainingUppercaseCriteria(String s) {
for (char ch : s.toCharArray()) {
if (Character.isUpperCase(ch)) {
return true;
}
}
return false;
}
}
먼저 WEAK
를 반환하는 if 절이 연달아 3개 존재한다.
더불어 해당 if 절은 세 조건 중에서 한 조건만 충족하고 있다.
따라서 충족하는 조건 개수를 사용하도록 바꿔보았다.
암호 강도는 이제 metCounts
값을 이용해서 계산한다.
이제 lengthEnough
, containsNum
, containsUpp
변수는 metCounts
값을 증가시킬 때만 사용된다.
public class PasswordStrengthMeter {
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;
}
private boolean meetsContainingNumberCriteria(String s) {
for (char ch : s.toCharArray()) {
if (ch>='0' && ch<='9') {
return true;
}
}
return false;
}
private boolean meetsContainingUppercaseCriteria(String s) {
for (char ch : s.toCharArray()) {
if (Character.isUpperCase(ch)) {
return true;
}
}
return false;
}
}
따라서 위의 코드처럼 lengthEnough
, containsNum
, containsUpp
변수를 제거하고 if 절의 조건문에 직접 넣어 코드를 리팩토링 하였다.
이 경우에는 충족 개수 metCounts
가 0이므로 이에 해당하는 코드를 추가해야 한다.
위 코드 정리의 PasswordStrengthMeter
에서 if (metCounts <= 1) return PasswordStrength.WEAK;
부분만 바꿔주면 된다.
==
에서 <=
로 변경하였다.
metCounts
변수를 계산하는 부분을 메서드로 빼서 meter()
메서드의 가독성을 높일 수 있다.
// PasswordStrengthMeter 클래스 내 getMetCriteriaCounts 메서드 생성
private int getMetCriteriaCounts(String s) {
int metCounts = 0;
if (s.length() >= 8) metCounts++;
if (meetsContainingNumberCriteria(s)) metCounts++;
if (meetsContainingUppercaseCriteria(s)) metCounts++;
return metCounts;
}
...생략
TDD 사이클을 레드(Red)-그린(Green)-리팩터(Refactor)로 부른다.
🔴Red : 실패하는 테스트 코드를 먼저 작성한다.
🟢Green : 테스트 코드를 성공시키기 위한 실제 코드를 작성한다.
🟡Yellow : 중복 코드 제거, 일반화 등의 리팩토링을 수행한다.
요구사항 분석 -> 설계 -> 개발 -> 테스트 -> 배포
소프트웨어 개발을 느리게 하는 잠재적 위험이 존재한다.
테스트 코드를 작성한 뒤에 실제 코드 작성
설계 단계에서 프로그래밍 목적을 미리 정의하고, 무엇을 테스트해야 할지 미리 정의해야 한다.
테스트 코드를 작성하는 도중에 발생하는 예외 상황(버그, 수정사항)들은 테스트 케이스에 추가하고 개선한다.
테스트 -> 코딩 -> 리팩토링
의 반복
테스트를 작성하는 과정에서 구현을 생각하지 않았다.
단지 해당 기능이 올바르게 동작하는지 검증할 수 있는 테스트 코드를 만들었을 뿐이다.
지금까지 작성한 테스트를 통과할 만큼만 구현을 진행하면서, 테스트 코드를 추가하고 범위를 점차 넓혀간다.
구현을 완료한 뒤에는 리팩토링을 진행한다.
당장 리팩토링할 대상이나 어떻게 리팩토링해야 할지 생각나지 않으면 다음 테스트를 진행하고, 대상이 눈에 들어오면 리팩토링을 진행해서 코드를 정리한다.
테스트 코드 자체도 리팩토링 대상에 넣는다.