테스트 주도 개발 시작하기 요약

Rudy Lee (이재훈)·2021년 6월 16일
2
post-thumbnail

테스트 주도 개발 시작하기

※ 본 포스트는 2020년 2월 18일에 출간된 최범균님의 <테스트 주도 개발 시작하기> (가메 출판사)를 읽으면서 개인적으로 정리한 내용입니다. 책과는 일부 다른 내용이 있을 수 있습니다.

TDD 시작

TDD 이전의 개발

개발 과정:

  1. 설계를 통해 인터페이스와 클래스, 메서드를 도출한다.
  2. 구현 코드를 한 번에 작성한다.
  3. 구현 완료 후 수동으로 기능을 테스트한다.
  4. 원하는 대로 동작 하지 않거나 문제가 발생하면 디버깅을 통해 원인을 파악하고 수정한다.
  5. 3, 4의 과정을 개발이 완료될 때까지 반복한다.

문제점:

  1. 한 번에 작성하는 코드가 많다.

    • 문제 발생시 원인을 파악하기 위해 확인해야 하는 코드량이 많다.
    • 디버깅 하는 시간이 길어진다. (최초에 코드를 작성하는 시간보다 디버깅 시간이 길어지기도 한다.)
  2. 코드를 작성하는 개발자와 코드를 테스트하는 개발자가 다른 경우가 많다.

    • 코드 작성 시점과 코드 테스트 시점이 다를 확률이 높다.
    • 다른 개발자가 과거에 작성한 코드를 모두 살펴보며 디버깅 해야 하므로 공수가 많이 든다.
  3. 테스트 환경을 구성하기 쉽지 않다.

    • WAS나 DB 등을 구동하는 데에 시간이 소모된다.
    • 테스트를 위한 사전 조건을 구성하는 과정이 복잡하다.

한 번에 구현하는 코드가 많을 수록, 한 번에 구현하는 시간이 길어질 수록 디버깅 난이도가 높아진다.

TDD란?

TDD란 기능이 올바르게 동작하는지 검증하는 테스트 코드를 먼저 작성하고, 작성한 테스트를 통과시키기 위한 구현을 작성하는 개발 방식이다.

구체적인 TDD 수행 절차:

  1. 테스트할 대상과 케이스를 선택한다.
  2. 해당 케이스에 대한 테스트 코드를 작성한다.
  3. 컴파일 오류를 없애는 데 필요한 구현 요소들(클래스, 메서드 등)을 추가한다.
  4. 테스트를 실행하여 실패하는 것을 확인한다.
  5. 테스트를 통과시킬 만큼만 구현 코드를 추가한다.
  6. 테스트를 통과했다면 구현 코드와 테스트 코드에 개선할 코드가 있는지 확인 후 리팩토링한다.
  7. 리팩토링 이후에도 모든 테스트를 통과하는지 확인한 후 마무리한다.
  8. 새로운 케이스를 선택한 후 2 ~ 7의 과정을 반복하면서 점진적으로 기능을 완성해 나간다.

단언(Assertion)이란?

  • 단언은 값이 특정 조건을 충족하는지 확인하고, 충족하지 않는 경우 예외(Exception)를 발생시킨다.

  • 테스트 메서드 내의 단언에서 예외가 발생하는 것을 "테스트에 실패했다"고 한다.

TDD 사이클:

  • 테스트 작성 - 개발 - 리팩토링을 반복하는 사이클
  • Red - Green - Refactor로 부르기도 한다.

테스트 코드의 작성 위치

  • 테스트 코드는 src/test/java 소스 폴더에 작성한다.

  • src/test/java 소스 폴더는 배포 대상이 아니므로 해당 폴더에 코드를 만들면 완성되지 않은 코드가 배포되는 것을 방지하는 효과가 있다.

  • 개발이 완료되면 구현 소스 코드를 src/main/java 소스 폴더로 이동한다.

  • 테스트 코드에서 사용하는 파일은 src/test/resources 폴더에 보관하고, 소스 코드 리포지토리에 함께 업로드 해야 한다.

테스트 작성 순서 결정

대원칙: TDD 사이클을 짧게 가져갈 수 있는 케이스부터 테스트한다.

  • 쉬운 케이스에서 어려운 케이스 순서로 테스트한다.

    • 쉬운 케이스는 한 번에 만들어야 할 코드가 적고, 구현이 어렵지 않다.
    • 따라서 짧은 시간에 구현을 완료하고 테스트를 통과시킬 수 있다.
    • 구현 시간이 짧아지면 디버깅할 때 유리하다.
  • 예외적인 케이스에서 정상 케이스 순서로 테스트한다.

    • 이미 많은 코드를 완성한 뒤에 예외 조건을 반영하면 코드 구조가 많이 바뀔 가능성이 높다.
    • 초반에 예외 상황을 테스트하면 예외 처리 구조가 미리 만들어지기 때문에 코드 구조가 덜 바뀐다.
    • 예외적인 상황을 미리 고민하고 찾아보는 과정을 통해 버그 발생 가능성을 사전에 파악할 수 있다.

