일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 | 31 |
- 로그 시스템
- java
- 예외 처리
- @Transactional
- spring
- jpa
- Redis
- TDD
- error
- 로그
- 테스트 주도 개발
- iam user 새성
- 카카오 소셜 로그인 에러 #카카오 소셜로그인 redirect #카카오소셜로그인 프런트 연동 에러
- gradle
- iam user
- 에러 모니터링
- SENTRY
- SQL
- Spring Boot
- MongoDB
- crud-update
- Exception
- aws 접근 권한
- nosql
- AWS IAM
- exceptionHandler
- aws
- Today
- Total
zini's blog
2장 TDD 시작 본문
TDD 이전의 개발
만들 기능 설계 고민 -> 구현 -> 기능 테스트 (문제 발생시 디버깅하며 원인 찾기)
문제점
- 한 번에 작성한 코드가 많은 경우 많은 디버깅 시간 소모됨
- 코드 작성자와 테스트하는 개발자가 다른 경우
- 테스트 과정의 어려움
즉, 개발 시간 ↑↑
=> TDD를 활용하면 개발 시간 줄일 수 있다!
TDD란?
TDD는 테스트부터 시작!
기능을 검증하는 테스트 코드를 먼저 작성하고 테스트를 통과시키기 위해 개발을 진행한다.
간단한 덧셈 기능을 TDD로 구현해보기
1. 테스트 작성
package chap02;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class CalculatorTest {
@Test
void plus(){
int result = Calculator.plus(1,2);
assertEquals(3, result);
}
}
- Junit은 @Test가 붙은 메서드를 테스트 메서드로 인식
- 계산 기능을 실행하는 코드를 작성
- 실행 결과가 기대한 값이 나오는지 assetEquals() 메서드를 통해 검증
assertEquals(기대한 값, 실제값) : 기대한 값과 실제 값을 파라미터로 받아 동일한지 비교, 동일하지 않으면 AssertionFalidError가 발생
계산 기능을 실행하는 코드를 작성할 때, 메서드이름, 파라미터 개수, 정적or인스턴스 메서드 선택, 클래스 이름 등의 고민이 발생 (실제 코드를 설계하는 과정과 유사)
실행 시, Calculator 클래스를 아직 작성하지 않아 컴파일에러가 발생
2. Calculator 클래스를 작성
package chap02;
public class Calculator {
public static int plus(int a1, int a2) {
return 0;
}
}
다시 테스트 실행시키면 에러가 발생
에러 메시지를 살펴보면 기대값(3)과 실제값(0)이 달라 생긴 에러임을 알 수 있음.
3. Calculator의 return 값을 3으로 고치고 다시 실행 -> 테스트 통과!
4. 테스트 클래스에 덧셈 검증 코드를 하나 더 추가
assertEquals(5, Calculator.plus(4,1));
기대값과 실제값이 달라 에러가 발생
5. 1+2 와 4+1을 모두 통과시키도록 Calculator.class 코드를 수정
public class Calculator {
public static int plus(int a1, int a2) {
if(a1==4 && a2 ==1) return 5;
else return 3;
}
}
덧셈 코드는 단순하므로 다시 수정해보면
public class Calculator {
public static int plus(int a1, int a2) {
return a1 + a2;
}
}
이렇게 점진적으로 수정해 나갈 수 있다.
6. 덧셈 기능을 완성했으니 이제 test/java 소스 폴더에서 main/java 소스 폴더로 옯겨 배포 대상에 포함시키기
테스트 실행 -> 통과하는지 확인
구현 중엔 src/test/java 소스 폴더에 코드를 만들어 배포 대상에서 제외시키고 이후에 이동시켜
완성되지 않은 코드가 배포되는 것을 막을 수 있다.
흐름을 정리해보면,
기능 검증 테스트 코드 작성 -> 컴파일 오류 없애기 위한 클래스와 메서드 작성 -> 테스트 통과할 만큼의 코드 작성
-> ( 테스트 추가 -> 다시 통과위한 코드 작성 ) 과정 반복을 통해 점진적으로 기능을 완성해 나간다.
TDD 예 : 암호 검사기
- 문자열을 검사해서 규칙을 준수하는지에 따라 암호를 '약함', '보통', '강함' 으로 구분한다.
- 규칙
- 8글자 이상
- 0~9까지 숫자 포함
- 대문자 포함
- 3개 만족 : 강함, 2개 만족 : 보통, 1개 이하 만족 : 약함
1. 테스트할 기능 이름 짓기 : PasswordLevel, PasswordStrength 등의 단어 사용
2. 테스트할 기능을 재공할 클래스의 이름 짓기 : 검사한다는 의미 포함하는 PasswordStrengthMeter
3. 테스트 코드 작성
package chap02;
import org.junit.jupiter.api.Test;
public class PasswordStrengthMeterTest {
@Test
void name(){
}
}
아무 검증도 하지 않는 테스트 메서드 이므로 실행시 당연히 통과함.
첫번째 테스트 : 모든 규칙을 충족하는 경우
📍중요! 첫번째 테스트 선택 시 가장 쉽거나 예외적인 상황을 선택해야 함
암호 검사 기능에서는 두가지 상황을 생각해 볼 수 있음
- 모든 규칙을 충족하는 경우
- 테스트 통과위해 각 조건을 검사하는 코드를 모두 구현해야 함
- 한 번에 많은 코드 구현해야하므로 사실상 구현 -> 테스트 방식과 다르지 않음
- 모든 규칙을 충족X 경우
- 테스트 쉽게 통과 가능
- 각 조건 검사하는 코드 구현 만들지 않고 '강함'에 해당하는 값 리턴하면 테스트 통과가능
후자의 경우를 먼저 작성해보자
1. 테스트 코드 작성
- - 암호가 모든 조건 충족하면 암호 강도는 강함 : meetsAllCriteria_Then_Strong 으로 메서드 이름 지음
- - PassWordStrengthMeterTest의 meter()메서드의 리턴값 정하기 : int 타입을 사용하거나 열거 타입 사용
package chap02;
import org.junit.jupiter.api.Test;
public class PasswordStrengthMeterTest {
@Test
void meetsAllCriteria_Then_Strong (){
PasswordStrengthMeter meter = new PasswordStrengthMeter();
PasswordStrength strength = meter.meter("ab12!@AB");
assertEquals(PasswordStrength.STRONG, result);
}
}
실행 -> 컴파일 에러
2. 컴파일 에러 없애기 위해 필요한 클래스와 enum 클래스 작성
package chap02;
public enum PasswordStrength {
STRONG;
}
'보통', '약함' 도 추가할 순 있지만 일단 테스트를 통과할 만큼만 작성한 것
PasswordStrengthMeter 클래스 작성
package chap02;
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
return null;
}
}
실행 -> 기대 값(STRONG)과 실제값(null)이 달라 테스트 실패함을 알 수 있음
3. PasswordStrengthMeter 의 meter()메서드가 STRONG을 return 하도록 수정
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
return PasswordStrength.STRONG;
}
}
실행 -> 통과
4. 모든 규칙을 충족하는 예 추가
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);
}
}
실행 -> 통과
두 번째 테스트 : 길이만 8글자 미만이고 나머지 조건은 충족
1. 테스트 메서드 추가
public class PasswordStrengthMeterTest {
...
@Test
void meetsOtherCriteria_except_for_Length_Then_Normal() {
PasswordStrengthMeter meter = new PasswordStrengthMeter();
PasswordStrength result = meter.meter("ab12!@A");
assertEquals(PasswordStrength.Normal, result);
}
}
실행 ->열거 타입(enum) 에 NORMAL이 없으므로 컴파일에서 발생
2. 열거 타입에 NORMAL 추가
public enum PasswordStrength {
NORMAL, STRONG;
}
실행 -> 실패
3. meter() 메서드가 NORMAL을 반환하도록 수정
-> 첫 번째 테스트를 통과하지 못함
두 테스트 모두 통과시키도록 코드 작성
-> 길이가 8보다 작으면 NORMAL을 반환하도록 추가
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
if (s.length() < 8) {
return PasswordStrength.NORMAL;
}
return PasswordStrength.STRONG;
}
}
실행 -> 통과!
4. 앞에서 했던 것 처럼 길이가 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("sg324@!");
assertEquals(PasswordStrength.NORMAL, result2);
}
실행 -> 통과
세 번째 테스트 : 숫자 포함 X, 나머지 조건은 충족
1. 테스트 코드 추가
@Test
void meetsOtherCriteria_except_for_number_Then_Normal() {
PasswordStrengthMeter meter = new PasswordStrengthMeter();
PasswordStrength result = meter.meter("abcd!@ABCD");
assertEquals(PasswordStrength.NORMAL, result);
}
실행 -> 실패 (값 불일치)
2. 암호가 숫자를 포함했는지 판단해서 포함하지 않는 경우 NORMAL 리턴하도록 구현
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;
}
}
각 문자를 비교해 0~9사이 숫자를 포함하고 있지 않으면 NORMAL 리턴하도록 구현
테스트 실행 -> 통과!
3. 코드 리팩토링
숫자 포함 여부를 확인하는 코드를 메서드로 추출
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;
}
}
테스트 실행 -> 통과
코드 정리 : 테스트 코드 정리
세 개의 테스트 메서드가 중복 코드를 가지고 있음을 확인할 수 있다.
중복을 없애보자
- PasswordStrengthMeter 객체 생성 코드 중복 -> 필드에서 생성하도록 수정
public class PasswordStrengthMeterTest {
PasswordStrengthMeter meter = new PasswordStrengthMeter();
@Test
void meetsAllCriteria_Then_Strong (){
PasswordStrength result = meter.meter("ab12!@AB");
assertEquals(PasswordStrength.STRONG, result);
...
}
@Test
void meetsOtherCriteria_except_for_Length_Then_Normal() {
PasswordStrength result = meter.meter("ab12!@A");
assertEquals(PasswordStrength.NORMAL, result);
...
}
@Test
void meetsOtherCriteria_except_for_number_Then_Normal() {
PasswordStrength result = meter.meter("abcd!@ABCD");
assertEquals(PasswordStrength.NORMAL, result);
}
}
테스트 실행 > 통과
- 암호 강도 측정 기능 실행하고 검증하는 코드 형식 중복 -> 메서드 추출
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("sg324@!", PasswordStrength.NORMAL);
}
@Test
void meetsOtherCriteria_except_for_number_Then_Normal() {
assertStrength("abcd!@ABCD", PasswordStrength.NORMAL);
}
}
테스트 실행 > 통과
※ 중복으로 인해 가독성이 떨어지고 수정이 용이한 경우에만 중복 제거
무조건 제거하는건 아님!
네 번째 테스트 : 값이 없는 경우
지금의 코드로는 값이 없는 경우 meter() 메서드에서 NPE(NullPointerException) 발생함
null인 경우에도 알맞게 동작하도록 테스트를 추가하고 수정해보자
IllegalArgumentException을 발생 or PasswordStrength.INVALID를 리턴하는 두가지 방법 중 후자 택
1. 테스트 코드 추가
@Test
void nullInput_Then_Invalid() {
assertStrength(null, PasswordStrength.INVALID);
}
}
실행 > 컴파일 에러
2. enum 클래스에 INVALID 추가
public enum PasswordStrength {
INVALID, NORMAL, STRONG;
}
테스트 실행 > 실패 (NPE 발생)
3. null 처리 구현 추가
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
if(s==null) return PasswordStrength.INVALID;
if (s.length() < 8) {
return PasswordStrength.NORMAL;
}
...
}
테스트 실행 -> 통과!
4. 빈 문자열에 대한 테스트도 추가
@Test
void emptyInput_Then_Invalid() {
assertStrength("", PasswordStrength.INVALID);
}
테스트 실행 -> 실패
5. 구현 코드 수정
if(s==null || s.isEmpty()) return PasswordStrength.INVALID;
테스트 실행 -> 통과
다섯 번째 테스트 : 대문자 포함X, 나머지 조건은 충족
1. 테스트 코드 작성
@Test
void meetsOtherCriteria_except_for_Uppercase_Then_Normal() {
assertStrength("asdf!34@df", PasswordStrength.NORMAL);
}
실행 -> 실패
2. 구현 코드 추가
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
...
boolean containsNum = meetsContainingNumberCriteria(s);
if (!containsNum) return PasswordStrength.NORMAL;
boolean containsUpp = false;
for (char ch:s.toCharArray()) {
if (Character.isUpperCase(ch)) {
containsUpp = true;
break;
}
}
if(!containsUpp) return PasswordStrength.NORMAL;
return PasswordStrength.STRONG;
}
...
실행 -> 성공
3. 코드 리팩토링
meter() 메서드가 다시 복잡해졌으니 메서드 추출로 정리
package chap02;
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
...
boolean containsUpp = meetsContainingUppercaseCriteria(s);
if(!containsUpp) return PasswordStrength.NORMAL;
return PasswordStrength.STRONG;
}
private static boolean meetsContainingUppercaseCriteria(String s) {
for (char ch: s.toCharArray()) {
if (Character.isUpperCase(ch)) {
return true;
}
}
return false;
}
...
실행 -> 성공
여섯 번째 테스트 : 길이만 8글자 이상인 조건만 충족
이제 한 가지 조건만 충족하거나 모든 조건을 충복하지 않는 경우 테스트 해야 함
과정은 앞의 방법과 유사하다
1. 테스트 코드 작성
@Test
void meetsOnlyLengthCriteria_Then_Weak() {
assertStrength("abcdefghi", PasswordStrength.WEAK);
}
실행 -> 컴파일 에러
2. PasswordStrength 열거 타입에 WEAK 추가
public enum PasswordStrength {
INVALID, WEAK, NORMAL, STRONG;
}
실행 -> NORMAL=! WEAK 에러
3. 길이 조건 충족하고 나머지 두 조건 충족하지 않을 때 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;
}
...
실행 -> 통과
일곱 번째 테스트 : 숫자 포함 조건만 충족
1. 테스트 코드 추가
@Test
void meetsOnlyNumCriteria_Then_Weak() {
assertStrength("12345", PasswordStrength.WEAK);
}
실행 -> 실패
2. 구현 코드 추가
// 숫자 조건만 만족
if(!lengthEnough && containsNum && !containsUpp)
return PasswordStrength.WEAK;
실행 -> 통과
여덟 번째 테스트 : 대문자 포함 조건만 충족
1. 테스트 코드 추가
@Test
void meetsOnlyUpperCriteria_Then_Weak() {
assertStrength("AGFFE", PasswordStrength.WEAK);
}
실행 -> 실패
2. 구현 코드 추가
// 대문자 조건만 만족
if(!lengthEnough && !containsNum && containsUpp)
return PasswordStrength.WEAK;
실행 -> 통과
코드 정리: meter() 메서드 리팩토링
조건의 개수를 사용하여 코드 리팩토링
기존의 각 조건의 값을 받아오는 세 변수를 없애고 충족한 조건의 개수를 카운트하는 metCounts 변수 생성
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
if(s==null || s.isEmpty()) return PasswordStrength.INVALID;
int metCounts = 0;
if (s.length() >= 9) metCounts++;
if (meetsContainingNumberCriteria(s)) metCounts++;
if (meetsContainingUppercaseCriteria(s)) metCounts++;
if (metCounts ==1) return PasswordStrength.WEAK;
if (metCounts ==2) return PasswordStrength.NORMAL;
return PasswordStrength.STRONG;
}
실행 -> 성공
아홉 번째 테스트 : 아무 조건도 충족 X
1. 테스트 추가
@Test
void meetsNoCriteria_Then_Weak() {
assertStrength("abc", PasswordStrength.WEAK);
}
실행 -> 실패 (기댓값:WEAK, 실제값:STRONG)
2. 구현 코드 수정
충족 개수 1개 이하인 경우 WEAK 리터하도록 수정
if (metCounts <= 1) return PasswordStrength.WEAK;
실행 -> 성공
코드 정리: 코드 가독성 개선
1. metCounts 변수를 계산하는 부분 메서드 추출
public class PasswordStrengthMeter {
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() >= 9) metCounts++;
if (meetsContainingNumberCriteria(s)) metCounts++;
if (meetsContainingUppercaseCriteria(s)) metCounts++;
return metCounts;
}
실행 -> 통과
테스트에서 메인으로 코드 이동
실행 -> 통과
TDD 흐름
- 테스트 -> 코딩 -> 리팩토링 -> 다시 테스트 ..... 이 과정의 반복
- 이렇게 테스트를 먼저 작성하고 위 과정의 반복으로 점진적으로 기능을 완성하는 것이 TDD의 흐름
- 이 TDD 사이클을 '레드 - 그린 - 리팩터' 라고도 부른다.
테스트가 개발을 주도
- 테스트를 먼저 작성하고, 통과시킬 만큼만 구현을 한다. 아직 추가하지 않은 테스트는 고려하지 않는다.
- 테스트 코드가 추가되면서 검증 범위가 넓어지고 구현도 완성되어 간다.
지속적인 코드 정리
- 리팩토링 대상이 눈에 들어오면 바로 진행하며 정리한다.
- 해당 기능이 온전하게 동작함을 검증해주는 테스트가 있기에 보다 과감히 리팩토링을 할 수 있다.
빠른 피드백
- TDD는 코드 수정에 대한 피드백이 빠르다는 장점을 가진다.
- 코드의 수정 또는 추가 시 바로 테스트를 돌려서 확인이 가능하기에, 잘못된 코드의 배포를 방지할 수 있다.
⌈테스트 주도 개발 시작하기⌋ 책 스터디를 진행하며 작성한 글입니다.
책 출처
최범균, ⌈테스트 주도 개발 시작하기⌋, 가메출판사
'스터디 > 테스트 주도 개발 시작하기' 카테고리의 다른 글
5장 JUnit 5기초 (0) | 2024.09.26 |
---|---|
4장 TDD-기능 명세-설계 (0) | 2024.09.26 |
3장 테스트 코드 작성 순서 (0) | 2024.09.13 |
1장 TDD 개발 준비 (0) | 2024.09.07 |