TDD. 테스트 주도 개발이 무엇인지 알자

이채은·2023년 10월 4일
0
post-thumbnail

TDD란?

  • Test Driven Development의 약자. 우리말로 테스트 주도 개발

  • 반복 테스트를 이용한 소프트웨어 방법론으로 작은 단위의 테스트 케이스를 작성하고 이를 통과하는 코드를 추가하는 단계를 반복하여 구현한다.

  • 이 기법을 개발했거나 ‘재발견’한 것으로 인정되는 켄트 벡(Kent Beck)은 TDD가 단순한 설계를 장려하고 자신감을 불어넣어 준다고 말하였다.

  • 최근에는 테스트 코드가 동작을 테스트하기 위해 사용될 뿐 아니라,
    jenkins 등의 ci 도구를 사용할 때 테스트 코드의 성공 여부를 확인해 전부 성공한 경우에만 pr을 통과시키거나 운영브랜치로 merge시키는 등의 추가 동작을 하는데도 많이 사용된다고 한다.

TDD 순서

1단계 🔴) 기능을 검증하는 테스트 코드 작성
2단계 🟢) 테스트를 통과하는 기능 개발
3단계 🔵) 작성한 코드 리팩토링

TDD는 기본적으로 위 3단계의 반복으로 진행하며 점진적으로 코드를 개선해나가며 개발이 진행된다.

왜 1단계가 Write a failing test (실패한 테스트 작성)일까?

기능을 개발하기 전, 테스트 코드를 우선으로 작성하기 때문에 당연히 그 테스트는 실패하는 테스트일 것이다.
2단계에서 이 실패하는 테스트를 성공시키기 위한 기능 개발을 하면 된다.


간단한 TDD 예제

숫자로 구성된 문자열을 입력받아 기본적인 더하기와 빼기 연산을 수행하는 계산기 프로그램을 만들려고 한다.
기능을 개발하기 전, 기능을 검증할 수 있는 테스트 코드를 작성해 본다.

import static org.assertj.core.api.Assertions.*;
import org.junit.jupiter.api.Test;

public class StringCalculatorTest {

    @Test
    public void testAdd() {
        StringCalculator calculator = new StringCalculator();
        assertThat(calculator.add("")).isEqualTo(0);
        assertThat(calculator.add("1,2")).isEqualTo(3);
        assertThat(calculator.add("2,3")).isEqualTo(5);
        assertThat(calculator.add("4,5")).isEqualTo(9);
    }

    @Test
    public void testSubtract() {
        StringCalculator calculator = new StringCalculator();
        assertThat(calculator.subtract("")).isEqualTo(0);
        assertThat(calculator.subtract("3,2")).isEqualTo(1);
        assertThat(calculator.subtract("4,3")).isEqualTo(1);
        assertThat(calculator.subtract("5,3")).isEqualTo(2);
    }
}

테스트 코드를 작성하면 아래와 같이 코드가 빨갛게 물들 것이다.

Why? 기능 개발을 하기 전이기 때문이다.
이 상태로 테스트를 실행시키면

아주 친절하게 StringCalculator가 없다고 알려준다.
이게 바로 위에서 설명했던 1단계 '실패한 테스트 작성'이다.
그럼 이제 2단계로 넘어가서 '테스트를 통과하는 기능 개발'을 하면 된다.
우리는 StringCalculator 클래스를 만들어주면 되는 것이다.

public class StringCalculator {

    public int add(String input) {
        // 아무것도 입력하지 않았을 때
        if (input.isEmpty()) {
            return 0;
        }

        String[] numbers = input.split(",");
        int sum = 0;
        for (String number : numbers) {
            sum += Integer.parseInt(number);
        }
        return sum;
    }

    public int subtract(String input) {
        // 아무것도 입력하지 않았을 때
        if (input.isEmpty()) {
            return 0;
        }

        String[] numbers = input.split(",");
        int result = Integer.parseInt(numbers[0]);
        for (int i = 1; i < numbers.length; i++) {
            result -= Integer.parseInt(numbers[i]);
        }
        return result;
    }
}

StringCalculator를 만들고 작성했던 테스트 코드에 다시 돌아와보자.