TDD의 장점

  • 테스트 코드를 작성하는 과정에서 기능 구현을 위한 설계 요소들을 먼저 고민하게 된다.
  • 코드가 추가 또는 변경될 때마다 테스트를 수행하여 코드의 문제점을 빠르게 파악할 수 있다.
  • 코드 수정에 대한 심리적 불안감을 줄여주므로 리팩토링을 보다 과감하게 진행할 수 있고, 지속적인 리팩토링이 가능하다.
  • 지속적인 리팩토링을 통해 코드 품질이 급격히 나빠지는 것을 방지하고, 유지보수 비용을 낮춰준다.
  • 변화하는 요구 사항을 적은 비용으로 반영할 수 있게되어 소프트웨어의 생존 시간을 늘려준다.
  • 테스트 코드가 추가될 때마다 검증하는 범위가 넓어져서 소프트웨어 품질이 상승한다.

완급조절

테스트를 만들고 통과시키는 과정에서 구현이 막히는 경우 다음과 같은 순서로 점진적으로 구현해 본다.

  1. 정해진 상수 값을 리턴
  2. 값 비교를 이용해서 정해진 상수 값을 리턴
  3. 다양한 테스트 케이스를 추가하면서 단계적으로 구현을 일반화

리팩토링 시점

  • 작은 리팩토링은 발견하는 즉시 실행한다.
  • 메서드 추출과 같이 코드 구조에 영향을 주는 리팩토링은 큰 틀에서 구현 흐름이 눈에 들어오기 시작한 후 진행한다.
  • 큰 단위의 리팩토링전체 코드의 의미나 구조가 명확해졌을 때 시도한다.
    • 리팩토링 범위가 크면 리팩토링에 실패할 수도 있다.
    • 범위가 큰 리팩토링을 진행하기 전에는 코드를 커밋한 후, 별도 브랜치에서 작업한다.

리팩토링 TIP

  • 중복을 알맞게 제거하거나, 의미가 잘 드러나게 코드를 수정한다.

    • 중복이 있다고 무턱대고 제거하면 안된다. 중복을 제거한 뒤에도 테스트 코드의 가독성이 떨어지지 않고, 수정이 용이한 경우에만 중복을 제거한다.
  • 메서드 파라미터 개수는 적을 수록 코드 가독성과 유지보수에 유리하다.

    • 파라미터 개수가 3개 이상이면 객체로 바꾸는 것을 고려한다.

테스트할 목록 정리하기

  • 테스트 과정에서 새로운 테스트 케이스를 발견하면, 그 즉시 테스트할 목록에 추가해서 놓치지 않도록 한다.
  • 테스트할 목록이 있다고 해서 한 번에 모든 테스트를 작성하면 안된다. 반드시 한번에 하나씩 사이클을 반복해야 한다.

TDD 기능 명세와 설계

기능

  • 기능 명세는 다양한 형태로 존재한다.
    • 파워포인트를 이용한 스토리보드나 와이어프레임
    • 이메일 및 메신저 등을 이용한 간단한 문장
    • 이슈 트래커, 버그 리포트 등의 문서
    • 구두 전달
  • 기능은 입력과 결과를 가진다.
    • 입력: 기능을 실행하는데 필요한 값
      • 메서드의 호출 인자
    • 결과: 기능을 수행한 결과로 형태가 다양
      • 리턴 값: 메서드 실행의 결과로 리턴되는 값
      • 예외: 메서드 실행 도중 throw된 예외
      • 변경: 시스템이나 DB의 상태 변화
  • 기능은 주어진 상황에 따라 다르게 동작할 수 있다.

설계

설계는 기능 명세로부터 시작한다.

  • 다양한 형태의 요구사항을 수집하여 기능 명세를 구체화한다.
  • 기능 명세를 구체화하면서 입력과 결과를 도출한다.
    • 입력과 결과가 모호하거나 애매한 경우 담당자와의 대화를 통해 입력과 결과를 구체화한다.
    • 구체적인 예시 케이스를 먼저 찾고, 이를 통해 기능 명세를 구체화한다.
    • 예외적이거나 복잡한 케이스를 구체적인 예시로 도출하는 과정에서 모호함이 사라진다.
  • 기능 명세의 입력과 결과를 코드에 반영한다.
  • 코드에 반영하는 과정에서 다음의 설계 요소들이 결정된다:
    • 테스트 대상 클래스 이름
    • 메서드 종류 (인스턴스 메서드 VS 정적 메서드)
    • 메서드 이름
    • 메서드 파라미터 타입 및 개수
    • 메서드 리턴 타입과 의미

