/tddStudy

"테스트 주도 개발 시작하기" 책을 읽으며 실습한 레포지토리

Primary LanguageJava

테스트 주도 개발 시작하기

2장. TDD 시작

암호 검사기 (TDD 실습)

  • 문자열을 검사해서 규칙을 준수하는지에 따라 암호를 약함, 보통, 강함으로 구분
  • 검사할 규칙은 다음 세 가지
    • 길이가 8글자 이상
    • 0부터 9 사이의 숫자를 포함
    • 대문자 포함
  • 세 규칙을 모두 충족하면 암호는 강함
  • 2개의 규칙을 충족하면 암호는 보통
  • 1개 이하의 규칙을 충족하면 암호는 약함

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

  • 첫 번째 테스트를 선택할 때에는 가장 쉽거나 가장 예외적인 상황을 선택해야한다.

테스트 코드 정리

  • 테스트 코드도 코드이기 때문에 유지보수 대상이다.

  • 즉, 테스트 메서드에서 발생하는 중복을 알맞게 제거하거나 의미가 잘 드러나게 코드를 수정할 필요가 있다.

  • 테스트 코드의 중복을 무턱대고 제거하면 안 된다. 중복을 제거한 뒤에도 테스트 코드의 가독성이 떨어지지 않고 수정이 용이한 경우에만 중복을 제거해야한다.

    • 중복을 제거한 뒤에 오히려 테스트 코드 관리가 어려워진다면 제거했던 중복을 되돌려야 한다.

isBlank() vs isEmpty()

public class Main 
{
    public static void main(String[] args) 
    {
        System.out.println( "ABC".isBlank() );      //false
        System.out.println( "  ".isBlank() );       //true
 
        System.out.println( "ABC".isEmpty() );      //false
        System.out.println( "  ".isEmpty() );       //false
    }
}

(내가 실수한 부분 노트) 앞서서 생각하지 말자

다른 예외까지 처리하려고 하는데.. 지금 당장은 테스트케이스만 통과되도록 코드를 작성하자.

고민했던 건 아래 코드인데,

if(!lengthEnough){
    return PasswordStrength.NORMAL;
}

!길이 and (!숫자 또는 !대문자) 일 경우 WEAK에 대한 예외가 안돼있어서 처리해줘야 하나? 였음

하지만, 당시 작성중이었던 테스트 코드는 길이가 8글자 이상인 조건만 충족하는 경우 였으므로,

위 예외는 생각할 필요가 없음.

즉, 지금은 이 부분만 작성해주면 됨

if(lengthEnough && !containNum && !containUppercase){
	return PasswordStrength.WEAK;
}

정리

  • TDD 사이클은 Red-Green-Refactor

    • 레드는 실패하는 테스트
  • 테스트가 개발을 주도

    • 테스트를 작성하는 과정에서 구현을 생각하지 않음
      • 해당 기능이 올바르게 동작하는지 검증할 수 있는 테스트 코드를 만듬
    • 테스트를 통과시킬 만큼 기능을 구현
      • 아직 추가하지 않은 테스트를 고려해서 구현하지 않음
  • TDD 이점

    1. 온전하게 동작한다는 것을 검증해주는 테스트가 있으므로 코드 수정에 대한 심리적 불안감을 줄여준다.

    2. 지속적으로 코드 정리를 하므로, 코드 품질이 급격히 나빠지지 않게 막아주는 효과가 있음

      • 향후 유지보수 비용을 낮추는데 기여
    3. 피드백이 빠르다.

      • 새로운 코드를 추가하거나, 기존 코드를 수정하면 테스트를 돌려서 해당 코드가 올바른지 바로 확인할 수 있
        • 잘못된 코드가 배포되는 것을 방지

3장. 테스트 코드 작성 순서

3장. 정기 유료 서비스 만료일 결정 (TDD 실습)

  • 서비스를 사용하려면 매달 1만원을 선불로 납부한다.
  • 2개월 이상 요금을 납부할 수 있다.
  • 10만 원을 납부하면 서비스를 1년 제공한다.

실습 후기

  • 요구사항은 단순 3 가지 이지만, 그외 생각 해야할 예외 케이스의 도출은 연습이 많이 필요한 것 같다.

    • 책에 있는 케이스들을 따라해보았지만, 요구사항만 주고 혼자서 해보라 하면 글쎄..아직은 많이 어색하다.
  • 아주 작은 단위인 1만원 로직부터 시작해서 예외 상황 처리 해주면서 개발해가는 방식이 인상 깊었다.

  • 리팩토링을 할 때, 기존 코드를 바로 수정하는 게 아니라 새로운 코드를 추가한 뒤, 호출 방식으로 정상 동작 하는지 확인 한뒤에 인라인 처리하는 게 인상깊었다.

