본문 바로가기
[개발] Test

과연 TDD를 제대로 알고 있는 걸까?

by Devsong26 2022. 3. 27.

면접을 보고 나서 제가 사용한다고 말하고 다닌 TDD는 TDD가 아니라는 걸 알게 됐습니다.

구현한 메서드가 어떻게 실행되는지만 확인하는 테스트 메서드일뿐이었습니다.

강의를 듣고 수준이 올라오면 책을 통해 학습하는 게 좋을 것이란 조언을 듣고, 

패스트 캠퍼스에서 'TheRED:백발의개발자를꿈꾸며:코드리뷰,레거시와 TDDby백명석,최범균' 강좌를 수강 후 

TDD에 대해 공부하였고 해당 내용을 정리해 보고자 합니다.

 


 

 

TDD란?

테스트 주도 개발(Test Driven Development)는 만들고자 하는 기능의 내용을 담고 있으면서 만들어진 코드를 검증도 해줄 수 있도록 테스트 코드를 먼저 만들고, 테스트를 성공하게 해주는 코드를 작성하는 방식의 개발 방법입니다.

 

테스트 코드 구조

  • 기능과 상황
  • 주어진 상황에 따라 실행 결과가 달라짐
    • 예: 승인 API
      • 회원이 없는 경우와 회원이 존재하는 경우 같은 승인 요청도 응답이 다름
    • 예: 병원 예약
      • 평일인 경우와 휴일인 경우 응답이 다름
      • 예약 인원을 초과한 경우와 초과하지 않은 경우 응답이 다름

 

테스트 구조

  • 테스트는 상황 - 실행 - 결과 검증으로 구성
  • 상황: given
  • 실행: when
  • 결과 검증: then

 

외부 상황과 외부 결과

  • 테스트과 외부 상황에 의존하는 경우
    • 예: 파일, DB, REST API, 소켓 서버, ...
  • 상황을 만들기 어려울 수 있음
  • 결과를 확인하기 어려울 수 있음
  • 다음 테스트에 영향을 줄 수 있음

 

테스트 대역(Double)

  • 테스트에서 실제 구현대신 실행할 대체 구현
  • 예: 계좌번호 검증기
    • 실제 구현은 PG가 제공하는 API를 호출하는 계좌 번호 검증
    • 대체 구현은 PG와 통신하지 않고 지정한 값을 바로 제공(원하는 행동)

 

테스트 대상은 실제 구현 대신 대역 사용

  • 대역을 이용해서 테스트에 필요한 상황/결과를 구성

 

대역 종류

  • 스텁(stub): 구현을 최대한 단순한 것으로 대체
  • 가짜(fake): 기능을 구현해서 진짜와 유사하게 동작(경량 버전)
  • 스파이(spy): 호출한 내용을 기록
  • 모의(mock)객체: 기대한대로 상호작용하는지 행위를 검증
    • 보통 모의 객체는 스텁과 스파이 가능

 

대역 이점

  • 의존 대상의 진짜 구현없이 테스트 가능
    • 현재 구현 대상에 집중, 병행 개발 가능
    • 의존 대상에 대한 상황 지정을 가능하게 함
    • 의존 대상에 대한 결과를 확인할 수 있게 함
  • 개발 속도 향상
    • 서버 구동 없이 상당한 기능 검증 가능
    • 외부 시스템 연동없이 주요 로직 검증 가능

 

TDD를 시작할 때 어려운 점

  • 업무에 적용
    • 돈 받고 하는 일에 연습없이 TDD 적용하면 안되며 철저한 연습이 필요
  • 본인이 망치지 않고 할 수 있는 범위부터 TDD 적용
    • 처음부터 모든 걸 TDD로 하겠다는 생각하지 말고 계산 로직같은 단순한 것부터 TDD 시작
    • TDD가 익숙해지면 TDD 적용 범위를 넓힐 것

 