구현하기 전에 모든 기능을 설계하는 것은 불가능하다. 개발을 진행하는 동안에도 요구사항은 계속 바뀐다. 구현하다 보면 설계 단계에서 생각하지 못했던 의존 대상이 출현하기도 하고, 필요할 것이라 생각했던 의존 대상이 사라지기도 한다.

테스트 작성 시점에 필요한 설계

테스트는 기능을 실행하고 결과를 검증할 수 있어야 한다. 따라서 다음과 같은 요소들을 정의해야 한다.

  • 기능을 실행하기 위해 정의할 요소: 클래스, 메서드, 파라미터 등
  • 결과를 검증하기 위해 정의할 요소: 리턴 값

알맞은 이름을 짖자

  • 이름에서 기대하는 것과 다르게 동작하는 코드는 개발자를 속이고 코드 분석 시간을 불필요하게 늘린다.
  • 시간이 다소 걸리더라도 기능을 정확하게 표현하는 이름을 찾아야 한다.

필요한 만큼만 설계하자

  • TDD는 테스트를 통과할 만큼의 코드만 작성한다.
  • 필요할 것으로 예측해서 미리 설계를 유연하게 만들지 않는다.
  • 테스트 케이스를 추가하고 통과시키는 과정에서 필요한 만큼만 설계를 변경한다.
  • 예외 또한 미리 앞서서 정의하지 않고, 테스트를 진행하는 과정에서 실제 예외가 필요해지는 시점에 예외를 도출하고 추가한다.
  • TDD는 미리 앞서서 코드를 만들지 않으므로 설계가 불필요하게 복잡해지는 것과 불필요한 구성 요소가 출현하는 것을 방지한다.

테스트 결과의 일관성과 대역

테스트 코드의 골격 (Given-When-Then)

  • 기능은 상황에 따라 결과가 달라진다.
  • 테스트 코드는 다음과 같은 기본 구조를 가진다:
    • Given: 어떤 상황이 주어졌을 때
    • When: 그 상황에서 기능을 실행하면
    • Then: 실행한 결과는 특정한 조건을 만족해야 한다.
      • 실행한 결과는 리턴 값일 수도, 예외일 수도, 시스템이나 DB의 상태 변경일 수도 있다.

테스트 결과의 일관성

  • 구현을 변경하지 않았다면, 테스트는 언제나 동일한 결과를 보장해야 한다.

    • 성공하는 테스트는 항상 성공하고, 실패하는 테스트는 항상 실패해야 한다.
    • 결과가 일관적이지 않은 테스트는 테스트의 신뢰도를 떨어뜨린다.
  • 테스트는 실행 순서에 영향을 받지 않아야 한다.

    • 각 테스트는 독립적으로 작성되어야 한다.
  • 테스트는 실행 시점이나 우연한 값에 의존하지 않아야 한다.

  • 테스트가 외부 요인에 의존할 경우, 테스트 결과의 일관성을 확보하기 어렵다.

외부 요인 다루기

  • 외부 요인의 예시: 파일시스템, DB, 외부 서버 등
  • 외부 요인에 의존적인 테스트를 작성하는 경우:
    1. 테스트 실행 전에 외부를 원하는 상태로 만든다.
    2. 테스트를 실행한다.
    3. 테스트 실행 후에 외부 상태를 원래대로 되돌려 놓는다.
  • 하지만 외부 환경을 테스트에 맞게 구성하고 제어하는 것어렵기도 하고, 때로는 불가능 할 수도 있다.
  • 이러한 경우 대역을 사용하여 외부 요인이나 결과를 대체해야 한다.