지속적인 리팩토링

  • 일단 동작하는 코드를 만드는 능력은 중요하다. 코드가 동작하지 않으면 아무것도 소용 없기 때문이다.
  • 하지만, 소프트웨어 생존 시간이 길어질수록 소프트웨어를 지속적으로 개선해야 한다.
    • 즉, 코드를 변경해야 한다.
    • 코드 변경이 어려우면 변화하는 여구를 제때 반영할 수 없게 되며, 이는 소프트웨어의 생존과 직결된다.
  • 따라서 코드를 잘 변경할 수 있는 능력 또한 매우 중요하다.
    • 코드를 잘 변경하려면, 변경하기 쉬운 구조를 가져야 하는데 이를 위한 것이 바로 리팩토링이다.

테스트할 목록 정리

  • TDD를 시작할 때, 테스트할 목록을 미리 정리하면 좋다.
    • 그중 구현이 쉬울 테스트가 무엇일지 상상한다.
    • 테스트 과정에서 새로운 테스트 사례를 발견하면 즉시 목록 업데이트
  • 테스트 목록을 적었다고 해서, 테스트를 한 번에 다 작성하면 안된다.
    • 한 번에 작성한 테스트 코드가 많으면 구현 초기에도 리팩토링을 마음 껏 못하게 된다.
      • 모든 테스트를 통과시키기 전까지는 계속해서 깨지는 테스트가 존재하므로, 개발리듬을 유지 하는 데 도움이 안된다.
  • 하나의 테스트 코드를 만들고 이를 통과시키고, 리랙토링하고.. 짧은 리듬을 반복
  • 개발을 진행하다 보면 변경 범위가 매우 큰 리팩토링 거리를 발견할 때도 있다.
    • TDD 흐름을 깨기 쉬우므로, 리팩토링 진행을 미뤄두고, 테스트를 통과하는데 집중한다.

시작이 안될 때는 단언부터 고민

  • 테스트 코드를 작성하다 보면 시작이 잘 안될 때가 있다.

    • 이럴 땐 검증하는 코드부터 작성

      • 예를 들어 만료일 계산 기능의 경우 만료일을 검증하는 코드부터 작성

        @Test
        void 만원_납부하면_한달_가_만료일이_됨(){
          // 처음 작성하는 코드
          assertEquals(기대하는 만료일, 실제 만료일);
        }

구현이 막히면

  • 과감하게 코드를 지우고 미련없이 다시 시작
  • 어떤 순서로 작성했는 지 되돌아보고, 순서를 바꿔 다시 진행
  • 상기할 것
    • 쉬운 테스트, 예외적인 테스트
    • 완급 조절

4장. TDD 기능 명세·설계

기능 명세

  • 기능은 크게 입력결과로 나뉜다.
  • 설계는 기능 명세로부터 시작
  • 요구사항 문서를 이용해서 기능 명세 구체화
    • 구체화하는 동안 입력결과 도출
    • 도출한 기능 명세를 코드에 반영
      • 기능의 이름, 파라미터, 리턴 타입 등 결정됨

설계 과정을 지원하는 TDD

  • 테스트 코드를 만들기 위해 필요한 것
    1. 테스트에서 실행할 수 있는 객체나 함수의 존재
    2. 실행 결과를 검증
  • 테스트 코드를 작성하는 과정에서 네 가지가 결정됨
    1. 클래스 이름
    2. 메서드 이름
    3. 메서드 파라미터
    4. 실행 결과
  • TDD 자체가 설계는 아니지만, 설계 과정에서 고민하는 것과 겹치는 부분 존재

필요한 만큼 설계

  • 설계가 불필요하게 복잡해지는 것을 방지

기능 명세 구체화

  • 요구사항 명세에는 개발자가 기능을 구현하기에 생략된 내용이 많다.
  • 실무 담당자와 얘기해서 상황에 따라 기능이 어떻게 동작하는지 구체적으로 정리해야 한다.

5장. JUnit 5 기초

  • @Test 를 붙인 메서드는 private이면 안된다.
    • 이유는? 리플렉션 때문인가? 시간되면 찾아보기
  • 테스트 메서드가 특정 순서대로 실행된다는 가정하에 테스트 메서드를 작성하면 안된다.
  • 각 테스트 메서드는 서로 독립적으로 동작해야 한다.

