여러 매체를 통해서 Java, SpringBoot, JPA를 공부하다 보니, TDD(Test Driven Development)에 대해 자주 접하게 되었습니다. 또한 실무에서 프로젝트를 진행하였을 때 계속해서 같은 테스트를 진행해봐야 하는 상황이 생겼었는데, 테스트 주도 개발로 개발을 했었더라면 보다 유지보수가 쉬웠지 않았을까 하는 생각이 들었습니다. 그래서 TDD를 공부하게 되었습니다. 이번 포스팅은 TDD를 해야 하는 이유와 간단한 예시를 들어보려 합니다.
TDD란?
제목과 같이 테스트로부터 시작하는 개발 방식을 일컫습니다. TDD는 먼저 실패하는 테스트 코드를 작성하고, 테스트를 통과시킬 만큼만 코드 구현을 진행하는 방식입니다. 이후에 어느 정도 테스트 과정이 끝나면 코드 정리(리팩토링)를 통해서 최종 산출물을 얻어냅니다. 그렇다면 이런 TDD를 왜 해야 할까요? 장점 먼저 살펴보겠습니다.
TDD의 장점🌞
첫 번째로 테스트 코드가 있으면 문제 범위를 좁혀서 디버깅하는 게 수월합니다. 프로젝트를 진행하다 보면 상당히 복잡한 기능을 구현해야 할 때가 있습니다. 만약 추후에 그 기능에 문제가 생겼을 때, 테스트 코드가 없다면 한줄한줄 디버깅을 해봐야 하는 경우가 생깁니다. 하지만, 테스트 코드가 있을 시에는 큰 기능 안에서 작은 기능들로 나뉘어 어떤 부분이 잘못되었는지 한눈에 파악할 수 있습니다.
두 번째로 코드 구조/설계가 좋아질 가능성이 높아집니다. 테스트가 가능하려면 의존 대상을 대역으로 교체할 수 있어야 하고, 대역으로 교체할 수 있는 구조는 그만큼 역할별로 분리되어 있을 가능성이 높기 때문입니다.
세 번째로 리팩토링 시에 안정성을 확보할 수 있습니다. 기존 코드는 그대로 남겨 놓은 채 테스트 파일에서만 테스트가 진행되기 때문에, 코드를 고치는 과정에서 안정성을 확보할 수 있는 장점이 있습니다.
네 번째로 개발 및 테스팅에 대한 시간과 비용을 절감할 수 있습니다. 가장 중요한 부분이기도 합니다. '테스트 코드를 작성하는 시간이 있는데 더 비용을 많이 쓰는 거 아닌가?'라는 생각을 하실 수도 있습니다. 하지만, 실제로 프로젝트를 진행하다 보면 개발이 끝난 뒤에 문제가 없는지 확인하기 위해 애플리케이션을 자주 실행하실 것입니다. 직접 수동 테스트를 진행하는 과정에서 서버를 껐다/켰다 하고 직접 눈으로 어떤 데이터가 나오는지도 확인해야 하는 번거로움이 있습니다.
이뿐만이 아니라, 테스트 코드가 없다면 버그가 있을 확률이 매우 높아집니다. 모든 버그들을 수정하고 테스트를 반복하는 비용은 프로그램이 커지면 커질수록 기하급수적으로 늘어나게 됩니다.
즉, 테스트 주도 개발은 장기적으로 유지보수면에서 상당한 효과를 발휘합니다.
어떤 상황에서 해야 할까?🦧
만약 기능을 만들어봤었고 해당 코딩의 결과가 어떻게 나올지 뻔하다면 굳이 TDD를 하지 않아도 됩니다. 또한 TDD를 진행 했을 때 얻는 것이 적다고 판단이 들면 하지 않아도 됩니다. 하지만 아래 상황에서는 필수적으로 진행하는 것이 좋습니다.
- 처음해보는 프로젝트 주제
- 고객의 요구조건이 바뀔 수 있는 주제
- 개발하고 나서 프로젝트의 유지보수를 타인이 해야 할 때
- 코드 수정이 많다고 예상되거나, 테스트할 대상이 많다고 예상될 때
이 밖에도, 실제 프로젝트를 진행 하다 보면 소비자의 요구사항이 처음부터 명확하지 않다는 것을 느끼게 됩니다. 따라서 처음부터 완벽한 설계는 사실상 힘드며 진행하는 기간 동안 점진적으로 개발해 나가야 합니다.
이러한 모든 사항들에 대해 TDD는 위에서 언급한 장점처럼 완벽히 대처할 수 있습니다 :)
TDD 방법 및 순서📑
- 가장 먼저 실패하는 테스트를 작성합니다.
- 경우의 수를 생각하면서 테스트를 통과할 때 까지 작성합니다(삼각 측량법)
- 실패 테스트가 없다고 생각이 들 경우 성공 테스트를 작성합니다.
- 3번까지의 과정을 계속해서 반복하면서 테스트를 일반화시킵니다.
- 프로덕트 코드가 완성되었다면 리팩토링 과정을 거쳐 중복을 제거하고 코드의 가독성을 높여줍니다.
- 리팩토링 된 코드를 main으로 옮겨 사용합니다.
삼각 측량법
값이 다른 여러 테스트를 작성하고, 이를 일반화하여 정답을 구현하는 방법
예시😄
이번에는 간단한 예시를 들어보려 합니다. 위 순서에 맞게 암호 검사기 기능을 만들어보겠습니다. 프로젝트 설정 과정은 포스팅 주제에 맞지 않아 생략하였습니다.
* 예시에 적용한 모든 코드 내용은 하단에 올리겠습니다. 혹여 파일 구조를 보고 싶으신 분은 깃허브로 확인해주시면 감사하겠습니다.
암호 검사기
1. 길이가 8글자 이면 암호 강도가 올라간다.
2. 대문자를 포함하고 있으면 암호 강도가 올라간다.
3. 숫자를 포함하고 있으면 암호 강도가 올라간다.
암호 검사기는 흔히 웹사이트에서 회원가입을 진행 시에 볼 수 있는 기능입니다. 위 사항처럼 암호 강도가 있고, null이나 빈 값이 들어올 경우에는 INVALID(유효하지 않다)를 리턴하겠습니다. 그리고 1개의 조건을 만족 시에는 WEEK, 2개의 조건을 만족시에는 NORMAL, 3개 조건 다 만족시에는 STRONG을 리턴하겠습니다.
private void assertPasswordStrength(String password, PasswordStrength expected) {
PasswordMeter meter = new PasswordMeter();
PasswordStrength result = meter.meter(password);
assertThat(result).isEqualTo(expected);
}
assertPasswordStrength 메서드는 입력값 password와 예상하는 값 expected를 파라미터로 받습니다. 각각의 클래스를 선언한 후에 assertThat을 이용하여 값을 비교해주는 공통 메서드입니다.
public enum PasswordStrength {
STRONG, NORMAL, WEEK, INVALID
}
PasswordStrength는 enum 열거형으로 선언해주었습니다. 강도는 STRONG(강함) > NORMAL(보통) > WEEK(약함) > INVALID(유효하지 않음) 순으로 구성하였습니다. 혹여나 enum 열거형을 모르시다면 이 글을 참고해주세요
public class PasswordMeterTest {
@Test
public void nullInput() {
assertPasswordStrength(null, PasswordStrength.INVALID);
}
}
첫 번째로 위 순서와 맞게 먼저 실패하는 테스트를 작성합니다. 처음에는 당연히 컴파일 에러부터 시작하지만 그 단계는 생략하였습니다. 파라미터 값으로 null과 기대하는 값인 INVALID를 넘겨줍니다.
public class PasswordMeter {
public PasswordStrength meter(String password) {
if (password == null) {
return PasswordStrength.INVALID;
}
}
PasswordMeter 클래스에서는 if 조건문을 이용하여 파라미터로 넘겨받은 password 값이 null일 경우 INVALID를 리턴해줍니다. 앞서 언급했듯이, 처음에는 if 조건문이 없어서 당연히 테스트를 실패할 것입니다. 실패한 내용을 확인하고 해당 실패 테스트를 성공시키기 위해 조건문을 삽입하였습니다.
@Test
public void emptyInput() {
assertPasswordStrength("", PasswordStrength.INVALID);
}
public PasswordStrength meter(String password) {
if (password == null) {
return PasswordStrength.INVALID;
}
if (password.isEmpty()) {
return PasswordStrength.INVALID;
}
}
빈 값을 넣었을 때도 동일하게 INVALID를 리턴 시켜주어야 하기 때문에 동일하게 코드를 작성합니다. 자 이제, INVALID에 해당하는 경우의 수는 모두 테스트 코드를 만들었습니다. 이제는 강도를 측정하는 3가지 경우의 수(길이, 대문자 포함 여부, 숫자 포함 여부) 테스트 코드를 작성해야 합니다.
@Test
public void meetOnlyLengthRule() {
assertPasswordStrength("abcdefgjwof", PasswordStrength.WEEK);
}
public PasswordStrength meter(String password) {
if (password == null) {
return PasswordStrength.INVALID;
}
if (password.isEmpty()) {
return PasswordStrength.INVALID;
}
if (password.length() >= 8) {
return PasswordStrength.WEEK;
}
}
길이만 측정하는 테스트 코드를 작성하였습니다. 요구사항에 맞추어 비밀번호가 8글자 이상일 시에 WEEK를 리턴 시켜주었습니다. 해당 테스트는 길이만 충족할 뿐 다른 2개의 조건은 충족하지 못하기에 WEEK입니다.
숫자와 대문자가 포함한 여부를 판단하는 코드는 유형이 비슷하므로 같이 설명하겠습니다.
@Test
public void meetOnlyUppercaseRule() {
assertPasswordStrength("abcABC", PasswordStrength.WEEK);
}
@Test
public void meetOnlyDigitRule() {
assertPasswordStrength("abb1122", PasswordStrength.WEEK);
}
public PasswordStrength meter(String password) {
if (password == null) {
return PasswordStrength.INVALID;
}
if (password.isEmpty()) {
return PasswordStrength.INVALID;
}
if (password.length() >= 8) {
return PasswordStrength.WEEK;
}
if (containsDigit(password)) {
return PasswordStrength.WEEK;
}
if (containsUpperCase(password)) {
return PasswordStrength.WEEK;
}
}
containsDigit 메서드와 containsUpperCase는 입력받은 password를 파라미터 값으로 받아 해당 password에 숫자와 대문자가 있는지 정규식 검사를 이용하여 true/false로 리턴 시켜주는 메서드입니다. 아래의 코드를 확인해주세요!
private boolean containsDigit(String password) {
Pattern pattern = Pattern.compile("[0-9]");
Matcher matcher = pattern.matcher(password);
return matcher.find();
}
private boolean containsUpperCase(String password) {
Pattern pattern = Pattern.compile("[A-Z]");
Matcher matcher = pattern.matcher(password);
return matcher.find();
}
이런 식으로 진행하면서 테스트 코드를 구현하는 것이 앞서 언급드린 삼각 측량법입니다. 예시에 언급한 경우의 수 말고도 2개의 조건을 만족시켰을 때의 NORMAL 리턴, 모두 만족시켰을 때의 STRONG 리턴 등 많은 경우의 수가 있습니다. 해당 예시를 코드 한줄한줄 다 설명드릴 수가 없어서 전체 코드를 올리겠습니다.
최대한 이해하기 쉽게 변수명과 가독성을 높였습니다. 혹여나 이해가 안 되는 부분은 댓글로 남겨주시면 친절히 대답해 드릴게요 :)
1. PasswordStrength
package tdd.pwdMeter;
public enum PasswordStrength {
STRONG, NORMAL, WEEK, INVALID
}
2. PasswordMeter
package tdd.pwdMeter;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class PasswordMeter {
public PasswordStrength meter(String password) {
if (password == null) {
return PasswordStrength.INVALID;
}
if (password.isEmpty()) {
return PasswordStrength.INVALID;
}
int metCount = 0;
if (containsDigit(password)) metCount++;
if (containsUpperCase(password)) metCount++;
if (password.length() >= 8) metCount++;
if(metCount <= 1){
return PasswordStrength.WEEK;
}
if(metCount == 2){
return PasswordStrength.NORMAL;
}
return PasswordStrength.STRONG;
}
private boolean containsDigit(String password) {
Pattern pattern = Pattern.compile("[0-9]");
Matcher matcher = pattern.matcher(password);
return matcher.find();
}
private boolean containsUpperCase(String password) {
Pattern pattern = Pattern.compile("[A-Z]");
Matcher matcher = pattern.matcher(password);
return matcher.find();
}
}
3. PasswordMeterTest
package tdd.pwdMeter;
import org.junit.Test;
import static org.assertj.core.api.Assertions.assertThat;
public class PasswordMeterTest {
private void assertPasswordStrength(String password, PasswordStrength expected) {
PasswordMeter meter = new PasswordMeter();
PasswordStrength result = meter.meter(password);
assertThat(result).isEqualTo(expected);
}
@Test
public void nullInput() {
assertPasswordStrength(null, PasswordStrength.INVALID);
}
@Test
public void emptyInput() {
assertPasswordStrength("", PasswordStrength.INVALID);
}
@Test
public void meetAllRules() {
assertPasswordStrength("abcdABCD1234", PasswordStrength.STRONG);
assertPasswordStrength("123abcdABCD", PasswordStrength.STRONG);
assertPasswordStrength("ABCD1234abc", PasswordStrength.STRONG);
}
@Test
public void meet2RulesExceptForLengthRule() {
assertPasswordStrength("abc12AB", PasswordStrength.NORMAL);
assertPasswordStrength("12ABabc", PasswordStrength.NORMAL);
}
@Test
public void meet2RulesExceptForDigitRule() {
assertPasswordStrength("abcdABabc", PasswordStrength.NORMAL);
}
@Test
public void meet2RulesExceptForUppercaseRule() {
assertPasswordStrength("abcde1234", PasswordStrength.NORMAL);
}
@Test
public void meetOnlyLengthRule() {
assertPasswordStrength("abcdefgjwof", PasswordStrength.WEEK);
}
@Test
public void meetOnlyUppercaseRule() {
assertPasswordStrength("abcABC", PasswordStrength.WEEK);
}
@Test
public void noRules() {
assertPasswordStrength("abcwef", PasswordStrength.WEEK);
}
}
👨👩👦👦 오픈채팅방 운영
취업을 준비하는 예비 개발자분들을 위한 질문&답변할 수 있는 공간을 만들었습니다. 취업과 이직을 하기 위해서 어떤 걸 중점적으로 준비해야 하는지부터 포트폴리오&이력서 작성법 등 다양한 질문들을 받고 답변을 드립니다. 참여하셔서 다양한 정보 얻고 가시면 좋을 것 같네요😁
참여코드 : 456456
https://open.kakao.com/o/gVHZP8dg
👨💻 전자책 출간
아울러 제가 🌟비전공자에서 2년만에 보안 전문 중견기업으로 이직 한 방법들을 정리한 전자책을 출간 하게 되었습니다. 어떤 걸 공부해야 하는지, 이직을 위해서 무엇을 준비해야 하는지, 제가 받았던 기술 면접 리스트 등 다양한 목차로 구성되어 있습니다. 또한, 구매 시 1:1 채팅을 이용하여 포트폴리오 첨삭을 도와드리고 있습니다. 🐕전자책으로 얻은 모든 수익은 유기견 센터 '팅*벨 입양센터'에 후원될 예정입니다. 관심 있으신 분들은 아래 링크를 참고해주세요😁
마치며
지금까지 TDD에 알아보았습니다. 처음 TDD를 접했을 때, 개발 방식을 많이 바꿔야 한다는 점에서 부담이 되었는데 알면 알수록 무조건 내 거라고 만들어야 할 습관이라고 생각이 들게 되었습니다. 저도 TDD를 잘 구사하지 못하지만, 지속적으로 연습해서 유지보수 좋은 프로젝트들을 만들어 나가고 싶네요 :)
관련 포스팅
'TIL' 카테고리의 다른 글
PostgreSQL JSON 데이터 활용 (0) | 2023.05.30 |
---|---|
AOP(Aspect-Oriented Programming) 파헤치기 (0) | 2022.06.22 |
Java MVC 패턴 바로 알기 (1) | 2022.05.20 |
Java Enum 클래스 (0) | 2022.05.19 |
Java 추상 클래스와 인터페이스 구분 (0) | 2022.05.18 |