대역(Double)

  • 대역: 특정 객체의 동작을 흉내내는 객체
  • 대역의 종류:
    • 스텁(Stub): 단순히 테스트를 위해 필요한 동작을 수행하는 대역
      • ex) 단순히 특정 상수를 리턴하는 경우
    • 가짜(Fake): 제품에는 적합하지 않지만 실제 동작하는 구현을 제공하는 대역
      • ex) 실제 DB 대신 In-memory DB를 구현한 경우
    • 스파이(Spy): 호출된 내역을 기록하는 대역으로, 호출 내역을 검증에 사용한다.
      • 스파이는 스텁이기도 하다.
    • 모의(Mock): 기대한 대로 상호작용하는지 행위를 검증하는 대역으로, 예외를 발생시킬 수 있다.
      • 모의 객체는 스텁이자 스파이이기도 하다.
      • Mockito를 이용하면 모의 객체를 쉽게 만들 수 있다.
  • 대역을 사용하면 실제 구현이 없어도 다양한 상황을 테스트하고 실행 결과를 확인할 수 있다.
  • 대역을 사용하면 외부 상황을 변경을 기다리는 대기 시간을 절약하여 개발 생산성을 높힐 수 있다.

대역으로 만들기 적합한 상황

  • 제어하기 힘든 외부 요인
  • 당장 구현하는 데 시간이 오래 걸리는 로직
  • 테스트 하고 싶은 기능과 직접적인 관계는 없지만 테스트 대상을 생성할 때 필요한 의존 요소

대역 만들기

  1. 제어하기 힘든 외부 상황을 별도 클래스로 분리한다.
  2. 테스트 코드는 별도로 분리한 클래스의 대역을 생성한다.
  3. 생성한 대역을 테스트 대상의 생성자 등을 이용해서 전달한다.
  4. 대역을 이용해서 원하는 상황을 구성한다.

모의 객체를 사용할 때의 주의점

  • 모의 객체를 과하게 사용하면 오히려 테스트 코드가 복잡해질 수 있다.
  • 결과 값을 확인하는 수단으로 모의 객체를 사용하면 결과 검증 코드가 길어지고 복잡해진다.
  • 모의 객체는 기본적으로 메서드 호출 여부를 검증하는 수단이므로, 테스트 대상과 모의 객체 간의 상호 작용이 조금만 바뀌어도 테스트가 깨지기 쉽다.
  • DAO나 리포지토리와 같은 저장소에 대한 대역은 모의 객체보다 메모리를 이용한 가짜 구현을 사용하는 편이 코드의 간결성과 유지보수성에서 더 좋다.

테스트 가능한 설계

테스트하기 어려운 케이스

테스트 하기 어려운 케이스의 근본적 원인은 의존하는 코드를 교체할 수 있는 수단이 없기 때문인 경우가 많다:

  1. 하드 코딩된 리소스가 존재하는 경우
    • 파일 경로
    • 시스템 환경 변수
    • 서버 IP 주소, 포트 번호 등
  2. 의존 객체를 직접 생성하는 경우
    • 의존 대상을 직접 생성하는 경우, 해당 객체가 올바르게 동작하는데 필요한 모든 환경을 구성해야 한다.
    • 테스트 환경을 구성하고 상황을 만드는 데에 공수가 많이 든다.
  3. 정적 메서드를 사용하는 경우
    • 정적 메서드 내에서 외부 요인(ex 외부 서버)에 의존하고 있는 코드가 존재하면 대역을 만들기 어렵다.
  4. 결과가 실행 시점에 따라 달라지는 경우
    • 현재 시간을 테스트에 이용하는 경우: LocalDate.now(), new Date() 등을 이용하는 경우
    • 난수(임의 값)를 테스트에 이용하는 경우: new Random() 등을 이용하는 경우
  5. 로직이 분리되어 있지 않고 섞여 있는 경우
    • 하나의 기능을 테스트 하려면 해당 기능에서 사용하는 의존 객체들을 대역으로 만들어야 한다.
  6. 메서드 중간에 통신(ex. HTTP 통신, 소켓 통신 등) 코드가 포함되어 있는 경우
    • HTTP 통신이나 소켓 통신은 실제를 대체할 서버를 로컬에 띄워서 처리해야 한다.
    • WireMockServer를 사용하면 외부 서버 API를 스텁으로 대체할 수 있다.
  7. 콘솔 입/출력을 사용하는 경우
  8. 테스트 대상이 사용하는 의존 대상 클래스나 메서드가 final인 경우
    • 대역으로 대체하기 어렵다.
  9. 테스트 대상의 소스를 소유하고 있지 않은 경우
    • 문제가 발생할 경우 소스를 수정하기 어렵다.