무진장 붉었던 테스트 코드들이 정상으로 돌아왔고 테스트를 실행시켜보면

초록초록하게 테스트가 성공하는 것을 볼 수 있다.
아, 여기서 DIsplayName()은 위처럼 테스트명을 지정해 줄 수 있는 어노테이션이다.

테스트가 성공했으니 이제 더 좋게 코드를 리팩토링해주면 된다.
입력값으로 null이 입력된 경우를 추가하거나 for문을 없애거나 하면 더 효율 높은 코드가 될 것이다.
이렇게 코드를 리팩토링하고 테스트하는 과정을 반복하여 보다 효율적이고 깔끔한 코드를 작성하는 것이 TDD의 목적이다.

TDD 예제 : 암호 검사기

  • 규칙 세 가지
    • 길이가 8글자 이상
    • 0부터 9 사이의 숫자를 포함
    • 대문자 포함
  • 세 규칙을 모두 충족하면 암호는 강함이다.
  • 2개의 규칙을 충족하면 암호는 보통이다.
  • 1개 이하의 규칙을 충족하면 암호는 약함이다.

책에서는 junit의 assertEquals를 사용했지만 나는 assertj의 assertThat을 사용해서 만들어보겠다.


첫 번째 테스트 : 모든 규칙을 충족하는 경우

#가장 쉽거나 가장 예외적인 상황을 선택

모든 조건을 충족하지 않는 경우 ← 는 한번에 만들어야 할 코드가 많기 때문에 모든 규칙을 충족하는 경우를 택함.

  • 테스트 작성
import org.junit.jupiter.api.Test;

// assertj의 Assertions 임포트
import static org.assertj.core.api.Assertions.*;

public class PasswordStrengthMeterTest {
@Test
    void meetsAllCriteria_Then_Strong() {
        PasswordStrengthMeter meter = new PasswordStrengthMeter();

        PasswordStrength result1 = meter.meter("ab12!@AB");
				// result1의 값이 PasswordStrength.STRONG와 동일한지 검증하는 코드
        **assertThat(PasswordStrength.STRONG).isEqualTo(result1);**

        PasswordStrength result2 = meter.meter("abc1!Add");
       **/assertThat(PasswordStrength.STRONG).isEqualTo(result2);**
}
  • 기능 작성
public calss PasswordStrengthMeter {
	public PasswordStrength meter(String s) {
		return PasswordStrength.STRONG;
	}
}

💡 기능을 작성할 때는 단순히 테스트가 통과하도록 기능을 최소한으로 구현한다!


두 번째 테스트 : 길이만 8글자 미만이고 나머지 조건은 충족하는 경우

  • 테스트 작성
@Test
void meetsAllCriteria_Then_Strong() {
	PasswordStrengthMeter meter = new PasswordStrengthMeter();
	PasswordStrength result = meter.meter("ab12!@A");
	**assertThat(PasswordStrength.NOMAL).isEqualTo(result);**
  PasswordStrength result2 = meter.meter("abc1!Add");
  **assertThat(PasswordStrength.STRONG).isEqualTo(result2);**
}
  • 기능 작성
**if(s.length() < 8) {
	return PasswordStrength.NORMAL;
}**

세 번째 테스트 : 숫자를 포함하지 않고 나머지 조건은 충족하는 경우

  • 테스트 작성
@Test
void meetsOtherCriteria_except_for_Length_Then_Normal() {
    PasswordStrengthMeter meter = new PasswordStrengthMeter();
    PasswordStrength result = meter.meter("ab12!@A");
    assertThat(PasswordStrength.NOMAL).isEqualTo(result);
}
  • 기능 작성
// 암호가 숫자를 포함했는지 판단해서 포함하지 않은 경우 NORMAL을 리턴한다.
**boolean containsNum = false;
	for(char c : s.toCharArray()) {
	  if(c >= '0' && c <= '9') {
	    containsNum = true;
			break;
    }
  }
if(!containsNum) return PasswordStrength.NORMAL;**

  • 코드의 가독성을 개선하기 위해 리팩토링 진행!
    public calss 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 c : s.toCharArray()) {
            if(c >= '0' && c <= '9') {
                return true;
            }
        }
        return false;
      }**
    }