6장. 테스트 코드의 구성

  • 상황 - 실행 - 결과 확인 구조에 너무 집착하지 말자

    • 테스트 코드를 보고 테스트 내용을 이해할 수 있으면 다른 구조여도 문제 없음
  • 기능은 상황에 따라 실행 결과가 달라진다.

    • 어떤 상황이 실행 결과에 영향을 줄 수 있는지 찾기 위한 노력이 필요
    • ex) 파일을 통해 입력을 받는 경우, 외부 API를 이용하는 경우
  • 물론, 결과에 영향을 주는 상황이 없는 경우도 존재한다

  • 외부 상태가 테스트 결과에 영향을 주지 않게 하기

    • 테스트는 언제 실행해도 항상 정상적으로 동작하는 것이 중요하다.
    • 외부 환경을 테스트에 알맞게 세팅해야함
    • 외부 요인 ex) 파일, DBMS, 외부 서버
  • 하지만 테스트에 맞게 외부 환경을 구성하는 것이 항상 가능한 것은 아님

    • 테스트 대상의 상황과 결과에 외부 요인이 관여할 경우 대역을 사용하면 테스트 작성이 쉬워짐
    • 대역: 테스트 대상이 의존하는 대상의 실제 구현을 대체하는 구현

7장. 대역

  • 처음 봤을 때 대역을 bandwidth 라고 오해 했는데, 제어하기 힘든 외부의 상황을 흉내내 대신 하는 개념이었음

  • test double에서 double이 대역을 의미

  • 의존하는 대상을 구현하지 않아도 테스트 대상을 완성할 수 있게 만들어줌

  • 협업 대기 시간을 줄여주어 개발 속도가 향상될 수 있다.

  • 대역의 종류

    • Stub

      • 구현을 단순한 것으로 대체
      • ex) 외부 API를 통한 검증 기능을 단순하게 구현할 수 있음
    • Fake

      • 테스트에 필요한 동작하는 구현을 제공
      • ex) DB 대신 맵을 이용한 메모리 DB
    • Spy

      • 호출된 내역을 기록
      • 테스트 결과 검증
      • ex) 이메일 발송 여부 확인
    • Mock

      • 기대한 상호작용을 하는지 행위 검증
      • 스텁이자 스파이
  • 모의 객체 과하게 사용하지 않기

    • 모의 객체를 이용하면 대역 클래스를 만들지 않아도 되니까 처음에는 편할 수 있음
    • 결과 값을 확인하는 수단으로 모의 객체를 사용하기 시작하면 결과 검증 코드가 길어지고 복잡해짐
    • 모의 객체는 기본적으로 메서드 호출 여부(행위)를 검증하는 수단이기 때문에
      • 테스트 대상과 모의 객체 간의 상호 작용이 조금만 바뀌어도 테스트가 깨지기 쉽다.
    • 저장소에 대한 대역은 메모리를 이용한 가짜 구현 사용이 테스트 코드 관리에 유리

8장. 테스트 가능한 설계

  • 테스트가 어려운 코드

    • 하드 코딩된 상수
      • ✅ 해결책: 생성자나 메서드 파라미터로 받기
    • 의존 객체를 직접 생성
      • ❓ 이유: 테스트 하려면 의존하는 모든 환경을 구성해야함
        • ex) DB를 준비해야 하고, 필요한 테이블도 만들어야함 -> 테스트 후 제거 작업도 필요
      • ✅ 해결책: 의존 대상을 주입 받기
        • 생성자, Setter
    • 정적 메서드 사용
      • ✅ 해결책: 외부 라이브러리는 직접 사용하지 말고 감싸서 사용
    // if. 외부 라이브러리가 제공하는 정적 메서드: AuthUtil.authorize(authKey);
    
    public class AuthService{
        public int authenticate(String id, String pw){
         	boolean authorized = AuthUtil.auithorize(authKey);
            if(authorized){
             	return AuthUtil.authenticate(id,pw);   
            }else{
                return -1;
            }
        }  
    }
    • 실행 시점에 따라 달라지는 결과
      • ✅ 해결책: 시간이나 임의 값 생성 기능 분리
    • 역할이 섞여 있는 코드
      • ✅ 해결책: 테스트 하고 싶은 코드를 분리
    • 그 외
      • 메서드 중간에 소켓 통신 코드 포함
      • 콘솔에서 입력을 받거나 결과를 콘솔에 출력
      • 테스트 대상이 사용하는 의존 대상 클래스나 메서드가 final
        • 대역으로 대체하기 어려울 수 있음
      • 테스트 대상의 소스를 소유하고 있지 않아 수정이 어려운 경우