테스트 가능한 설계

  1. 하드 코딩된 상수를 생성자나 메서드 파라미터로 받는다.
  2. 의존 대상을 주입 받는다.
    • 의존 대상은 생성자 주입으로 교체할 수 있도록 만든다.
    • 의존 대상을 교체할 수 있게 되면 실제 구현 대신에 대역을 사용할 수 있어 테스트가 수월해진다.
  3. 테스트하고 싶은 코드를 분리한다.
    • 기능의 일부만 테스트하고 싶은 경우, 해당 코드를 별도 기능으로 분리해서 테스트를 진행 할 수 있다.
  4. 시간이나 임의 값 생성 기능을 분리한다.
    • 테스트 대상이 사용하는 시간이나 임의 값을 제공하는 기능을 별도로 분리해서 테스트 가능성을 높일 수 있다.
  5. 외부 라이브러리는 직접 사용하지 말고 감싸서 사용한다.
    • 외부 라이브러리가 정적 메서드를 제공하는 경우, 대역으로 대체하기 어렵다.
    • 외부 라이브러리와 연동하는 객체를 별도로 만들면, 쉽게 대역으로 대체할 수 있게 된다.
    • 의존하는 대상이 final 클래스거나 의존 대상의 호출 메서드가 final인 경우에도 동일한 기법으로 테스트 가능하게 만들 수 있다.

테스트 범위와 종류

테스트 범위

테스트의 범위는 테스트의 목적과 수행하는 사람에 따라 달라진다.

  1. 단위 테스트(Unit Test)

    • 개별 코드나 컴포넌트가 기대한대로 동작하는지 확인한다.
    • 한 클래스나 한 메서드와 같은 작은 범위를 테스트한다.
    • 일부 의존 대상은 스텁이나 모의 객체 등을 이용해서 대역으로 대체할 수 있으므로, 통합 테스트로 만들기 어려운 상황을 쉽게 구성할 수 있다.
    • 테스트 실행 속도가 빠르다.
    • 개발자가 테스트 코드를 작성하고 수행한다.
  2. 통합 테스트(Integration Test)

    • 시스템의 각 구성 요소가 올바르게 연동되는지 확인한다.
      • 구성 요소: 프레임워크, 라이브러리, 데이터베이스, 외부 서버 등
    • 주로 서비스 레이어의 기능에 대한 테스트가 일반적이다.
    • 각 연동 대상의 구동과 초기화가 완료되고 테스트 상황이 구성되어야만 테스트가 실행될 수 있으므로 테스트 실행 속도가 비교적 느리다.
    • 개발자가 테스트 코드를 작성하고 수행한다.
  3. 기능 테스트(Functional Test)

    • 사용자 입장에서 시스템이 제공하는 기능이 올바르게 동작하는지 확인한다.

    • 시스템을 구동하고 사용하는데 필요한 모든 구성 요소(ex. 웹 브라우저, 모바일 앱, 데이터베이스, 외부 서비스 등)를 하나로 엮어서 진행한다.

    • 끝에서 끝까지 모든 구성요소를 논리적으로 완전한 하나의 기능으로 다루므로, E2E(End-to-End) Testing으로 볼 수 있다.

    • 주로 QA 조직의 전문적인 테스터가 수행한다.

  4. 인수 테스트(Acceptance Test)

    • 시스템이 주어진 요구사항을 올바르게 구현했는지 확인한다.
    • 고객이 직접 테스트를 수행한다.

테스트 코드와 유지보수

자동화 테스트

CI(Continuous Integration; 지속적 통합): 새로운 코드 변경 사항이 지속적으로 빌드/테스트되어 공유 코드 리포지토리에 통합되는 것

CD(Continuous Delivery/Deployment; 지속적 제공/배포): 변경된 코드가 컨테이너 레지스트리에 지속적으로 업로드되고, 자동으로 프로덕션 환경까지 릴리즈 하는 것

  • CI/CD를 실현하기 위해서 자동화 테스트는 필수이다.
  • 자동화 테스트는 기존 기능이 망가지거나 버그가 배포되는 것을 방지하여 소프트웨어 품질을 높여준다.

테스트 코드와 유지보수

  • 테스트 코드는 제품 코드와 동일하게 유지보수 대상이다.
  • 테스트 코드를 유지보수하는 데 시간이 많이 들면 유지보수를 소홀히 하게 되고 실패하는 테스트가 증가하게된다.
  • 한 두개의 실패하는 테스트를 방치하기 시작하면(깨진 유리창 이론) 다음과 같은 문제들이 발생한다:
    • 실패한 테스트가 새로 발생해도 무감각해지게 된다.
    • 테스트 실패 여부에 상관없이 빌드하고 배포하게 된다.
    • 빌드를 통과시키기 위해 실패한 테스트를 주석처리만 하고 고치지 않게 된다.
    • 결과적으로 테스트 코드를 만들지 않게 된다.
  • 테스트 코드를 만들지 않게 되면 테스트가 용이하지 않은 코드를 만들어내게 되고, 이는 다시 테스트 코드를 만들지 않게 하는 악순환이 발생한다.
    • 이런 악순환이 발생하지 않으려면 테스트 코드 자체의 유지보수성이 좋아야 한다.
    • 테스트 코드를 유지보수하기 좋아야 지속적으로 테스트를 작성하게 된다.
  • 실패하는 테스트가 발견되면 즉시 수정해서 테스트 실패가 확산되는 것을 방지해야 한다.

