TDD는 테스트부터 시작한다. 먼저 테스트를 하고 그 다음에 구현을 한다.
TDD 개발 순서
- 테스트 코드를 먼저 작성한다.
개발하고자 하는 기능이 올바르게 동작 했을 때의 결과를 확인 가능한 테스트 코드를 먼저 작성한다. - 테스트 코드에 올바르게 동작하는 기능을 개발한다.
비밀번호 복잡도 평가 로직 개발
/**
* 비밀번호 보안 수준 테스트
* [보안 수준 조건]
* -길이 8글자 이상
* -0~9 사이의 숫자를 포함
* -대문자 포함
*
* 위의 세 가지 조건을 충족하면 암호는 강함.
* 2개의 조건을 충족하면 보통.
* 1개 이하의 조건을 충족하면 약함.
* 비밀번호가 공백(Empty)이거나, 없으면 인식불가.
*/
비밀번호 복잡도의 테스트 로직을 아래와 같이 만든다.
- 테스트 대상 클래스명
- 테스트 대상 메소드명, 파라미터 형식
- 메소드 호출 리턴 타입
- 메소드 호출 결과
src/test/chap01/PasswordStrengthMeterTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class PasswordStrengthMeterTest {
@Test
void meetsAllCriteria_Then_String() {
PasswordStrengthMeter meter = new PasswordStrengthMeter();
PasswordStrength result = meter.meter("ab12!@AB");
assertEquals(PasswordStrength.STRONG, result);
}
}
컴파일 에러를 막기위해 최소한의 클래스를 다음과 같이 추가한다.
- Target 클래스 추가
- Target 메소드 선언 (메소드명, 파라미터, 리턴값)
- 기본 리턴값 하드 코딩
src/main/chap01/PasswordStrength.java
public enum PasswordStrength {
STRONG
}
src/main/chap01/PasswordStrengthMeter.java
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
return null;
}
}
테스트에 통과하도록 로직을 수정 후 테스트
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
return PasswordStrength.STRONG;
}
}
보통 수준의 암호 테스트 케이스 추가
테스트할 로직에 대한 주석 추가
테스트 메서드명 한글로
보통 수준의 암호 케이스 추가
/**
* 길이 8글자 이상
* 9~9 사이의 숫자를 포함
* 대문자 포함
* 위의 세 가지 조건을 충족하면 암호는 강함.
* 2개의 조건을 충족하면 보통.
* 1개 이하의 조건을 충족하면 약함.
*/
public class PasswordStrengthMeterTest {
@Test
void 강한암호_테스트() {
PasswordStrengthMeter meter = new PasswordStrengthMeter();
PasswordStrength result = meter.meter("ab12!@AB");
assertEquals(PasswordStrength.STRONG, result);
}
@Test
void 보통수준암호_대문자_숫자_테스트() {
PasswordStrengthMeter meter = new PasswordStrengthMeter();
PasswordStrength result = meter.meter("AB12!2");
assertEquals(PasswordStrength.NORMAL, result);
}
}
컴파일 에러가 안나도록 enum PasswordStrength에 NORMAL 추가
public enum PasswordStrength {
STRONG,
NORMAL
}
테스트 실행
org.opentest4j.AssertionFailedError:
Expected :NORMAL
Actual :STRONG
두 테스트 모두 통과하도록 로직 추가
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
if(s.length() < 8)
return PasswordStrength.NORMAL;
return PasswordStrength.STRONG;
}
}
* TDD 방식의 개발 진행에서는 한방에 완성된 로직을 짜려고 하는 기존 개발 방식의 습관을 지양해야 한다.
한번에 두 개 이상의 로직을 한꺼번에 추가하는 것을 지양하고, 최소한의 로직만으로 테스트를 통과 시킨 후
테스트 케이스를 추가하면서 로직도 점차 완성되어 가도록 해야 한다.
TDD가 무엇인지는 알지만, TDD 방식으로 개발을 하지 못하는 가장 큰 원인이 이 개발 방식의 패러다임을 이해하지 못한채 코딩 수준에서 Test 케이스 작성까지만 이해했기 때문임
Test-Driven-Development는 테스트 조건을 만들고, 이를 하나씩 통과시켜 나가면서 미완성의 로직을 완성해 나가는 여정
크고 복잡한 클래스 구조, 복잡한 로직의 설계를 최소화하고, 한 단계씩 차근차근 진행하는 과정에서 Side Effect이 적은 코드가 만들어져 가고, TDD 과정에서 자연스러운 리팩토링을 통해 코드 퀄리티가 계속 유지된다.
보통 수준 암호 테스트 케이스 추가
@Test
void 보통수준암호_대문자포함_8자초과_숫자없음_테스트() {
PasswordStrengthMeter meter = new PasswordStrengthMeter();
PasswordStrength result = meter.meter("AB!@abcde");
assertEquals(PasswordStrength.NORMAL, result);
}
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
if(s.length() < 8)
return PasswordStrength.NORMAL;
boolean containsNum = false;
for(char ch : s.toCharArray()) {
if(ch >= '0' && ch <= '9') {
containsNum = true;
break;
}
}
if(!containsNum) return PasswordStrength.NORMAL;
return PasswordStrength.STRONG;
}
}
숫자 체크 로직 리팩토링 - 메서드 분리
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;
}
}
/**
* 숫자 포함 여부 - 람다표현식
* @param s
* @return
*/
public static boolean isContainsNumber(String s) {
return s.chars()
.filter(Character::isDigit)
.findAny()
.isPresent();
}
테스트 코드의 중복 소스 리팩토링
- 모든 테스트 케이스에 반복되는 PasswordStrengthMeter 객체 생성 부분을 클래스 멤버객체로 뽑아 중복을 제거한다.
public class PasswordStrengthMeterTest {
PasswordStrengthMeter meter = new PasswordStrengthMeter();
@Test
void 강한암호_테스트() {
PasswordStrength result = meter.meter("ab12!@AB");
assertEquals(PasswordStrength.STRONG, result);
}
@Test
void 보통수준암호_대문자숫자_테스트() {
PasswordStrength result = meter.meter("AB12!2");
assertEquals(PasswordStrength.NORMAL, result);
}
@Test
void 보통수준암호_대문자8자초과_테스트() {
PasswordStrength result = meter.meter("AB!@abcde");
assertEquals(PasswordStrength.NORMAL, result);
}
}
- 각 테스트 케이스에 반복 등장하는 아래와 같은 부분을 리팩토링(메서드 분리) 한다.
PasswordStrength result = meter.meter("암호값");
assertEquals(PasswordStrength.암호강도, result);
- 메서드 이름을 assertPasswordStrength 로 지정한다.
분리된 메서드는 아래와 같다.
private void assertPasswordStrength() {
PasswordStrength result = meter.meter("ab12!@AB");
assertEquals(PasswordStrength.STRONG, result);
}
- 추가로 "ab12!@AB" 부분을 파라미터로 리팩토링한다.
- 파라미터 이름 : password
- PasswordStrength.STRONG 부분도 파라미터로 뽑아낸다. passwordStrength
- 리팩토링이 끝나면 IntelliJ가 자동으로 방금 전 리팩토링에 의해 중복 제거가 가능한 소스들의 추가 변환 여부를 묻는다.
- [Replace]를 눌러 자동 리팩토링이 되는 신세계를 경험해 보자.
IntelliJ에 의해 중복 소스가 모두 자동으로 분리된 메서드로 리팩토링된다.
새롭게 분리된 메소드를 클래스 하단으로 이동
Option + 화살표(위) 로 블럭 지정
Option + Shift + 화살표(위/아래)로 이동
패스워드 null 에 대한 테스트 케이스 추가
- null 테스트 케이스 추가
@Test
void 비밀번호_NULL_테스트() {
assertPasswordStrength(null, PasswordStrength.INVALID);
}
- 컴파일 가능하도록 enum 추가
public enum PasswordStrength {
STRONG,
NORMAL,
INVALID
}
- 테스트
- null 검출 로직 추가
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
if(s == null) return PasswordStrength.INVALID;
... 생략 ...
- 비밀번호 공백 테스트 케이스 추가
@Test
void 비밀번호_공백_테스트() {
assertPasswordStrength("", PasswordStrength.INVALID);
}
- 비밀번호 공백 검출 로직 추가
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
if(s == null || s.isEmpty()) return PasswordStrength.INVALID;
... 생략 ...
//대문자 포함x
@Test
void 보통수준암호_숫자_8자이상_테스트() {
assertPasswordStrength("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 containsUpperCase = false;
for(char ch:s.toCharArray()) {
if(Character.isUpperCase(ch)) {
containsUpperCase = true;
break;
}
}
if(containsUpperCase == false) return PasswordStrength.NORMAL;
//------- 추가한 코드 ----------//
return PasswordStrength.STRONG;
}
방금 추가한 코드를 메서드 분리
- 분리된 메서드에서 불필요한 코드 정리
private boolean isContainsUpperCase(String s) {
for(char ch: s.toCharArray()) {
if(Character.isUpperCase(ch)) {
return true;
}
}
return false;
}
- 숫자 포함 확인 메서드 이름 리팩토링 -> isContainsNumber
/**
* 대문자 포함 여부 -> 람다 표현식
* @param s
* @return
*/
public static boolean isContainsUpperCase(String s) {
return s.chars()
.filter(Character::isUpperCase)
.findAny()
.isPresent();
}
낮은 수준 암호 테스트 케이스 추가
@Test
void 낮은수준암호_8자이내_테스트() {
assertPasswordStrength("ab12", PasswordStrength.WEAK);
}
복잡도 조건을 카운트 하도록 로직 변경
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
if(s == null || s.isEmpty()) return PasswordStrength.INVALID;
int meetCounts = 0;
if(s.length() >= 8) meetCounts++;
boolean containsNum = isContainsNumber(s);
if(containsNum) meetCounts++;
boolean containsUpperCase = isContainsUpperCase(s);
if(containsUpperCase) meetCounts++;
if(meetCounts == 3)
return PasswordStrength.STRONG;
if(meetCounts == 2)
return PasswordStrength.NORMAL;
return PasswordStrength.WEAK;
}
... 생략 ...
- enum에 WEAK 추가
public enum PasswordStrength {
INVALID,
WEAK,
NORMAL,
STRONG
}
PasswordStrengthMeter 메서드의 두 가지 역할을 분리
- 비밀번호 보안 수준에 일치하는 갯수를 세는 역할
- 갯수에 따라 비밀번호 보안 수준 강도를 결정하는 역할
- IntelliJ의 메소드 분리 기능으로 간단하게 메소드 분리 후 이름을 getMeetPasswordCriteriaCount로 변경
하드코딩 제거 - 비밀번호 길이 8 -> MINIMUM_PASSWORD_LENGTH_CRITERIA
private int getMeetPasswordCriteriaCount(String s) {
int meetCounts = 0;
if(s.length() >= MINIMUM_PASSWORD_LENGTH_CRITERIA) meetCounts++;
하드코딩 또 제거 - 갯수를 비교하는 부분의 하드코딩을 없애고 싶다.
- 하드 코딩은 "병적이다" 싶을 정도로 제거하는 것이 좋습니다.
// 변경 전
public class PasswordStrengthMeter {
public static final int MINIMUM_PASSWORD_LENGTH_CRITERIA = 8;
public PasswordStrength meter(String s) {
if(s == null || s.isEmpty()) return PasswordStrength.INVALID;
int meetCounts = getMeetPasswordCriteriaCount(s);
if(meetCounts == 3)
return PasswordStrength.STRONG;
if(meetCounts == 2)
return PasswordStrength.NORMAL;
return PasswordStrength.WEAK;
}
// 변경 후
public class PasswordStrengthMeter {
public static final int MINIMUM_PASSWORD_LENGTH_CRITERIA = 8;
public PasswordStrength meter(String s) {
if(s == null || s.isEmpty()) return PasswordStrength.INVALID;
int meetCounts = getMeetPasswordCriteriaCount(s);
return PasswordStrength.getPasswordStrength(meetCounts);
}
... 생략 ...
public enum PasswordStrength {
INVALID(-1),
WEAK(0),
NORMAL(2),
STRONG(3);
private int passwordCriteriaCnt;
PasswordStrength(int passwordCriteriaCnt) {
this.passwordCriteriaCnt = passwordCriteriaCnt;
}
public static PasswordStrength getPasswordStrength(int meetCriteriaCnt) {
if(meetCriteriaCnt == PasswordStrength.STRONG.passwordCriteriaCnt) return PasswordStrength.STRONG;
if(meetCriteriaCnt == PasswordStrength.NORMAL.passwordCriteriaCnt) return PasswordStrength.NORMAL;
if(meetCriteriaCnt >= PasswordStrength.WEAK.passwordCriteriaCnt) return PasswordStrength.WEAK;
return PasswordStrength.INVALID;
}
}
복잡도를 더 줄이고 싶다.
- 현재 정리된 로직에서는 굳이 PasswordStrength와 getMeetPasswordCriteriaCount를 분리할 필요는 없어 보인다.
- 오히려 깔끔하게 합치는 것이 이해가 쉬울 것
// 변경 전
public class PasswordStrengthMeter {
public static final int MINIMUM_PASSWORD_LENGTH_CRITERIA = 8;
public PasswordStrength meter(String s) {
if(s == null || s.isEmpty()) return PasswordStrength.INVALID;
int meetCounts = getMeetPasswordCriteriaCount(s);
return PasswordStrength.getValue(meetCounts);
}
private int getMeetPasswordCriteriaCount(String s) {
int meetCounts = 0;
if(s.length() >= MINIMUM_PASSWORD_LENGTH_CRITERIA) meetCounts++;
boolean containsNum = isContainsNumber(s);
if(containsNum) meetCounts++;
boolean containsUpperCase = isContainsUpperCase(s);
if(containsUpperCase) meetCounts++;
return meetCounts;
}
// 변경 후
public class PasswordStrengthMeter {
public static final int MINIMUM_PASSWORD_LENGTH_CRITERIA = 8;
public PasswordStrength meter(String s) {
if(s == null || s.isEmpty()) return PasswordStrength.INVALID;
int meetCounts = 0;
if(s.length() >= MINIMUM_PASSWORD_LENGTH_CRITERIA) meetCounts++;
if(isContainsNumber(s)) meetCounts++;
if(isContainsUpperCase(s)) meetCounts++;
return PasswordStrength.getPasswordStrength(meetCounts);
}
최종 소스
public enum PasswordStrength {
INVALID(-1),
WEAK(0),
NORMAL(2),
STRONG(3);
private int passwordCriteriaCnt;
PasswordStrength(int passwordCriteriaCnt) {
this.passwordCriteriaCnt = passwordCriteriaCnt;
}
public static PasswordStrength getPasswordStrength(int meetCriteriaCnt) {
if(meetCriteriaCnt == PasswordStrength.STRONG.passwordCriteriaCnt) return PasswordStrength.STRONG;
if(meetCriteriaCnt == PasswordStrength.NORMAL.passwordCriteriaCnt) return PasswordStrength.NORMAL;
if(meetCriteriaCnt >= PasswordStrength.WEAK.passwordCriteriaCnt) return PasswordStrength.WEAK;
return PasswordStrength.INVALID;
}
}
public class PasswordStrengthMeter {
public static final int MINIMUM_PASSWORD_LENGTH_CRITERIA = 8;
/**
* 비밀번호 보안 수준 측정
*/
public PasswordStrength meter(String s) {
if(s == null || s.isEmpty()) return PasswordStrength.INVALID;
int meetCounts = 0;
if(s.length() >= MINIMUM_PASSWORD_LENGTH_CRITERIA) meetCounts++;
if(isContainsNumber(s)) meetCounts++;
if(isContainsUpperCase(s)) meetCounts++;
return PasswordStrength.getPasswordStrength(meetCounts);
}
/**
* 대문자 포함 여부
* @param s
* @return
*/
private boolean isContainsUpperCase(String s) {
return s.chars()
.filter(Character::isUpperCase)
.findAny()
.isPresent();
}
/**
* 숫자 포함 여부
* @param s
* @return
*/
private boolean isContainsNumber(String s) {
return s.chars()
.filter(Character::isDigit)
.findAny()
.isPresent();
}
}
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* 비밀번호 보안 수준 테스트
* [보안 수준 조건]
* -길이 8글자 이상
* -0~9 사이의 숫자를 포함
* -대문자 포함
*
* 위의 세 가지 조건을 충족하면 암호는 강함.
* 2개의 조건을 충족하면 보통.
* 1개 이하의 조건을 충족하면 약함.
* 비밀번호가 공백이거나, 없으면 인식불가.
*/
public class PasswordStrengthMeterTest {
PasswordStrengthMeter meter = new PasswordStrengthMeter();
private void assertPasswordStrength(String password, PasswordStrength passwordStrength) {
PasswordStrength result = meter.meter(password);
assertEquals(passwordStrength, result);
}
@Test
void 강한암호_테스트() {
assertPasswordStrength("ab12!@AB", PasswordStrength.STRONG);
}
@Test
void 보통수준암호_대문자숫자_테스트() {
assertPasswordStrength("AB12!2", PasswordStrength.NORMAL);
}
@Test
void 보통수준암호_대문자8자초과_테스트() {
assertPasswordStrength("AB!@abcde", PasswordStrength.NORMAL);
}
@Test
void 비밀번호_NULL_테스트() {
assertPasswordStrength(null, PasswordStrength.INVALID);
}
@Test
void 비밀번호_공백_테스트() {
assertPasswordStrength("", PasswordStrength.INVALID);
}
@Test
void 보통수준암호_숫자포함8자이상_테스트() {
assertPasswordStrength("ab12!@df", PasswordStrength.NORMAL);
}
@Test
void 낮은수준암호_8자이내_테스트() {
assertPasswordStrength("ab12", PasswordStrength.WEAK);
}
@Test
void 대문자포함_테스트() {
boolean containsUpppercase = PasswordStrengthMeter.isContainsUpperCase("abcde");
Assertions.assertFalse(containsUpppercase);
containsUpppercase = PasswordStrengthMeter.isContainsUpperCase("123ABab");
Assertions.assertTrue(containsUpppercase);
}
@Test
void 숫자포함_테스트() {
boolean containsUpppercase = PasswordStrengthMeter.isContainsNumber("abcde");
Assertions.assertFalse(containsUpppercase);
containsUpppercase = PasswordStrengthMeter.isContainsNumber("123ABab");
Assertions.assertTrue(containsUpppercase);
}
}
'프로그래밍 > OOP_Pattern_TDD' 카테고리의 다른 글
TDD - Gradle 프로젝트에 JaCoCo & SonarQube 적용 (0) | 2022.08.05 |
---|---|
TDD 테스트 코드 작성 순서 (0) | 2022.08.03 |
IntelliJ에서 TDD 개발 환경 준비 (0) | 2022.08.03 |
디자인패턴 프로토타입 패턴 (Prototype Pattern) (0) | 2022.08.02 |
디자인패턴 메멘토 패턴(memento pattern) (0) | 2022.08.02 |