테스트 작성 순서

  • 당장 빠르게 구현할 수 있는 것부터 고민
    • 암호 강도 예: 모든 조건 충족 vs 2개 조건 충족(길이 & 대문자, 길이 & 소문자, 길이 & 숫자, ...)
  • 예외적인 경우를 정상적인 경우보다 우선 고려해야 함
    • 예외적인 경우를 나중에 하게 될 경우 코드 구조가 복잡해질 수 있음
    • 암호강도 검사 예: 입력값이 null인 경우 vs 2개 조건 충족 
    • 회원 가입 예: 같은 ID 회원이 있는 경우 vs 같은 ID 회원이 없는 경우
    • 주문 취소 예: 주문이 이미 취소된 경우 vs 주문을 취소할 수 있는 경우

 

완급 조절

  • 매우 작은 단계씩 점진적으로 진행
    • 상수 리턴
    • 값 비교
    • 구현 일반화
  • 몇 단계를 한 번에 진행하여 구현이 막히는 경우에는 뒤로 돌아와서 천천히 진행

 

대역 도출 시점

  • 상황에서 도출
  • 결과 확인 과정에서 도출
  • 기능 구현 과정에서 도출

 

TDD 효과

  • 테스트 코드가 쌓이면 디버깅 시간 감소
    • 테스트 코드가 있으면 문제 범위를 좁혀서 디버깅하기 쉬움
  • 테스트 코드가 쌓이면 코드 변경에 따른 영향 범위 확인 가능
    • 코드를 수정했는데 실패하는 테스트가 발생하면 문제를 빨리 알 수 있음(회귀 테스트)
  • 코드 구조/설계가 좋아질 가능성이 높아짐
    • 테스트가 가능하려면 의존 대상을 대역으로 교체할 수 있어야 함
    • 대역으로 교체할 수 있는 구조는 그 만큼 역할별로 분리되어 있을 가능성이 높음

 

테스트와 관련된 내용

  • 픽스처(Fixture): 테스트를 수행하는 데 필요한 정보나 오브젝트이며 일방적으로 픽스처는 여러 테스트에서 반복적으로 사용되기 때문에 @Before 메서드를 이용해 생성해 두면 편리하다. (ex: MockMvc, Repository, ...)
  • @DirtiesContext: 스프링의 테스트 컨텍스트 프레임워크에게 해당 클래스의 테스트에서 애플리케이션 컨텍스트의 상태를 변경한다는 것을 알려주는 애노테이션이며 테스트 컨텍스트는 이 애노테이션이 붙은 테스트 클래스에는 애플리케이션 컨텍스트 공유를 허용하지 않는다.
  • 학습테스트(Learning Test): 자신이 만들지 않은 프레임워크나 제 3자가 만들어서 제공한 라이브러리 등에 대해서 작성하는 테스트
    • 장점 1. 다양한 조건에 따른 기능을 손쉽게 확인해 볼 수 있다.
    • 장점 2. 학습 테스트 코드를 개발 중에 참고할 수 있다.
    • 장점 3. 프레임워크나 제품을 업그레이드할 때 호환성 검증을 도와준다.
    • 장점 4. 테스트 작성에 대한 좋은 훈련이 된다.
    • 장점 5. 새로운 기술을 공부하는 과정이 즐거워진다.
  • 버그테스트(Bug test): 코드에 오류가 있을 때 그 오류를 가장 잘 드러내줄 수 있는 테스트이며 일단 실패하도록 만드러야 한다. 버그가 원인이 되서 테스트가 실패하는 코드를 만드는 것이다.
    • 장점 1. 테스트의 완성도를 높여준다.
    • 장점 2. 버그의 내용을 명확하게 분석하게 해준다.
    • 장점 3. 기술적인 문제를 해결하는 데 도움이 된다.
  • 동등분할(Equivalence partitioning): 같은 결과를 내는 값의 범위를 구분해서 각 대표 값으로 테스트를 하는 방법
  • 경계값 분석(Boundary value analysis): 에러는 동등분할 범위의 경계에서 주로 많이 발생한다는 특징을 이용해서 경계의 근처에 있는 값을 이용해 테스트하는 방법
  • 테스트 대역(Test double): 테스트 환경을 만들어주기 위해서 테스트 대상이 되는 오브젝트의 기능에만 충실하게 수행하여, 빠르게 자주 테스트를 실행할 수 있도록 사용하는 오브젝트
  • 테스트 스텁(Test stub): 테스트 대상 오브젝트의 의존객체로서 존재하면서 테스트 동안에 코드가 정상적으로 수행할 수 있도록 돕는 것
  • 협력 오브젝트(Collaborator): 작은 기능이라도 다른 오브젝트의 기능을 사용하면, 사용하는 오브젝트의 기능이 바뀌었을 때 자신이 영향을 받을 수 있기 때문에 의존한다고 말하는 것이다. 의존 오브젝트를 협력 오브젝트라고도
    한다.
  • 목 오브젝트(Mock object): 테스트 대상의 간접적인 출력 결과를 검증하고, 테스트 대상 오브젝트와 의존 오브젝트 사이에서 일어나는 일을 검증할 수 있도록 특별히 설계된 오브젝트

 


 