테스트 코드의 유지보수성을 높히는 방법

  1. 변수나 필드를 사용해서 기댓값을 표현하지 않는다.
    • 변수나 필드를 사용해서 기댓값을 표현하면, 해당 변수나 필드가 참조하고 있는 값이 무엇인지 파악해야 하므로 테스트 코드의 가독성이 떨어질 수 있다.
    • 기댓값에는 간단한 상수 리터럴을 사용하는 편이 더 명료한 경우가 많다.
  2. 하나의 테스트에서 두 개 이상을 검증하지 않는다.
    • 한 테스트에서 검증하는 내용이 두 가지 이상이면 테스트 결과를 확인할 때 집중도가 떨어질 수 있다.
    • 테스트가 실패했다면 두 가지 검증 대상 중 무엇이 실패했는지 파악해야만 한다.
    • 첫 번째 검증 대상을 통과시켜야 비로소 두 번째 검증의 성공 여부를 확인할 수 있다.
    • 한 테스트는 한 가지만 검증해야 테스트가 실패했을 때 무엇이 잘못되었는지 빨리 확인할 수 있고, 테스트도 빨리 통과시킬 수 있다.
    • 검증 대상이 명확하게 구분된다면 각 검증 대상을 별도로 분리하는 것이 유지보수에 유리하다.
  3. 정확하게 일치하는 값으로 모의 객체를 설정하지 않는다.
    • 모의 객체를 이용할 때, 특정 값을 사용해서 결과나 호출 여부를 검증할 경우, 파라미터 값이 조금만 변경되어도 테스트가 깨지게 된다.
    • (테스트의 의도를 해치지 않는 범위에서) 모의 객체는 가능한 특정한 값보다는 범용적인 값을 사용해서 기술하는 편이 좋다.
    • 범용적인 값을 사용하면 약간의 코드 수정 때문에 테스트가 실패해서 모의 객체 관련 코드를 함께 수정해야 하는 번거로움을 줄일 수 있다.
    • ex) "someString"과 같은 특정한 값 보다는 Mockito.anyString()과 같은 범용적인 값을 쓰자.
  4. 내부 구현이 아닌 실행 결과를 검증한다.
    • 테스트 대상의 내부 구현을 검증하는 것은 지양하는 편이 좋다.
    • 내부 구현을 검증하는 테스트는 구현이 조금만 바뀌더라도 테스트가 깨질 가능성이 높다.
    • 내부 구현은 언제든지 바뀔 수 있기 때문에 테스트 코드는 내부 구현이 아닌 실행 결과를 검증해야 한다.
  5. 셋업을 이용해서 중복된 상황을 설정하지 않는다.
    • 각 테스트 코드에서 동일한 상황이 필요할 때가 있다.
    • 중복을 제거하고 코드 길이를 줄인다는 이유로 상황을 설정하는 코드를 setUp() 메서드로 추출하고 싶은 유혹에 빠지기 쉽다.
    • 셋업 메서드를 이용하면 모든 테스트 메서드가 동일한 상황을 공유하기 때문에 조금만 내용을 변경해서 테스트가 깨질 수 있다.
      • 테스트가 깨지는 것을 방지하기 위해서는 다른 테스트 메서드의 코드를 분석하여 영향도를 파악해야 하므로 번거롭다.
    • 테스트 메서드는 검증을 목표로 하는 하나의 완전한 프로그램이어야 한다. 그러기 위해서는 상황 구성 코드가 테스트 메서드 안에 위치해야 한다.
    • 셋업 메서드를 이용해서 여러 메서드에 동일한 상황을 적용하는 것이 처음에는 편리하지만 시간이 지나면 테스트 코드를 이해하고 유지보수하는데 오히려 방해 요소가 된다.
    • 테스트 메서드는 자체적으로 검증하는 상황과 내용을 자기 자신이 완전히 기술하고 있어야 한다.
  6. 통합 테스트에서 데이터 공유를 주의한다.
    • 통합 테스트 코드를 만들 때는 다음의 두 가지로 초기화 데이터를 나눠서 생각해야 한다.
      • 모든 테스트가 같은 값을 사용하는 데이터. ex) 코드값 데이터
      • 특정 테스트 메서드에서만 필요한 데이터. ex) 중복 ID 검사를 위한 회원 데이터
    • 모든 테스트가 같은 값을 사용하는 데이터는 동일한 데이터를 공유해도 된다.
    • 특정 테스트 메서드에서만 의미 있는 데이터는 모든 테스트가 공유하면 안된다. 이러한 데이터는 특정 테스트에서만 생성하는 편이 좋다.
  7. 실행 환경에 따라 실패하는 테스트를 작성하지 않는다.
    • 같은 테스트 메서드가 실행 환경에 따라 성공하거나 실패하면 안된다.
      • ex) 로컬 환경에서는 성공하는데 빌드 서버에서는 실패한다. 윈도우에서는 성공하는데 리눅스에서는 실패한다.
    • 테스트에서 사용하는 파일은 프로젝트 폴더를 기준으로 상대 경로를 사용해야 한다.
    • 파일을 생성하는 경우 실행 환경에 알맞은 임시 폴더 경로를 구해서 사용한다.
    • 특정 OS 환경에서만 실행 또는 무시되어야 하는 경우, @EnabledOnOS 또는 @DisabledOnOS를 사용한다.
  8. 실행 시점에 따라 실패하는 테스트를 작성하지 않는다.
    • 특정 시간이 관련된 테스트를 하는 경우, 별도의 시간 클래스를 작성하여 테스트 코드의 시간을 원하는 시점으로 제어한다.
  9. 랜덤하게 실패하는 테스트를 작성하지 않는다.
    • 테스트에서 랜덤 값을 사용하면 테스트는 랜덤하게 실패할 수 있다.
    • 직접 랜덤 값을 생성하지 말고, 생성자를 통해 값을 받거나 랜덤 값 생성을 다른 객체에게 위임하게 바꾼다.
  10. 필요하지 않은 값을 설정하지 않는다.
    • 검증할 내용에 관련없는 값들은 해당 테스트 메서드에 있을 필요가 없다.
    • 테스트할 범위에 꼭 필요한 값들만 설정하면 테스트 코드도 짧아지고 가독성도 높아진다.
  11. 객체 생성 보조 클래스를 사용한다.
    • 상황 구성을 위해 필요한 데이터가 다소 복잡한 경우, 테스트를 위한 객체 생성 클래스를 따로 만드는 편이 좋다.
    • 팩토리나 빌더를 이용해서 다양한 상황을 유연하게 구성할 수 있다.
  12. 조건부로 검증하지 않는다.
    • 테스트는 성공하거나 실패해야 한다.
    • 조건부로 단언을 실행하는 경우, 테스트가 성공 또는 실패하지 않고 그대로 끝나는 경우가 발생할 수 있다.
    • 이러한 경우 조건에 대한 단언도 실행하여 반드시 성공 또는 실패하도록 만들어야 한다.
  13. 통합 테스트시 필요하지 않은 범위까지 연동하지 않는다.
    • 통합 테스트 실행시에 전체 애플리케이션을 구동하면 필요하지 않은 객체까지 생성하게 되어 테스트 실행이 느려진다.
    • 또한 테스트 대상 이외의 요소 때문에 테스트가 실패할 수 있다.
    • 테스트에 필요한 연동 대상만을 생성하고 설정하도록 만들면 테스트 수행 시간이 짧아진다.
  14. 더 이상 사용되지 않는 테스트 코드는 제거한다.
    • 소프트웨어가 제공해야 하는 기능을 테스트하는 코드가 아니라면 삭제한다.
    • 단지 테스트 커버리지를 높이기 위한 목적으로 작성한 테스트 코드는 실제 코드 유지보수에는 아무런 도움이 되지 않으므로 삭제한다.