코드 정리 : 테스트 코드 정리

#코드의 중복 제거

→ PasswordStrengthMeter 객체 생성을 필드에서!

public class PasswordStrengthMeterTest {
	private PasswordStrengthMeter meter = new PasswordStrengthMeter();
  
	private void assertStrength(String password, PasswordStrength expStr) {
      PasswordStrength result = meter.meter(password);
      assertThat(expStr).isEqualTo(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.NOMAL);
  }

  @Test
  void meetsOtherCriteria_except_for_number_Then_Normal() {
      assertStrength("abc!@ABC", PasswordStrength.NOMAL);
  }
}

✅ 테스트 코드를 수정 후 실패하는 곳이 없는지 확인 후 다음 테스트 진행


네 번째 테스트 : 값이 없는 경우

#값이 없을 때는 NullPointerException 이 발생한다.

→ 유효하지 않는 암호가 입력되면 PasswordStrength.INVALID를 리턴하도록 설계하자.

  • 테스트 작성
@Test
void nullInput_Then_Invalid() {
	assertStrength(null, PasswordStrength.INVALID);
}
  • 기능 작성
// 암호가 null이라면 INVALID를 리턴한다.
**if(s == null) return PasswordStrength.INVALID;**

→ 암호가 빈 문자열일 때의 예외상황도 고려해보자!

  • 테스트 작성
@Test
void emptyInput_Then_Invalid() {
  assertStrength("", PasswordStrength.INVALID);
}
  • 기능 작성
// 암호가 빈 문자열이라면 INVALID를 리턴한다.
if(s == null **|| s.isEmpty()**) return PasswordStrength.INVALID;

다섯 번째 테스트 : 대문자를 포함하지 않고 나머지 조건을 충족하는 경우

  • 테스트 작성
@Test
void emptyInput_Then_Invalid() {
  assertStrength("", PasswordStrength.INVALID);
}
  • 기능 작성
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) PasswordStrength.NORMAL;**

→ 코드의 가독성을 개선하기 위해 리팩토링 진행!

// 대문자 포함 여부 확인 코드를 메서드로 분리한다.
**private boolean meetsContainingUppercaseCriteria(String s) {
  for(char c : s.toCharArray()) {
    if(Character.isUpperCase(c)) {
	    return true;
    }
  }
  return false;
}**

여섯 번째 테스트 : 길이가 8글자 이상인 조건만 충족하는 경우

  • 테스트 작성
@Test
void 길이가_8글자_이상인_조건만_충족() {
  assertStrength("abcdefghi", PasswordStrength.WEAK);
}

→ 테스트 이름은 한글로 작성해도 된다!

  • 기능 작성
// 이전 코드 삭제
**~~if(s.length() < 8) {
	return PasswordStrength.NORMAL;
}~~**

// 암호의 길이가 8 이상인지 검증한다.
**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;

****

일곱 번째 테스트 : 숫자 포함 조건만 충족하는 경우

  • 테스트 작성
@Test
void 숫자_포함_조건만_충족() {
  assertStrength("12345", PasswordStrength.WEAK);
}
  • 기능 작성
**if(!lengthEnough && containsNum && !containsUpp) {
	return PasswordStrength.WEAK;
}**

여덟 번째 테스트 : 대문자 포함 조건만 충족하는 경우

  • 테스트 작성
@Test
void 대문자_포함_조건만_충족() {
  assertStrength("ABCEF", PasswordStrength.WEAK);
}
  • 기능 작성
**if(!lengthEnough && !containsNum && containsUpp) {
	return PasswordStrength.WEAK;
}**

  • 지금까지 작성한 코드 리팩토링 진행
    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.NOMAL;
            
            return PasswordStrength.STRONG;
        }
    
    		// 숫자 포함 여부 검증 메서드
        private boolean meetsContainingNumberCriteria(String s) {
            for(char c : s.toCharArray()) {
                if(c >= '0' && c <= '9') {
                    return true;
                }
            }
            return false;
        }
    		// 대문자 포함 여부 검증 메서드
        private boolean meetsContainingUppercaseCriteria(String s) {
            for(char c : s.toCharArray()) {
                if(Character.isUpperCase(c)) {
                    return true;
                }
            }
            return false;
        }
    }