예제 코드를 보면서 테스트 주도 개발을 어떻게 진행하였는지 살펴보겠습니다.

 

암호검사기 조건

  • 사용하는 규칙
    • 길이가 8글자 이상
    • 0~9까지 숫자 포함
    • 대문자 포함
  • 세 규칙을 모두 충족하면 패스워드 강도 강함
  • 두 규칙을 충족하면 패스워드 강도 보통
  • 1개 이하의 규칙을 충족하면 패스워드 강도 약함

 

PasswordMeterTest.java

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

public class PasswordMeterTest {
    @Test
    void nullInput(){
        assertPasswordStrength(null,
                PasswordStrength.INVALID);
    }

    private void assertPasswordStrength(String password, PasswordStrength expected) {
        PasswordMeter meter = new PasswordMeter();
        PasswordStrength result = meter.meter(password);
        assertThat(result).isEqualTo(expected);
    }

    @Test
    void emptyInput(){
        assertPasswordStrength("", PasswordStrength.INVALID);
    }

    @Test
    void meetAllRules() {
        assertPasswordStrength(
                "abcdABCD123",
                PasswordStrength.STRONG);
        assertPasswordStrength(
                "123abcdABCD",
                PasswordStrength.STRONG);
        assertPasswordStrength(
                "ABCD1234abc",
                PasswordStrength.STRONG);
    }

    @Test
    void meet2RulesExceptForLengthRule() {
        assertPasswordStrength(
                "abc12AB",
                PasswordStrength.NORMAL);
        assertPasswordStrength(
                "12ABabc",
                PasswordStrength.NORMAL);
    }

    @Test
    void meet2RulesExceptForDigitRule() {
        assertPasswordStrength(
                "abcdABabc",
                PasswordStrength.NORMAL);
    }

    @Test
    void meet2RulesExceptForUppercaseRule(){
        assertPasswordStrength(
                "abcde1234",
                PasswordStrength.NORMAL);
    }

    @Test
    void meetOnlyLengthRule() {
        assertPasswordStrength(
                "abcdefghiehd",
                PasswordStrength.WEAK);
    }

    @Test
    void meetOnlyDigitRule() {
        assertPasswordStrength(
                "abc123",
                PasswordStrength.WEAK);
    }

    @Test
    void meetOnlyUppercaseRule() {
        assertPasswordStrength(
                "abcABC",
                PasswordStrength.WEAK);
    }

    @Test
    void noRules() {
        assertPasswordStrength(
                "abcwef",
                PasswordStrength.WEAK);
    }
}

 

PasswordMeter.java

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.WEAK;
        if(metCount == 2)
            return PasswordStrength.NORMAL;

        return PasswordStrength.STRONG;
    }

    private boolean containsUppercase(String password) {
        Pattern pattern = Pattern.compile("[A-Z]");
        Matcher matcher = pattern.matcher(password);
        return matcher.find();
    }

    private boolean containsDigit(String password) {
        Pattern pattern = Pattern.compile("[0-9]");
        Matcher matcher = pattern.matcher(password);
        return matcher.find();
    }
}

 

1. 예외 상황에 대한 테스트 케이스 작성

  • null, 빈 문자열을 입력 -> 컴파일 에러 해결 ->테스트 실행 -> 테스트 실패(구현부가 없음) -> 테스트가 성공하도록 PasswordStrength.INVALID가 반환되도록 구현 부 작성 -> 테스트 실행 -> 테스트 성공

 