테스트 커버리지(Test Coverage)란 테스트하는 동안 실행하는 코드가 얼마나 되는지 설명하기 위해 사용하는 지표로 보통 비율을 사용한다.

마치며

스트레스와 악순환

  • 빨리 구현해야 한다는 압박은 높은 스트레스를 준다.
  • 압박은 본인이 만든 코드를 충분히 테스트하지 않고 다음 기능을 구현하게 만든다.
  • 구현한 코드를 제대로 테스트하지 못했다는 사실 또한 스트레스를 증가시킨다.
  • 충분하지 않은 테스트와 피로감은 판단력을 흐리게 해서 점점 더 테스트를 소홀히 하게 만든다.
  • 코드의 품질을 돌볼 여력이 없기에 코드는 점점 복잡해지고 가독성은 떨어지며, 소프트웨어 품질은 하락한다.
  • 스트레스는 테스트를 하지 않게 만들고 이는 다시 스트레스 증가로 이어진다.

TDD를 이용한 악순환 제거

  • 테스트를 먼저 작성하면 적어도 해당 테스트를 통과한 만큼은 코드를 올바르게 구현했다는 사실을 알 수 있다.

  • 테스트가 증가할수록 테스트의 검증 범위가 넓어짐으로써 코드에 대한 신뢰도 함께 증가한다.

  • 테스트 코드는 회귀 테스트로 사용할 수 있으므로, 변경된 코드에 대한 안전장치를 제공해준다.

  • 코드와 테스트에 대한 신뢰는 개발자의 스트레스를 줄여주고, 심리적 안정을 준다.

  • 이처럼 테스트를 먼저 작성해야 한다는 간단한 규칙을 통해 이로운 개발 주기를 만들 수 있다.