아홉 번째 테스트 : 아무 조건도 충족하지 않은 경우

#모든 상황을 다 테스트 했으니, 마지막으로 아무 조건도 충족하지 않은 경우를 검증한다.

  • 테스트 작성
@Test
void 아무조건도_충족하지_않음() {
  assertStrength("abc", PasswordStrength.WEAK);
}
  • 기능 작성
// 비교 조건 수정
~~if(metCounts == 1) return PasswordStrength.WEAK;~~
if(metCounts <= 1) return PasswordStrength.WEAK;

✅ 암호 검사기 프로그램의 모든 기능이 완성되었다~! 얏호


  • 마지막으로 코드의 가독성을 조금 더 높여보자 (리팩토링)
    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.NOMAL;
            
            return PasswordStrength.STRONG;
        }
    
    		// metCounts를 계산하는 메서드
    		**private int getMetCriteriaCounts(String s) {
            int metCounts = 0;
            if(s.length() >= 8) metCounts++;
            if(meetsContainingNumberCriteria(s)) metCounts++;
            if(meetsContainingUppercaseCriteria(s)) metCounts++;
            return metCounts;
        }**
    
    		// 숫자 포함 여부 검증 메서드
        private boolean meetsContainingNumberCriteria(String s) {
            for(char c : s.toCharArray()) {
                if(c >= '0' && c <= '9') {
                    return true;
                }
            }
            return false;
        }
    		// 대문자 포함 여부 검증 메서드
        private boolean meetsContainingUppercaseCriteria(String s) {
            for(char c : s.toCharArray()) {
                if(Character.isUpperCase(c)) {
                    return true;
                }
            }
            return false;
        }
    }


TDD의 장점

1. 기능 단위로 테스트하기 때문에 문제의 원인을 알기 쉽다.

보통 개발을 다 해놓고 나서 테스트를 하는 ATDD(인수테스트 주도 개발)를 하는데, 테스트 중 혹여나 문제를 발견한다면 정확하게 문제의 원인이 무엇인지 진단하기 힘들다.

하지만 TDD를 사용하면 기능 단위로 테스트하기 때문에 문제의 원인을 알기 쉽다.

2. 변화에 대한 두려움 해소

테스트 코드를 먼저 작성하고 기능 개발을 하기 때문에 개발한 기능이 잘 동작할지에 대한 불안감을 없앨 수 있다.

3. 프로그래머의 오버 엔지니어링을 방지한다.

개발을 하다 보면 간혹 계획하지 않았던 코드를 추가하여 오버 엔지니어링하는 경우가 있다. 하지만 TDD의 원칙 중 하나는, 테스트를 통과하기 위한 최소한의 코드만 작성 및 개선해야 한다는 것이다. 기능 단위로 테스트를 진행하기 때문에, 문제가 발견되지 않은 코드에 영향을 줄 수 있는 오버 코딩은 하지 않는다.

TDD의 단점

TDD를 익히는 데 많은 시간이 걸린다.

TDD는 마치 운동과 같다. 
운동을 꾸준히 하면 건강해지고 체력이 좋아지는 것처럼 
TDD도 꾸준히 연습하고 적용해야 실력이 늘고 효과를 볼 수 있다.
-저자 최범균 [테스트 주도 개발 시작하기] 255p

TDD를 효과적으로 사용하기까지는 많은 시간이 필요하다고 한다.

생산성 저하

TDD에 대한 프로그래머들의 의견은 늘 엇갈리는데, 그 이유 중 가장 큰 것이 이 생산성 저하 문제 때문일 것이다.
TDD를 지키면서 개발하려면 처음부터 2개의 코드를 짜야 하고, 중간중간 테스트를 하면서 고쳐나가야 한다. -> 개발 속도가 느려지기 때문에 생산성이 저하된다.


이 글은 최범균 저 '테스트 주도 개발 시작하기' 를 읽고 작성한 글입니다.

참고
TDD란? 테스트주도개발에 대한 편견과 실상, 방법론
테스트 주도 개발
[개발상식] tdd란 (tdd 예제, tdd하는법)

이 글을 읽는 모두, 좋은 하루 되세요. 🙂

0개의 댓글