2. 모든 조건을 만족하는 meetAllRules() 케이스 작성

  • meetAllRules 조건 작성 -> 컴파일 에러 해결 -> 테스트 실행 -> 테스트 실패(반환 값이 다름) -> 테스트가 성공하도록 1번의 경우가 아닌 경우 PasswordStrength.STRONG을 반환 -> 테스트 실행 -> 테스트 성공

 

3. 중복되는 테스트 메서드의 로직을 메서드 추출을 통해 중복 제거

  • assertPasswordStrength 메서드

 

4. 2개의 조건만을 만족하는 테스트 케이스를 작성(meet2Rules~)

  • meet2Rules~ 조건 작성 -> 컴파일 에러 해결 -> 테스트 실행 -> 실패(반환 값이 다름) -> 테스트가 성공하도록 1, 2번의 아닌 경우 PasswordStrength.NORMAL을 반환 -> 테스트 실행 -> 테스트 성공

 

5. 1개의 조건만을 만족하는 테스트 케이스를 작성(meetOnly~)

  • meetOnly~ 조건 작성 -> 컴파일 에러 해결 -> 테스트 실행 -> 실패(반환 값이 다름) -> 테스트가 성공하도록 1, 2, 4번이 아닌 경우 PasswordStrength.WEAK를 반환 -> 테스트 실행 -> 테스트 성공

6. 구현부에 한번만 사용되는 변수는 삭제하고 반환값이 중복되는 경우는 조건문이 있을 경우 합침(리팩토링)

 

7. 조건을 한개도 만족하지 않는 테스트 케이스를 작성

  • noRules 조건 작성 -> 컴파일 에러 해결 -> 테스트 실행 -> 테스트 실패(반환 값이 다름) -> 조건을 하나만 만족한 경우와 0개가 만족한 경우는 반환 값이 같으므로 if(metCount <= 1)으로 조건문을 만들어 PasswordStrength.WEAK를 반환 -> 테스트 실행 -> 테스트 성공

 


 

후기

메서드를 구현하고 Intellij의 Test Method 생성 기능을 통해 기능의 동작 여부만을 확인하는 것을 TDD로 착각했던 제

자신이 부끄럽게 느껴졌습니다. 상황에 맞춰 테스트 케이스를 먼저 작성하고 구현을 해나가면서 네거티브 테스트, 버그 테스트를 우선적으로 처리한 다음에 정상적인 경우를 해결해 나가야 한다는 것을 깨달았습니다. 확실히 책으로만 공부하는 것은 한계가 있어 보입니다. 강의료가 괜히 비싼 게 아니더군요. 강의를 통해 강사님들의 노하우를 같이 배운다는 점이 정말 좋고 질문도 가능하다는 게 큰 장점인 것 같습니다. 테스트를 우선적으로 작성하며 개발하는 것이 몸에 배려면 시간이 조금 걸리겠지만 유지 보수 측면이나 개발 시에도 조건에 상황을 빠짐없이 확인할 수 있어 매력적인 개발론인 것 같습니다. 위의 예제는 강의의 단편일 뿐 API와 Service를 테스트하는 강의도 있으므로 관심이 있으신 분들은 수강하셔도 좋을 것 같습니다.

 

 


 

참고

패스트 캠퍼스 강좌: 'TheRED:백발의개발자를꿈꾸며:코드리뷰,레거시와 TDDby백명석,최범균'

도서: 토비의 스프링 3.1

 

 

 


 

같이 볼만한 포스팅

https://developer-syubrofo.tistory.com/45

 

TDD(Test Driven Development)

TDD는 Test Driven Development의 약자로써, 테스트 주도 개발을 의미이며 XP(eXtreme Programming)라는 애자일 기반의 개발 방법론이다. 전통적인 개발 프로세스의 경우 설계-> 코드 작성-> 테스트의 순서를 가

developer-syubrofo.tistory.com

 

'[개발] Test' 카테고리의 다른 글

험블 객체 패턴  (0) 2023.12.15
JUnit Test code setup  (0) 2023.11.13
Service, Controller 테스트  (0) 2018.08.05
JUnit  (0) 2018.01.27
TDD(Test Driven Development)  (0) 2018.01.23