9장. 테스트 범위와 종류

  • 단위 테스트

    • 응용 프로그램에서 테스트 가능한 가장 작은 소프트웨어를 실행하여, 예상대로 동작하는지 확인하는 테스트

    • 클래스나 한 메서드와 같은 작은 범위

  • 통합 테스트

    • 단위 테스트가 하지 못하는 DB연결, 외부 api 연동을 통합하며, **비즈니스 로직(내부 구조)**을 테스트 할 수 있는 테스트

    • 단위 테스트보다 초기 세팅(디비 커넥션 연결 등..)이 많아 오래 걸린다.

    • 결국은 각 구성 요소가 올바르게 연동되는 것을 확인 해야하기 때문에 필요함

    • @SpringBooTest를 통해 가능

  • 인수 테스트(= E2E 테스트, 기능 테스트)

    • 실제 사용자 관점에서 하는 테스트로, 내부 로직에 관심이 없는 블랙박스 테스트이다.
    • 데이터베이스나 외부 서비스에 이르기 까지 모든 구성 요소를 하나로 엮어서 진행한다.
    • RestAssured, MockMvc 같은 도구를 활용하여 인수 테스트를 작성할 수 있다.
  • 속도가 빠른 단위 테스트에서 다양한 상황을 다루고, 통합 테스트나 기능 테스트는 주요 상황 에 초점을 맞춰야 함.

    • WireMock 을 이용한 REST 클라이언트 테스트
      • 서버 API를 Stub으로 대체할 수 있음
    • 스프링 부트의 내장 서버를 이용한 API 기능 테스트
      • TestRestTemplate

10장. 테스트 코드와 유지보수

  • 깨진 테스트가 발견되면 즉시 수정해서 테스트 실패가 확산되는 것을 방지해야한다.

    • 깨진 유리창 이론

처음 TDD를 시도하는 개발자들이 빠지기 쉬운 실수와 주의 사항 🛑

변수나 필드를 사용해서 기댓값 표현하지 않기

  • 복잡하고, 실수로 테스트가 깨질 수 있다.

  • 성공하더라도, 테스트 코드를 처음 보는 사람은 변수와 필드를 오가며 이해해야함

두 개 이상을 검증하지 않기

  • 뭐가 실패했는지 확인하는 시간이 추가로 듬

  • 역할을 분리하자

정확하게 일치하는 값으로 모의 객체 설정하지 않기

  • 모의 객체는 해당 객체의 내부 로직 테스트 용이 아님

  • 테스트 대상의 상황을 원할하게 만들기 위함임

과도하게 구현 검증하지 않기

  • 내부 구현 검증은 로직이 조금만 변경해도 테스트가 깨질 수 있으므로 지양해야함

  • 테스트 코드는 내부 구현보다 실행 결과검증 해야한다.

셋업을 이용해서 중복된 상황을 설정하지 않기

  • 테스트가 실패할 경우 실패한 원인을 분석해야한다.

  • 모든 곳에서 사용하므로, 테스트가 깨지기 쉬운 구조가 됨.

  • 상황 구성 코드가 테스트 메서드 안에 위치해야 함

통합 테스트에서 데이터 공유 주의하기

  • 셋업 메서드 중복 상황과 비슷한 사유

통합 테스트의 상황 설정을 위한 보조 클래스 사용하기

  • 테스트 메서드에서 직접 상황을 구성하면 복잡해지는 단점이 있는데, 보조 클래스로 코드 중복을 제거한다.

실행 환경이 다르다고 실패하지 않기

  • 꼭 특별한 환경이 필요하다면, @EnabledOnOs 애노테이션 사용

실행 시점이 다르다고 실패하지 않기

  • ex) 현재 시간 활용

랜덤하게 실패하지 않기

필요하지 않은 값은 설정하지 않기

  • 검증할 범위에 필요한 값만 설정

조건부로 검증하지 않기

  • 테스트는 반드시 성공하거나 실패 해야 한다.
  • 조건에 따라서 단언을 하지 않으면, 그 테스트는 성공하지도 실패하지도 않은 테스트가 됨

통합 테스트는 필요한 부분만 연동하기

  • @SpringBootTest는 서비스, 컨트롤러 등 모든 스프링 빈 초기화 + DB 관련 설정 + etc... = 오래 걸림
  • DataBase 연동 통합 테스트만 필요한 경우 다른 빈을 생성하지 않는@JdbcTest와 같은 애노테이션 이용

11장. 마치며

  • 테스트를 먼저 작성하면 적어도 해당 테스트를 통과한 만큼은 코드를 올바르게 구현했다는 사실을 알 수 있음
  • 테스트 코드는 회귀 테스트로 사용할 수 있음
    • 코드를 수정하거나 추가할 때 앞서 작성한 테스트를 사용하면 문제가 없는지 바로 확인 가능
    • 즉, 버그 수정도 더 쉬워짐.
  • 개발시간 = 코딩 + 디버깅 + 테스트
    • TDD를 경험하지 못한 사람들은
      • 수동으로 테스트하는 시간과 디버깅하는 시간이 개발 시간에 포함된다는 사실을 인지 못하는 경우가 있음
    • 테스트 시간을 줄이려면 자동화를 해야하는데, TDD는 이러한 반복적인 테스트 시간을 줄여줌 => 개발시간 감소

Reference.