회귀 테스트(Regression Test): 테스트한 소프트웨어가 이후에 코드를 수정해도 기존 코드가 올바르게 동작하는지 확인하는 테스트

  • 소프트웨어를 변경하면 이전에 고쳤던 버그가 재발하거나 새로운 버그가 발생하는 일이 종종 발생한다.
  • 버그를 발견하면 버그를 수정하고 이를 확인할 테스트를 만든다.

레거시 코드와 테스트

  • 레거시 코드처럼 테스트 코드를 만들기 힘든 경우에는 일부 코드를 리팩토링 해서 테스트 코드를 만들 수 있는 구조로 변경해야 한다.
  • 테스트 코드 없는 리팩토링은 위험하지만, 영원히 테스트 코드를 만들지 못하는 것보다는 약간의 위험을 감수하는 편이 낫다.
  • 레거시 코드는 일부 코드를 별도 클래스로 분리하는 방식으로 테스트 코드를 만들 수 있다.
  • 일단 레거시 코드에 테스트가 들어가기 시작하면 테스트 코드를 이용해서 동료들과 협업 할 수 있게 된다.

레거시 코드에 테스트를 추가하는 방법

  1. 테스트 코드를 만들고 싶은 대상의 일부 코드를 별도 클래스로 분리한다.
    • 분리하는 코드의 범위는 작을 수록 테스트를 만들기 용이하다.
  2. 분리한 클래스에 대한 테스트 코드를 작성한다.
  3. 1 ~ 2의 과정을 반복하면서 점진적으로 코드를 분리해낸다.

TDD와 개발 시간

  • 개발 시간은 크게 세 가지로 나눌 수 있다.
    • 코딩 시간: 특정 기능을 구현하기 위해 코딩하는 시간
    • 테스트 시간: 기능을 잘 구현했는지 확인하기 위해 테스트를 수행하는 시간
    • 디버깅 시간: 테스트를 수행하는 과정에서 문제를 발견하면 원인을 찾기 위해 디버깅 하는 시간
  • 코딩 → 테스트 → 디버깅은 개발을 완료할 때까지 반복된다.
  • 전체 개발 시간 = (코딩 시간 + 테스트 시간 + 디버깅 시간) × 반복 횟수
  • 전체 개발 시간을 줄이려면 코딩 시간뿐만 아니라 테스트 시간과 디버깅 시간을 줄여야 한다.
  • 개발이 진행될 수록 기능은 점점 많아지고 복잡해지기 때문에 테스트를 수동으로 진행하면 전체 개발 시간은 점점 증가한다.
  • 따라서 처음에는 개발 시간을 늘리는 것처럼 보이는 테스트 코드가 개발이 진행됨에 따라 오히려 개발 시간을 줄여준다.

TDD 사이클과 개발 시간

  • TDD 사이클: 테스트 코드 작성 → 구현 및 테스트 통과 → 리팩토링
  • 테스트 코드
    • 자동화된 테스트 코드를 사용하기 때문에 테스트 시간이 감소한다.
  • 구현 및 테스트 통과
    • 한번에 하나만 집중해서 구현하므로, 구현 시간이 감소한다.
    • 기능을 구현하자마자 즉시 테스트를 수행할 수 있으므로, 디버깅 시간이 감소한다.
      • 코드를 작성한 시점과 테스트를 하는 시점이 멀어질수록 어떤 문제가 발생했을 때 원인을 찾는 시간이 증가한다.
  • 리팩토링
    • 리팩토링을 통해 코드 구조와 가독성을 개선하므로, 향후 코드 수정과 추가가 쉬워진다.
    • 미래의 코딩 시간이 감소한다.

1개의 댓글

comment-user-thumbnail
2021년 6월 16일

앞으로도 계속 올려주세요 ㅋㅋㅋ

답글 달기