이펙티브 소프트웨어 테스팅 정리하기

2025. 4. 26. 10:13·💪 Practice

 

 

 

 

최근 2달동안 이 책을 사내 스터디에서 야금야금 읽었습니다.

읽기 전에 기대했던 것은 좀 더 똑똑하게 테스트를 짜는 방법은 없을까였어요.

테스트를 짜는 데에 시간이 너무 많이 걸리고 비효율적이지 않느냐는 의견이 있었거든요.

많은 경우의 수를 계산해서 더미데이터를 만든다든지, 중복된 테스트를 레이어마다 여러개 만들고 있지는 않을까 하는 걱정이요.

그래서 읽기 시작했고 이 책을 읽은 후 생각을 나누며 테스트 규칙을 만들어가자는 것이 목표였어요.

 

 


1. 효율적이고 체계적인 소프트웨어 테스트

효율적인 소프트웨어 테스트란?

  • 요구사항을 작은 부분으로 나누어 테스트 케이스를 도출해내는 도메인 테스트
  • 명세를 완성하고 나서 코드에 초점을 맞추고 구조적 테스트(코드 커버리지) 통해 현재의 테스트 케이스가 충분한지 평가
  • 예시 기반 테스트 – 테스트를 위해 한 가지 데이터 포인트 작성
  • 특이한 경우에는 속성 기반 테스트를 사용해서 코드에서 발생할 수 있는 버그 발견
  • 계약과 스스로 고안한 방법의 사전, 사후 조건

1.1. 개발 과정에서의 효율적인 테스트

  1. 기능 개발은 종종 개발자가 어떤 종류의 요구사항을 받는 것으로 시작된다. UML 유스케이스나 애자일 유저 스토리 같은 포맷에 따라 요구사항 분석 후 코드 작성을 시작한다.
  2. 기능 개발을 유도하기 위해 개발자는 짧은 테스트 주도 개발(TDD) 과정을 반복한다.
  3. 요구사항이 매우 크고 복잡할 경우, 여러 단위(클래스, 메서드)를 만들게 되고, 각 단위는 다른 계약(Contract) 을 가지며 서로 어울려 전체 기능을 구성한다. 클래스를 테스트하기 쉽게 만드는 일은 어렵지만, 개발자는 항상 테스트 가능성(testability) 을 염두에 두고 설계해야 한다.
  4. 개발자가 자신이 만든 단위에 만족하고 요구사항을 충족한다고 생각하면 테스트를 작성한다. 새로 만든 단위를 대상으로 도메인 테스트, 경계 테스트, 구조적 테스트를 수행한다.
  5. 시스템 수준에서는 대규모 테스트(통합/시스템 테스트) 가 필요하다.
  6. 다양한 기법으로 테스트 케이스를 만든 후에는 자동화된 지능형 테스트 도구(테스트 케이스 생성, 돌연변이 테스트, 정적 분석 등)를 사용하여 사람이 도출하지 못한 케이스를 찾아낸다.
  7. 테스트를 마친 개발자는 편안한 마음으로 기능을 배포한다.

1.2. 반복 프로세스로서의 효율적 테스트

  • 효율적인 테스트는 단회성 작업이 아니라, 지속적 반복을 통해 개선되는 활동이다. 개발-테스트-개선의 루프가 자연스럽게 이어져야 한다.

1.3. 개발에 먼저 집중하고 나서 테스트하기

  • 코드 작성을 마친 후에는 체계적인 체크리스트에 따라 다양한 테스트 기법을 적용한다.
  • "개발 먼저, 테스트 나중" 접근법에서도 테스트는 여전히 중요한 역할을 한다.

1.4. '제대로 된 설계'에 대한 미신

  • 완벽한 설계를 전제로 테스트를 미루는 경우가 많지만, 실제로는 불완전한 설계라도 테스트가 가능해야 한다. 테스트는 설계를 보완하고 개선하는 역할을 한다.

1.5. 테스트 비용

  • 테스트는 비용이 든다.
    하지만 제대로 실패하는 테스트는 이후의 큰 실패를 막는 투자이다.
    언제 멈추고, 어디까지 해야 할지에 대한 균형 감각이 필요하다.

1.6. 효율적이면서 체계적이라는 것의 의미

  • 효율적이라는 말은, 올바른 테스트에 집중해야 함을 의미한다.
    테스트와 관련된 모든 것은 트레이드오프이며, 모든 기법은 명확한 시작점(무엇을 테스트할지) 과 끝점(언제 멈출지) 을 가진다.
  • 체계적이라는 말은, 어떤 코드 조각을 테스트할 때 누가 테스트하더라도 동일한 테스트 스위트를 만들 수 있도록 표준화된 프로세스를 갖추는 것을 의미한다.
    즉, 감에 의존하지 않는 테스트가 되어야 한다.

소프트웨어 테스트 원칙

시스템을 잘 테스트하고 싶다면 계속해서 더 많은 테스트를 추가해야 한다고 생각할 수 있지만, 현실은 그렇지 않다.

  1. 완벽한 테스트는 불가능하다
    • 효율적인 테스트가 필요한 이유다.
  2. 테스트를 그만둘 시점을 파악해야 한다
    • 목표는 최소한의 비용으로 최대한 많은 버그를 찾는 것이다.
  3. 가변성이 중요하다 (살충제 패러독스)
    • 하나의 기법만으로는 모든 버그를 잡을 수 없다. 다양한 전략을 섞어 써야 한다.
  4. 버그는 특정 지점에 몰려 있는 경향이 있다
    • 시스템을 관찰하고 학습해야 하며, 소스 코드보다 데이터나 메트릭이 테스트 우선순위를 결정하는 데 더 도움이 될 수 있다.
  5. 어떤 테스트도 완벽하거나 충분하지 않다
    • 항상 남은 위험이 존재한다.
  6. 맥락(Context)이 핵심이다
    • 테스트 기법 선택, 테스트 케이스 도출은 상황에 따라 달라진다.
  7. 검증은 유효성 검사가 아니다
    • 검증(Verification)은 시스템이 "제대로 만들어졌는가",
      유효성 검사(Validation)는 "올바른 시스템을 만들고 있는가" 에 대한 질문이다.
      둘 다 필요하다.

 

2. 명세 기반 테스트

  • 요구사항 자체에서 테스트를 도출하는 것

2.1 요구사항이 모든 걸 말한다.

  • 예제 1. substringsBetween
    • str 에 대한 테스트
      • null 일경우 null 을 반환해야한다.
      • 문자열이 1글자일 경우
        • open 만 존재하는 경우
        • close 만 존재하는 경우
        • 둘 다 존재하지 않는 경우
      • 문자열이 2글자일 경우
        • open 만 존재하는 경우
        • close 만 존재하는 경우
        • 둘 다 존재하지 않는 경우
      • 문자열이 매우 길 경우
    • open
      • null 일 경우 null 을 반환해야한다. . . .
  • 방법
    • 1단계 : 요구사항과 입출력에 대해 이해하기
    • 2단계 : 여러 입력값에 대해 프로그램이 수행하는 바를 탐색하기
    • 3단계 : 테스트 가능한 입출력과 구획을 탐색하기
      • "두 입력은 동등하다."
        • 프로그램은 일부 입력 집합에 대해 동일한 방식으로 동작한다. 자원은 한정적이므로 하나의 케이스가 그런 부류의 입력 전체를 대표하는 것이라 믿는다.
    • 4단계 : 경계 분석하기
      • 경계, 접점, 거점
      • 내점, 외점
      • 보통 경계를 발견할 때마다 두개의 테스트면 충분하다
    • 5단계 : 테스트 케이스 고안하기
      • 모든 조합의 테스트 케이스를 기획하면 효율성이 현저히 떨어진다.
      • 예외적인 경우 한번만 수행하고 조합하지 않을 수도 있고, 완전히 조합할 필요가 없는 구획도 있다.
    • 6단계 : 테스트 케이스 자동화하기
      • 고안한 테스트의 입, 출력값을 작성해 자동화하기
    • 7단계 : 창의성과 경험을 발휘해서 테스트 스위트를 강화하기
      • 명세 테스트 이후의 다음 단계는 구현을 실행하고 테스트 스위트를 보강하는 일. -> 3장에서
  • 참고
    • 범주-구획법
    • 도메인 테스트 워크북
  • 도메인 지식은 좋은 테스트 케이스를 만들기 위해 중요하다.

2.4 현업에서의 명세 테스트

  • 프로세스는 연속적인 아니라 반복적이어야 한다.
    • 전체 프로세스는 요구사항 분석 - 테스트 - 구현 이 연속적이 아니라 계속 전체를 반복하게된다.
  • 명세 테스트는 어느정도로 수행해야 하는가?
    • 프로그램의 비용이 높은 부분이라면 더 많은 코너 케이스를 탐색하고, 품질을 보장하기 위해 다양한 기술을 시도하는 것이 현명할 수 있다.
  • 구획인가 경계인가는 중요하지 않다.
    • 중요한 것은 테스트 케이스가 도출되고 버그가 프로그램에 스며들지 않는다는 점
  • 접점과 거점으로도 충분하지 만 내점과 외점도 얼마든지 추가하자
    • 추가한 점들로 인해 프로그램을 더 잘 이해하고 실제 입력을 더 잘 나타낼 수 있다.
    • 테스트 스위트를 가볍게 유지하려는 노력은 항상 좋은 생각이지만 몇몇 점들을 추가하는 것은 괜찮다.
  • 이해를 높이기 위해 입력을 변경해서 사용하자
    • 어떤 테스트 케이스가 실패하면 다른 케이스와의 비교를 하기 쉬워진다.
    • 그러나 다양한 입력은 필수
  • 조합의 수가 폭발적으로 증가한다면 실용적이어야 한다
    • 가능한 조합의 수를 줄이도록 한다.
    • 메서드 수준에서 너무 많은 조합이 발생한다면, 메서드를 두 개로 나누는 것을 고려하자.
  • 무엇을 입력할지 모르겠다면 간단한 입력을 넣어보자
    • 현실적이면서도 디버깅에 용이한 간단한 값을 넣어본다.
  • 관심 없는 입력에 대해 합리적인 값을 선택하자
  • 널과 예외 케이스는 의미가 있을 때만 사용한다
    • 테스트를 작성하기 전에 소프트웨어 시스템(혹은 아키텍처)의 전반적인 그림을 이해해야 한다.
    • 아키텍처는 메서드를 호출하기 전에 메서드의 사전 조건을 확인할 수 있다.
    • 요소에 도달하기 전에 검사된 것이 확실하다면 이러한 ㅌ ㅔ스트를 건너뛸 수 있다.
  • 테스트가 동일한 스켈레톤을 갖는 경우 매개변수화 테스트를 사용하자
    • 매개변수화 테스트가 뭐였지? 다시 확인해볼 것
  • 요구사항은 잘게 쪼갤 수 있다
  • 클래스와 상태에 어떻게 동작하는가?
    • 입력 변수 뿐만 아니라 상태 설정도 고려해야할 필요가 있다.

3.구조적 테스트와 코드 커버리지

  • 명세 기반 테스트 -> 소스 코드를 활용해 테스트 스위트를 확장한다.
  • 구조적 테스트
    • 소스 코드의 구조를 사용하여 테스트를 도출하는 것

3.2 구조적 테스트 간략히 살펴보기

  • 명세 기반 테스트
  • 프로세스 (필요한 구간 반복)
    • 구현 사항 읽고 개발자의 주요 결정사항을 이해하기
    • 고안했던 테스트 케이스를 코드 커버리지 도구로 수행(체크)
    • 테스트가 수행되지 않은 코드에 대해
      • 왜 수행되지 않았는지 이해
      • 테스트할 가치가 있는지 결정
      • 자동화된 테스트 케이스를 구현
    • 다른 흥미로운 테스트 찾아보기

3.3 코드 커버리지 기준

  • 코드 줄 커버리지
  • 분기 커버리지
  • 조건 + 분기 커버리지
    • 각 조건을 true|false 로 만족하도록 하는 ㅔㅌ스트를 적어도 하나 만들 것
    • 그리고 전체 분기문을 true|false 로 만족하는 테스트를 적어도 하나 만들 것
    • 모든 경우의 수를 고려하지 않아도됨
  • 경로 커버리지
    • 프로그램이 수행할 수 있는 모든 실행경로를 수행
  • 복잡한 조건과 MC/DC 커버리지 기준
    • 가능한 모든 조건을 테스트 하는 대신 테스트가 필요한 중요한 조합을 찾아낸다
    • 필요한 테스트 개수가 N+1 이다
    • 결국 결과에 독립적으로 영향을 미치는가, 가 중요
  • 반복문과 유사 구조 처리
    • 경계를 고려하여 반복문 테스트가 여러개 생길수도 잇음
    • 보통은 0, 1, 여러번 으로 나눔

3.6 기준 포함과 선택

  • 기준간의 트레이드오프가 필요함
  • 취약한 기준은 비용이 적고 빠르게 수행할 수 있지만, 코드가 수행하지 않는 부분을 남기게 됨
  • 탄탄한 기준은 비용을 많이 들여서 더 엄격하게 코드를 수행할 수 있도록 개발함

3.8 경계 테스트와 구조적 테스트

  • 명세 기반 테스트의 가장 어려운 부분은 경계를 찾는 일인데, 경계는 소스 코드에서 훨씬 찾기 쉽다.
  • ? TDD 를 하게 되면 구조적 테스트를 못하는가?
    • 미리 설계가 되어있어야 하지 않는가?

3.9 구조적 테스트만 적용하는 것은 충분하지 않다.

  • 특이 케이스는 주로 커버리지로 유도되는 순수한 구조적 테스트에서는 얻을 수 없다.
  • 명세에 대한 지식을 더했을 때 그 가치를 드러낸다.

3.10 현업에서의 구조적 테스트

  • 왜 코드 커버리지를 싫어하는 것일까?
    • 구조적 테스트와 코드 커버리지를 사용하는 방법
      • 명세 기반 테스트를 강화하고, 테스트 스위트가 현재 수행하지 않는 코드 부분을 재빨리 찾아내며 명세 기반 테스트를 수행할 때 놓쳤던 구획을 찾을 수 있을 것이다.
    • 반드시 코드 커버리지를 높일 필요는 없지만, 코드 커버리지가 매우 낮다는 것은 시스템이 제대로 테스트되지 않았다는 증거가 될 수 있다.
  • 작가는 어떤 기준을 선호하는가?
    • 얼마아 엄격하고 싶은지, 무엇을 테스트 하는지에 따라 다름.
    • 명세 기반 테스트를 보완하기 위한것이므로 수행하지 않은 부분을 찾을 때 코드 커버리지 적용
    • 분기 커버리지와 조건+분기 커버리지, MC/DC 를 복합적으로 사용하는 것을 선호
  • 무엇을 수행하지 말아야 하나?
    • 커버리지 100 달성? 불가능
    • equal, hash, getter, setter 테스트
    • 예외가 발생하는 것보다 그 외 나머지에서 어떤 일이 일어나는지가 더 중요하다

3.11 돌연변이 테스트

  • 존재하는 코드에 일부러 버그를 주입해서 테스트 스위트가 깨지는지 검사
  • 테스트를 테스트함
  • 가정
    • 유능한 프로그래머 가설
      • 유능한 프로그래머가 작성한 프로그램은 그 구현 버전이 올바르거나 단순 오류의 조합으로 정확한 프로그램이 된다는 가정
    • 커플링 효과
      • 복잡한 버그는 작은 버그들이 모여 발상한다는 가정. 테스트 스위트가 작은 버글르 잡을 수 있으면 더 복잡한 것도 잡을 수 있음
  • 돌연변이 테스트를 실행하기 위한 도구
    • 파이테스트
  • 돌연변이 테스트 도구는 코드를 알지 못한다. 다만 커버리지와 같은 도구로 바라보아야 한다.
  • 시스템에서 좀 더 민감한 부분에 대해 돌연변이 테스트를 적용해보자.

계약 설계

  • 사전 조건, 사후 조건, 불변식을 설계하는 방법
  • 계약과 유효성 검사의 차이점에 대한 이해

4.1 사전조건과 사후 조건

  • 메서드가 제대로 동작하도록 하는 사전조건
    • ex. 간단한 if 문으로 유효하지 않은 값이 통과되지 않도록 한다.
  • 메서드가 산출물로 보장하는 사후조건
    • 무언가 잘못되었다면 예외를 던진다.
  • 단언키워드
    • assert : 작성한바와 같지 않으면 JVM 은 AssertionError 를 던진다.
      • if 대신 사용 가능
  • 강한 조건과 약한 조건
    • 약한 조건과 강한 조건을 쓸지에는 정답은 없다.
    • 약한 조건은 다른 클래스가 이 메서드를 쉽게 호출할 수 있도록한다.
    • 강한 조건은 코드에서 발생할 수 있는 실수의 범위를 줄여준다.

4.2 불변식

  • 사전, 사후 모두의 경우에서 유지되어야 하는 조건
  • 불변식은 메서드 실행 도중 유지되지 않을 수 있다. 하지만 결국 불변식이 유지되도록 보장할 필요가 있다.

4.3 계약 변경과 리스코프 치환 법칙

  • 시스템에 기대하는 동작을 깨뜨리지 ㅇ낳고 자식 클래스를 부모 클래스로 치환할 수 있는 개념을 리스코프 치환법칙이라고 한다.
  • 결국 계약이 변경되었을 때 중요한 것은 이를 사용하고 있는 곳에서의 영향이다.

4.4 계약에 의한 설계가 테스트와 어떤 관련이 있는가?

  • 단언문을 통해 제품 코드에서 버그를 일찍 발견할 수 있다.
  • 사전조건, 사후 조건, 불변식은 개발자에게 테스트 대상을 제공한다.
  • 명시적인 계약은 소비자의 삶을 편안하게 해준다.

4.5 현업에서의 계약에 의한 설계

  • 강한 사전조건 vs. 약한 사전조건
    • 명확한 방법은 없다. 전체 맥락을 고려해서 결정을 내려야 한다.
  • 입력 유효성 검사인가, 계약인가? 아니면 둘 다인가?
    • 유효성 검사와 계약은 서로 다르기 때문에 둘 다 이루어져야한다.
    • 유효성 검사 : 사용자로부터 들어올 수 있는 불량 데이터나 유효하지 않은 데이터가 시스템에 침투하지 않도록 한다.
    • 계약 : 클래스간의 의사소통이 문제없이 일어나도록 한다. 계약 위반이 일어난다면 프로그램은 중지한다.
    • 각 상황에서 가장 나은 바를 결정하기 위해 맥락을 고려하자.
      • 어떤 부분에서는 이미 수행했기 때문에 중복하지 않아도된다.
      • 반면 약간의 중복과 입력 유효성 및 계약 검사를 수행할 때 중복하는 것을 감수해야할 필요도 있다.
  • https://stackoverflow.com/questions/5049163/when-should-i-use-apache-commons-validate-istrue-and-when-should-i-just-use-th/5452329#5452329
    • Validate.isTrue와 'assert'는 완전히 다른 목적을 위해 사용됩니다.
    • assert
      • Java의 assert 문은 일반적으로 메서드가 어떤 상황에서 호출될 수 있는지, 그리고 호출자가 나중에 무엇이 참일 것으로 기대할 수 있는지를 (assertion을 통해) 문서화하는 데 사용됩니다. assertion은 선택적으로 런타임에 검사할 수 있으며, 유지되지 않으면 AssertionError 예외가 발생합니다.
      • 설계 계약 측면에서 어설션은 사전 및 사후 조건과 클래스 불변식을 정의하는 데 사용할 수 있습니다. 런타임에 이러한 조건이 유지되지 않는 것으로 감지되면 이는 시스템의 설계 또는 구현 문제를 나타냅니다.
    • Validate.isTrue
      • org.apache.commons.lang.Validate는 다릅니다. 조건을 확인하고 조건이 충족되지 않으면 "IllegalArgumentException"을 throw하는 간단한 JUnit 유사 메서드 세트를 제공합니다.
      • 일반적으로 공개 API가 잘못된 입력에 대해 관대해야 할 때 사용됩니다. 이 경우 계약은 잘못된 입력에 대해 IllegalArgumentException을 throw하도록 약속할 수 있습니다. Apache Validate는 이를 구현하기 위한 편리한 약어를 제공합니다.
      • IllegalArgumentException이 throw되므로 Apache의 Validate를 사용하여 사후 조건이나 불변식을 확인하는 것은 의미가 없습니다. 마찬가지로, 사용자 입력 검증에 'assert'를 사용하는 것은 잘못된 것입니다. 왜냐하면 어설션 확인은 런타임에 비활성화될 수 있기 때문입니다.
    • 둘 다 사용
      • 하지만, 다른 목적이기는 하지만 동시에 둘 다 사용할 수 있습니다. 이 경우, 계약은 특정 유형의 입력에 대해 IllegalArgumentException이 발생하도록 명시적으로 요구해야 합니다. 그런 다음 Apache Validate를 통해 구현합니다. 그런 다음 불변식과 사후 조건이 간단히 주장되고, 가능한 추가 사전 조건(예: 객체의 상태에 영향을 미침)도 주장됩니다.
public int m(int n) {
  // the class invariant should hold upon entry;
  assert this.invariant() : "The invariant should hold.";

  // a precondition in terms of design-by-contract
  assert this.isInitialized() : "m can only be invoked after initialization.";

  // Implement a tolerant contract ensuring reasonable response upon n <= 0:
  // simply raise an illegal argument exception.
  Validate.isTrue(n > 0, "n should be positive");

  // the actual computation.
  int result = complexMathUnderTrickyCircumstances(n);

  // the postcondition.
  assert result > 0 : "m's result is always greater than 0.";
  assert this.processingDone() : "processingDone state entered after m.";
  assert this.invariant() : "Luckily the invariant still holds as well.";

  return result;
}
  • 더 많은 정보:
    • Bertrand Meyer, "계약에 의한 설계 적용", IEEE Computer, 1992( pdf )
    • Johsua Bloch. Effective Java , 2nd ed., 항목 38. 유효성을 위한 매개변수 확인. ( 구글 도서 )
  • 단언과 예외
    • 둘 중하나를 사용해야 하는 경우
    • 많은 개발자가 확인된 예외나 확인되지 않은 예외를 선호한다.
    • 라이브러리나 유틸리티 클래스에 대한 계약을 모델링한다면, 라이브러리를 따른다.
    • 데이터가 이전 레이어에서 정제되었다는 것을 알고 있다면 단언문을 선호, 그러나 정제되어 있는지 모를 경우 예외를 선택한다.
    • 유효성 검사에 대해서는
      • 더 세련된 방법으로 유효성 검사를 모델링하는 것을 선호
        • 전체 오류 목록을 사용자에게 표시하는 것이 더 일반적
        • 많은 코드가 요구되는 복잡한 유효성 검사를 모델링 할 수 있다.
        • 도메인주도설계(에릭 에반스) : 명세패턴
          • https://studynote.oopy.io/books/13#49d0df11-3b0a-4432-a2ba-ec4c9e4df58e
        • 단언문 사용법(존 레기어)
    • 더 전문적이고 의미있는 예외를 사용하는 것이 좋다.
  • 예외 vs. 부드러운 반환값
  • 계약에 의한 설계를 사용하지 않는 경우
    • 걍 무조건 사용하라.
    • 객체 지향 시스템을 개발하느 ㄴ일은 객체가 제대로 소통하고 협업할 수 있도록 보장하는 것.
    • 테스트로 계약에 의한 설계를 서로 대체할 수 없다.
  • 사전 조건, 사후조건, 불변식에 대한 테스트가 필요한가?
    • 유효성 검사에 대해서 자동 테스트를 작성하는 것을 추천.
    • 단언문에 대해서는 비즈니스 규칙을 다루는 다른 테스트에 의해 자연스럽게 수행됨.
    • https://stackoverflow.com/questions/4995471/cobertura-coverage-and-the-assert-keyword/6486294#6486294
    • 설계 계약 스타일 로 단언을 사용하는 경우 Java 명령문을 실패하게 만드는 테스트를 추가할 필요가 없습니다 assert. 사실, 많은 단언(예: 불변식, 사후 조건)의 경우 실패하게 만드는 객체를 생성할 수도 없으므로 이러한 테스트를 작성하는 것은 불가능합니다 . 그러나 할 수 있는 것은 불변식/사후 조건을 사용하여 경계를 연습하는 테스트 사례를 도출하는 것입니다 (Robert Binder의 불변 경계 패턴 참조). 하지만 이렇게 하면 단언이 실패하지 않습니다.

5. 속성 기반 테스트

  • 예시 기반 테스트와 속성 기반 테스트
  • 예시 기반 테스트
    • 구체적인 예시를 제시해줌.
  • 속성 기반 테스트
    • 우리가 테스트하고자 하는 속성을 표현만 하고, 테스트 프레임워크는 이 속성으로 프로그램을 깨뜨릴 수 있는 반례를 찾으려고 함.
  • 속성 기반 테스트 작성에는 창의성이 필요하다.
    • 임의의 값을 생성해야한다.
    • indexOf 메서드가 그 값을 애매모호하지 않게 찾을 수 있도록 해야한다.
  • 생각해야할 것
    • 내가 최대한 실제 동작과 가깝게 속성을 동작시키는가?
    • 테스트가 모든 구획을 같은 비율로 수행하는가?

5.6 현업에서의 속성 기반 테스트

예시기반 테스트 vs 속성 기반 테스트

  • 명세 기반 테스트와 구조적 테스트를 수행할 때 예시 기반 테스트를 사용한다.
  • 단순하고 자동화에 창의성을 많이 필요로 하지 않는다.
  • 단순하기 때문에 요구사항을 이해하기 쉽고 더 좋은 테스트 케이스를 설계할 수 있다.
  • 테스트가 충분하지만 확신할 수 없을 때 속성 기반 테스트를 사용한다.

주의사항

  • 매우 비용이 많이 들거나 심지어 불가능한 데이터를 생성해야한다.
  • 속성의 경계를 올바로 나타내는지 확인하자.
  • 테스트 대상 메서드에 전달할 입력 데이터가 가능한 모든 옵션 간에 균등하게 분포되어있는지 확인해야한다.

창의성이 핵심이다.

  • 속성을 나타내는 방법을 찾고, 임의의 데이터를 생성하고, 구체적인 입력을 모른채로 예상 동작을 단언하는 일은 쉽지 않다.

요약

  • 속성 기반 테스트는 해당 메서드가 유지해야하는 속성을 무작위로 생성하여 테스트한다.
  • 명세기반 테스트와 구조적 테스트를 대체하지 않는다.
    • 때로는 예시 기반 테스트로도 충분하다.
  • 속성 기반 테스트를 작성하는 것이 조금 더 어렵다. 속성을 표현하기 위해서는 창의적이어야 한다.

테스트 더블과 모의 객체

  • 클래스간 종속성을 너무 신경쓰지 말고 격리된 방식으로 테스트하는 데 초점을 맞춘다.
  • 테스트 대상 클래스를 구체적인 의존성과 함께 테스트 수행하는것은
    • 느리거나
    • 힘들거나
    • 너무 많은 일을 해야한다.
  • 다른 클래스에 의존하는 클래스를 테스트할 때 의존성을 사용하지 않는 방법
    • 테스트 더블
      • 구성요소 B의 동작을 모방하는 객체를 생성하여 테스트 맥락에 따라 B처럼 행동할 수 있도록 한다.
  • 다른 객체의 동작을 시뮬레이션 하는 객체를 사용하면 다음과 같은 장점이 있다.
    • 더 큰 제어권을 가진다.
    • 시뮬레이션은 빠르다.
    • 클래스간의 상호작용을 반영할 수 있다.
  • 목표
    • 다른 단위에 크게 신경쓰지 않고 단윌 단위에 집중하게 한다.

6.1 더미, 페이크, 스텁, 모의 객체, 스파이

  • 더미 객체
    • 테스트 대상 클래스에 전달되지만 절대 사용되지 않는 객체
  • 페이크 객체
    • 시뮬레이션하려는 클래스 같이 실제로 동작하는 구현체를 가진다.
    • 다만 훨씬 단순한 방법으로 동작한다.
    • 예를 들어 실제 DB 대신 인메모리 DB를 사용하는 등
  • 스텁
    • 테스트 과정에서 수행된 호출에 대해 하드 코딩된 응답을 제공한다.
  • 모의 객체
    • 메서드 응답을 설정할 수 있다는 점에서 스텁같은 역할을 하지만
    • 모든 상호작용을 저장해서 나중에 단언문에 활용할 수 있도록 해준다.
  • 스파이
    • 의존성을 감시한다.
    • 실제 객체를 감싸서 그 행동을 관찰한다.
    • 감시하고 있는 근본 객체와의 모든 상호작용을 기록한다.
    6.3 현업에서의 모의 객체
    • 모의 객체를 사용하는 것은 테스트 스위트가 코드가 아니라 모의 객체를 테스트하도록 만드는 것이 아닌가?
      • 대규모 소프트웨어에서는 모의 객체가 클래스의 실제 계약을 표현하지 않을 수 있다는 점에서 모의 객체에 대한 제어권을 잃기 쉽다.
      • 모의 객체가 대규모로 잘 동작하게 하려면 계약을 신경써서 설계해야한다.
    • 계약 변경이 일어난 의존성을 찾아서 테스트가 새로운 계약을 수행하는지 검사하는 것은 개발자가 해야하는 일이다.
      • 이건 모의객체를 사용하지 않더라도 마찬가지이다.
      • 이건 오히려 장점이 아닌가?
    • 테스트가 많이 알고 있으면 테스트 변경이 힘들 수 있다.
      • 테스트를 단순하게 만들지만 테스트와 제품 코드간의 결합도를 증가시킨다.
  • 단점

모의해야하는 대상과 하지 말아야 하는 대상

  • 모의해야할 때
    • 의존성이 너무 느린 경우 : 데이터베이스, 웹서비스 등
    • 의존성이 외부인프라와 통신하는 경우
    • 의존성을 시뮬레이션하기 힘든 경우
  • 모의하지 않아야 할때
    • 엔티티
      • 공수가 더 많이 듬
    • 네이티브 라이브러리와 유틸리티 메서드
    • 충분히 단순한 의존성

날짜 및 시간 래퍼

  • 모의할 수 있는 방법 알아두기

소유하지 않은 것을 모의하기

  • 라이브러리와의 모든 상호작용을 캡슐화하는 추상화 객체 생성할 수 있다.
  • 추상화 자체는 통합 테스트해야한다.

모의에 관한 외부 의견

  • 테스트 더블을 사용하려면 시스템 테스트 가능성을 가지도록 설계해야 한다.
  • 실제 구현에 충실하게 테스트 더블ㅇ르 구축하는 것은 어렵다. 하지만 가능한 그렇게 해야한다.
  • 고립성보다 현실성이 낫다.
    • 가능하다면 페이크나 스텁, 모의 객체보다 실제 구현을 선택하도록 하자.
  • 실제 구현을 사용하는 일이 불가능하거나 너무 비용이 많이 든다면 모의 객체보다 페이크를 사용한다.
  • 모의를 너무 많이 사용하면 위험해질 수 있다.
    • 테스트가 이해하기 어려워지고
    • 깨지기 쉽고
    • 덜 효과적이다.
  • 모의할 때는 상호작용 테스트보다 상태 테스트가 낫다.
  • 너무 구체화된 상호작용 테스트는 피하자.
    • 인수 및 기능 테스트에 초점을 두자.
  • 좋은 상호작용 테스트를 작성하려면 테스트 대상 시스템을 설계할 때 엄격한 지침이 필요하다.

테스트 가능성을 위한 설계

모든 소프트웨어 시스템을 테스트할 수 있지만, 어떤 시스템에서는 테스트하기 힘들다.

소프트웨어 시스템은 때때로 테스트를 할 수 있도록 설계되어 있지 않다.

테스트 가능성 이라는 말은 테스트 대상 시스템이나 클래스, 메서드에 대해 자동 테스트를 얼마나 쉽게 작성할 수 있는지를 말한다.

테스트 가능성을 위한 설계는 체계적인 테스트를 수행하기 위한 핵심사항이다. 코드가 테스트하기 어려우면 테스트를 하지 않으려고 할 것 이다. 테스트 가능성을 위한 설계는 언제 진행해야 할까? 테스트 가능성을 생각해야 하는 적당한 때는 언제일가? 항상 고려해야 한다.

1. 도메인 코드에서 인프라 코드 분리

도메인 코드에서 인프라 코드 분리하기.

package com.likelen.openapi;

import java.sql.*;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

class InvoiceFilter {

    private List<Invoice> all() {
        try {
            Connection connection = DriverManager.getConnection("db", "root", "");
            PreparedStatement ps = connection.prepareStatement("select * from invoice");
            ResultSet rs = ps.executeQuery();

            List<Invoice> allInvoices = new ArrayList<>();
            while (rs.next()) {
                allInvoices.add(new Invoice(rs.getString("name"), rs.getInt("value")));
            }
            ps.close();
            connection.close();
            return allInvoices;
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }

    }

    public List<Invoice> lowValueInvoices() {
        List<Invoice> issuedInvoices = all();
        return issuedInvoices.stream().filter(invoice -> invoice.value < 100).collect(Collectors.toList());
    }

    private class Invoice {

        private final String name;
        private final int value;

        public Invoice(String name, int value) {

            this.name = name;
            this.value = value;
        }
    }
}

문제점은

  1. 도메인 코드와 인프라 코드가 뒤섞여 있다. lowValueInvoices호출 시 DB 접속을 피할 수 없다. public 메서드를 수행하면서 어떻게 private 메서드를 스텁으로 만들 수 있을까? DB를 다루는 부분은 스텁으로 만들 수 없다.
  2. 책임이 클수록 더 복잡해지고 버그가 발생할 가능성이 증가한다. 덜 응집된 클래스는 코드양이 많다. 코드양이 많다는 것은 버그가 발생할 확률이 크다는 뜻.

이 책에서는 핵사고날 아키텍쳐를 언급하는데, 이 부분에 대해서 논쟁이 있다. 인터페이스를 무조건 만들어야 하는가?

'모든 포트에 대해 인터페이스를 만들어야 하나요?' 옭고 그른 것은 없고, 모든 것은 상황에 따라 다르며, 실용성이 관건이라는 것을 납득시키고자 한다. 소프트웨어 시스템의 모든 것에 대해 인터페이스를 생성할 필요는 없다. 필자는 구현체가 두 개 이상이 되는 포트에 대해서는 인터페이스를 만든다. 또한 추상적 행위를 표현하는 인터페이스를 만들지 않을 때는, 구체적인 구현에서 구현 세부사항이 유출되지 않도록 한다. 언제나 문맥에 따라 판단하는 실용주의가 최고의 방법이다.

2. 의존성 주입과 제어 가능성

클래스 수준에서 필자는 클래스를 완전히 제어할 수 있고(즉 테스트 대상 클래스의 행위를 쉽게 제어할 수 있고), 관찰할 수 있도록(테스트 대상 클래스에서 무슨 일이 일어나는지 알 수 있고 출력 결과를 검사할 수 있도록)해야 한다는 것.a

제어 가능성은 일반적으로 테스트 스위트(모의 객체, 페이크, 스텝)을 활용한다.

의존성 주입의 경우, 생성자를 통해서 주입하는 것을 설명한다.

핵사고날 아키텍쳐의 포트는 의존성 역전 원칙을 도입한 것인데, 다음과 같은 공식화를 가진다.

  • (비지니스 클래스 같은) 고수준 모듈은 저수준 모듈에 의존해서는 안 된다. 이 둘 모두는(인터페이스 같은)추상화에 의존해야 한다.
  • 추상화는 세부사항에 의존하면 안 된다. 세부사항(구체적인 사항)은 추상황에 의존해야 한다.

우리의 코드는 언제나 가능한 한 추상황에 의존해야 하고 세부사항에 거의 의존하지 않도록 해야 한다. 이 패턴의 이점은 추상화가 저수준의 세부사항보다 덜 취약하고 변경하기 쉽다는 점. 그러나, 모든 것을 인터페이스로 만드는 것은 폼이 많이 든다.

3. 클래스 및 메서드를 관찰 가능하게 하기

클래스 수준에서 관찰 가능성은 기능이 기대했던 대로 동작하는지를 얼마나 쉽게 단언할 수 있는가에 관한 것.

  1. 예제1 - 단언을 보조하는 메서드 도입여기서 isReadyForDelivery 메서드는 테스트 코드 작성시 단언을 보조해주는 역할을 한다.
  2. public class ShoppingCart { private boolean readyForDelivery = false; // 장바구니에 대한 정ㅂ public void markAsReadyForDelivery(Calendar estimatedDayOfDelivery) { this.readyForDelivery = true; // ... } public boolean isReadyForDelivery(){ return readyForDelivery } }
  3. 예제2 - void 메서드의 행위를 관찰하기generateInstallments 메서드를 어떻게 테스트할 수 있을까? 모키토를 잘 알고 있다면 모의 객체에 전달된 모든 인스턴스를 얻는 방법(argumentCaptor)을 사용하는 것이다. '테스트 도중에 전달된 모든 인스턴스를 돌려줄래?'또는 리스트를 반환하게 해서 테스트 코드를 변경할 수도 있다.
  4. 핵심은 실용주의이다. 테스트 가능성을 개선해주는 작은 설계 변경은 괜찮다는 점만 기억하자. 가끔은 변경사항이 코드 설계를 망치지 않을지 판단하기 어려울 수 있다. 시도해보고 마음에 안들면 폐기하자.
  5. public class InstallmentGeneratorTest { @Mock private InstallmentRepository repository; @Test void checkInstallments() { InstallmentGenerator generator = new InstallmentGenerator(repository); ShoppingCart cart = new ShoppingCart(100.0); generator.generateInstallments(cart,10); ArgumentCaptor<Installment> captor = ArgumentCaptor.forClass(Installment.class); verify(repository, times(10)).persist(captor.capture()); List<Installment> allInstallments = captor.getAllValues(); } }
  6. public class InstallmentGenerator { private InstallmentRepository repo; public void generateInstallments(ShoppingCart cart, int numberOfInstallments) { ... } }

4. 의존성 전달 방법: 클래스생성자와 메서드 매개변수

5. 현업에서의 테스트 가능성 설계

테스트에 귀를 기울이면, 테스트하려는 코드의 설계에 대한 힌트를 얻을 수 있다.

클래스를 훌륭하게 설계하는 일은 객체 지향 시스템에서 어려운 작업. 도움을 더 얻을수록 너 나은 설계를 할 수 있다.

"테스트가 코드 설계에 대해 피드백을 제공한다"

  1. 테스트는 테스트 대상 클래스의 인스턴스를 생성.
  2. 테스트는 테스트 대상 메서드를 호출
  3. 테스트는 메서드가 기대한 대로 동작했는지 단언

5.1 테스트 대상 클래스의 응집도

응집도란 아키텍쳐상의 모듈, 클래스, 메서드 또는 어떤 요소든지 단 하나의 책임을 가지는 것을 뜻한다. 단일 책임을 결정하는 것은 문맥에 따라 달라진다.

테스트 코드를 작성하면서 약간의 징후가 있을 수 있는 부분은 다음과 같았다.

  • 응집력이 없는 클래스에 대한 테스트 스위트는 거대하다.
  • 응집력이 없는 클래스는 크기가 커지는 일을 멈추지 않는다.
    • 계속해서 크기가 커지는 클래스는 SOLID 지침에 있는 단일 책임 원칙/개방 폐쇄 원칙을 모두 어긴다.

5.2 테스트 대상 클래스의 결합

응집력 있는 클래스를 사용하면, 여러 클래스를 조합해서 큰 행위를 구성한다. 하지만 이렇게 하면 결합도가 높은 설계를 하게 될 수 있다. 과도한 결합은 진화를 해칠 수 있다.

테스트 코드를 통해 결합도가 높은 클래스를 발견할 수 있다.

  • 만약 제품 코드의 클래스를 테스트할 때 수많은 의존성 인스턴스가 필요하다면 이것은 나쁜 징후일 수 있다. 클래스 재설계를 고려하자. 다양한 리팩터링 전략을 사용할 수 있다. 아마 클래스가 구현하고 있는 큰 행위를 두 단계로 나눌 수 있을 것이다.
  • ATest클래스에서 어떤 테스트(A 행위에 대한 테스트)가 실패했는데 디버깅 해보니 클래스 B의 문제를 발견하는 경우다. 이 클래스들이 어떻게 결합되어 있고, 어떻게 상호작용하는지, 그리고 그러한 설계 오류를 시스템의 다음 버전에서 예방할 수 있는지 재확인

5.3 복잡한 조건과 테스트 가능성

복잡한 조건들을 여러 개의 작은 조건들로 분할하는 방식으로 복잡성을 감소시키는 방법은 문제의 전체적인 복잡성을 감소시키지는 않겠지만 적어도 확산시키지는 않을 것. Specification 과 Condition 고민하기

5.4 private 메서드와 테스트 가능성

만약 private 메서드를 테스트하고 싶다는 마음이 생긴다면, 이것은 트세트가 우리에게 무엇간를 알려주는 좋은 예시다. 설계 관점에서 private 메서드가 현재 위치에 있어서는 안 된다는 뜻일 수 있다.

5.5 정적 메서드, 싱글톤, 테스트 가능성

정적 메서드는 테스트 가능성에 악영향을 미친다. 따라서 가능하면 정적 메서드를 만들지 않는 것이 좋다. 만약 LocalDate 클래스에서 수행한 것과 같이 추상화를 그 위에 추가하는 방식으로 테스트 가능성이 높이는게 좋다.

![image-20240128234452956](/Users/len/Library/Application Support/typora-user-images/image-20240128234452956.png)


테스트 가능성은 단순히 “테스트 코드가 잘 돌아가게 하는 기술”이 아니라, 좋은 설계의 결과물이다. 테스트가 잘 되도록 설계하는 과정은, 클래스의 책임을 나누고, 의존성을 정리하고, 동작을 명확하게 만드는 방향으로 우리를 이끈다.

좋은 설계는 테스트를 쉽게 만든다.
테스트하기 쉬운 코드는 유지보수도 쉬운 코드다.

  • 테스트가 힘들면 리팩토링 신호
    • 테스트 작성에 if/loop/mock이 너무 많아지면 SRP 위반 의심
    • void 함수는 테스트를 고려해서 이벤트/콜백/리턴값 설계 고민

테스트 주도 개발

전통적인 개발 방법론은. 먼저 구현을 한다. 그러고 나서 구현을 한 뒤에 꼭 테스트를 한다. 반대는 하지 않는다.

왜일까?

테스트 주도 개발은 '코드를 약간 작성하고 테스트한다' 라는 기존의 코딩 방식에 도전한다.

1. 첫번째 TDD 세션

TDD의 예를 들어 로마 숫자를 정수형으로 바꾸는 프로그램이라 가정해보자.

  • I, unus, 1
  • V, quinque, 5
  • X, decem, 10
  • L quiquaginta, 50
  • C, cemtum, 100

여기에는 두 가지 규칙이 있다.

  • 오른쪽에 있는 숫자가 더 작거나 같은 값을 가지면 더 높은 값을 가진 숫자에 더한다.
  • 왼쪽에 있는 숫자가 더 작은 값을 가지면 더 높은 값을 가진 숫자에서 차감한다.

예를 들어 XV는 15(10+ 5). XXIV는 24(10 + 10 - 1 + 5)

먼저 예시를 만드는 일은 TDD의 일부이다

  • 단순하게 단일 문자를 사용한 경우
  • 숫자가 여러 문자로 이루어진 경우(뺄셈 규칙 사용 X)
  • 간단한 뺄셈 규칙 사용
  • 숫자가 여러 문자로 이루어져 있고, 뺄셈 규칙을 사용하는 경우
    • 입력값: "XIV", 기댓값: 14
    • 입력값: "XXIX", 기댓값: 29

과정은?

  1. 우리가 만든 예시 목록에서 가장 간단한 예를 선택
  2. 주어진 입력과 기댓값에 대한 단언문으로 프로그램을 수행하는 자동 테스트 작성. 이 시점에서 코드는 심지어 컴파일되지 않을 수도 있다. 테스트를 실행하면 실패할 것. 기능을 아직 구현하지 않았기 때문
  3. 테스트를 통과할 수 있을 만큼 제품 코드 작성
  4. 작업을 멈추고 지금까지 수행한 작업 확인. 제품 코드 개선. 테스트 코드 개선. 목록에 예시 추가
  5. 이를 반복.
package io.agistep.contractual;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import java.util.HashMap;
import java.util.Map;

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

class RomanNumberConverterTest {

    @Test
    void shouldUnderstandSymbolTest() {
        RomanNumberConverter roman = new RomanNumberConverter();
        int number = roman.convert("I");
        assertThat(number).isEqualTo(1);
    }

    @Test
    void shouldUnderstandSymbol_V_Test() {
        RomanNumberConverter roman = new RomanNumberConverter();
        int number = roman.convert("V");
        assertThat(number).isEqualTo(5);
    }

    @ParameterizedTest
    @CsvSource({"I,1", "V,5", "X,10", "C, 100", "D, 500", "M, 1000"})
    void parmasTest(String num, int number) {
        RomanNumberConverter roman = new RomanNumberConverter();
        int cNumber = roman.convert(num);
        assertThat(number).isEqualTo(cNumber);
    }

    @Test
    void 여러문자로_이뤄진_로마숫자() {
        RomanNumberConverter roman = new RomanNumberConverter();
        int xxx = roman.convert("II");
        assertThat(xxx).isEqualTo(2);
    }

    private class RomanNumberConverter {

        private static final Map<String, Integer> table = new HashMap<>() {{
            put("I", 1);
            put("V", 5);
            put("X", 10);
            put("L", 50);
            put("C", 100);
            put("D", 500);
            put("M", 1000);
        }};

        public int convert(String i) {
            int finalNumber = 0;

            for (int j = 0; j < i.length(); j++) {
                String key = i.charAt(j) + "";
                finalNumber += table.get(key);
            }
            return finalNumber;
        }
    }
}

2. TDD 에 대한 고찰

  1. 구현하고자 하는 기능 조각에 대한 (단위)테스트를 작성. 테스트 실패
  2. 기능을 구현. 테스트 통과
  3. 제품 코드와 테스트 코드 리팩터링

이 TDD 프로세스를 빨강-초록-리팩터 주기

TDD 실무자들은 이 접근법이 개발 과정에서 다음과 같은 이점이 있다고 말한다.

  • 요구사항을 먼저 살펴본다.
    • 기본적으로 요구사항을 수행.
    • 테스트 코드를 작성할 때마다 프로그램이 해야할 일과 하지 말아야 할 일을 반영
  • 제품 코드 작성 속도를 완전히 제어한다.
    • 풀고자 하는 문제를 잘 알고 있으면 더 복잡한 테스트 케이스를 한 번에 작성할 수 있다.하지만 문제를 어떻게 해결해야 할지 확실하지 않다면 작은 부분으로 나누어서 간단한 부분에 대해 먼저 테스트 작성
  • 피드백이 빠르다.
    • TDD 주기로 일하지 않는 개발자들은 큰 덩어리의 제품 코드를 만들고 나서야 피드백을 얻음. TDD 주기를 사용하면 개발자들은 한 번에 하나씩 수행한다. 테스트를 하나 작성하고, 이를 통과시키고, 다시 고찰. 고찰을 통해 발생할 수 있는 새로운 문제를 쉽게 발견. 이전 주기에서 모든 것을 통제하고 있고, 그 이후로 적은 양의 코드를 작성했기 때문
  • 테스트 가능한 코드를 작성한다.
    • 테스트를 먼저 작성하면 제품 코드를 구현하기 전에 처음부터 테스트를 할 수 있는 방법 고민. 기존의 개발 흐름에서는 종종 기능 개발 단계 이후가 되어서야 테스틀 고민. 이 시점에서 테스트를 보조하도록 코드를 바꾸려면 비용이 많이 든다.
  • 설계에 대한 피드백을 얻을 수 있다.
    • 테스트 코드를 종종 개발 중인 클래스나 구성요소의 첫번째 클라이언트.
    https://www.jamesshore.com/v2/projects/lets-play-tdd

3. 현업에서의 TDD

  1. TDD인가 아닌가?
    • TDD 를 하지 않아도 동일한 이점을 누릴 수 있다고 한다. 그러나 필자는 TDD가 주는 리듬에 감사하다고 말한다. 다음으로 개발할 가장 간단한 기능을 찾고, 그에 맞는 테스트를 작성하고, 필요한 만큼만 구현하고, 작업 내용을 고찰하는 일은 필자의 개발 속도에 따라 조절할 수 있다. TDD 는 혼란과 좌절의 무한 루프에서 빠져나오도록 해준다.
    • 클래스를 설계하는 일은 어려운 편에 속하는데, TDD 덕분에 개발 초기부터 코드를 수행해볼 수 있다.
  2. 항상 TDD를 사용해야 할까?
    • 실용적 으로 '아니다' 개발할 기능을 내가 얼마나 알아야 할지에 따라 다르다.
      • 설계나 아키텍쳐, 특정 요구사항에 대한 구현 방법이 명확하지 않을 때 TDD를 사용한다. 이러한 경우에는 조금 천천히 진행하면서 여러 가능성을 실험하는 것이 좋다. 잘 알고 있는 문제를 작업하고 있다면 문제 해결 방안을 이미 알고 있으므로 몇몇 단계 생략
      • 필자는 복잡한 문제를 다루거나 그 문제에 관한 전문성이 부족할 때 TDD 사용. 구현 난이도가 있는 경우에 TDD를 적용하면 한 걸음 뒤로 물러서서 요구사항을 학습하고 작은 테스트부터 작성하게 해준다.
      • 개발 과정에서 배울만한 게 없으면 TDD를 사용하지 않는다. 이미 어떤 문제와 그 해결책을 잘 알고 있다면 편안하게 해결 방안을 바로 코딩한다.
    • **TDD는 설계관점(원하는 대로 구조화되었는가)에서뿐만 아니라 구현 관점(코드가 필요한 일을 하고 있는가)**에서 작성 중인 코드를 배울 수 있도록 해준다. 하지만 일부 복잡한 기능에 대해서는 어떤 테스트를 먼저 작성할지 결정하기 어렵다. 이 경우 TDD를 사용하지 않는다.

우리는 멈춰 서서 지금 무엇을 하고 있는지 생각할 수 있는 도구가 필요하다.

TDD는 그 목적을 위한 완벽한 접근 방식이지만, 유일한 방법은 아니다. 언제 TDD를 사용할지 결정하는 일은 경험이 필요하다.


  • 실패하는 테스트가 말해주는 것
    • 실패하는 테스트는 그 자체로 학습 도구가 될 수 있어. 왜 실패했는지를 파고들다 보면 숨겨진 문제를 미리 발견함.
    • 특히 병렬성, 동기화, 캐싱, 외부 API 등 복잡한 로직을 다룰 땐 **“테스트 실패 패턴 분석”**이 좋은 디버깅 루틴이 돼.

9. 대규모 테스트 작성

9.1 언제 대규모 테스트를 고려할까?

다음과 같은 상황에서는 대규모 테스트를 작성하는 것이 도움이 된다:

  • 각 클래스에 대한 단위 테스트는 존재하지만, 여러 컴포넌트가 함께 동작하는 흐름을 검증할 테스트가 없을 때
  • 테스트하려는 클래스가 플러그 앤 플레이 구조로, 다양한 구성 요소와의 통합이 중요한 경우

이처럼 복수의 요소가 유기적으로 작동하는 시나리오를 확인하려면, 대규모 테스트가 필요해진다.


9.2 DB와 연동된 테스트를 위한 준비

데이터베이스와 통합하여 테스트하려면, 다음과 같은 인프라를 준비해두는 것이 좋다:

  1. 전용 DB 연결 클래스 구성
    테스트 전용 DB에 연결하고 트랜잭션 처리나 초기화, 정리 등을 담당할 클래스를 만든다.
  2. 테스트용 데이터 생성기 작성
    매번 반복해서 데이터를 입력하지 않도록 도와주는 생성기를 준비하되, 불필요한 데이터를 만들지 않도록 최소화한다.
  3. 단언 도우미 함수 정의
    assert 문이 복잡해지는 경우엔 이를 감싸는 헬퍼 함수를 만들어, 테스트 코드를 더 읽기 쉽고 재사용 가능하게 만든다.

이러한 인프라를 잘 갖추면, DB 연동 테스트도 복잡하지 않게 작성할 수 있다.


9.3 시스템 테스트는 언제 필요할까?

시스템 테스트는 애플리케이션 전체의 흐름이 제대로 동작하는지를 검증하는 데 쓰인다.
예를 들어, 회원가입 전체 흐름을 테스트할 때는 다음과 같이 진행할 수 있다:

  • 셀레늄(Selenium) 등의 도구를 사용해 브라우저를 자동으로 띄우고
  • 입력 → 버튼 클릭 → 결과 확인의 전 과정을 자동화

이처럼 시스템 테스트는 실제 사용자 관점에서 흐름을 검증하는 데 효과적이다.


9.4 대규모 테스트는 얼마나, 어디까지?

  • 가능한 로직은 단위 테스트로 커버하는 것이 기본
  • 핵심 시나리오나 비즈니스 흐름은 대규모 테스트로 보완
  • 단, 대규모 테스트는 실행 시간이나 유지 비용이 크기 때문에 적절한 선에서 선택적으로 작성하는 것이 중요하다

핵심은 ‘테스트 인프라’

통합/시스템 테스트의 품질과 유지 보수성을 높이려면 다음이 중요하다:

  • 테스트 환경 구축을 자동화하거나 간소화
  • 반복되는 코드 최소화
  • 누구나 쉽게 테스트를 작성할 수 있도록 유틸성 도구와 프레임워크를 마련

결국, 잘 설계된 테스트 인프라가 대규모 테스트의 성공을 결정짓는다.


10. 테스트 코드 품질

좋은 테스트는 단순히 "돌아가는 코드"가 아니라, 읽기 쉽고, 쉽게 고칠 수 있는 코드여야 한다.
이 장에서는 테스트 코드의 품질을 높이기 위해 지켜야 할 원칙들과, 피해야 할 테스트 냄새들을 정리해본다.


10.1 좋은 테스트를 위한 원칙

- 테스트는 빠르게 실행되어야 한다

느린 테스트는 개발자의 실행 빈도를 떨어뜨린다.
가능하면 Mock 등을 활용해 빠르게 만들고, 느린 테스트는 따로 묶어 관리하는 것도 방법이다.

- 테스트는 응집력 있고 독립적이어야 한다

하나의 테스트는 하나의 행위만 검증하도록 하고, 실행 순서나 다른 테스트에 영향을 받지 않도록 작성한다.

- 테스트는 명확한 목적을 가져야 한다

"그냥 만들어둔 테스트"는 나중에 오히려 방해가 될 수 있다.
어떤 행위나 로직을 보호하기 위한 목적이 명확한 테스트만 유지하자.

- 테스트는 반복 가능하고 안정적이어야 한다

실행할 때마다 결과가 바뀌는 테스트는 신뢰를 잃게 만든다.
환경에 따라 실패하는 테스트는 먼저 의심해야 할 대상이다.

- 단언문(Assert)은 확실하고 구체적이어야 한다

테스트 결과를 검증하는 핵심이다. assert가 애매하면, 실패했을 때 원인을 알기 어렵다.

- 행위가 바뀌면 테스트가 깨져야 한다

테스트가 깨졌다는 건 코드 변화가 테스트에 반영되었다는 뜻이다.
오히려 테스트가 멀쩡하다면, 보호받지 못하는 코드가 있다는 신호일 수 있다.

- 테스트는 명확한 이유 하나로 실패해야 한다

한 테스트가 실패하면, 무엇 때문에 실패했는지 바로 파악 가능해야 한다.
여러 책임이 얽혀 있다면 테스트를 나누자.

- 테스트는 작성하기 쉬워야 한다

단위 테스트는 쉬워도, 통합 테스트는 인프라가 필요하다.
복잡한 시나리오도 쉽게 작성할 수 있도록 테스트 환경을 잘 구축하자.

- 테스트는 읽기 쉬워야 한다

이해하기 어려운 테스트는 유지보수하기 어렵다.
테스트는 코드를 설명해주는 문서이기도 하다.
의도가 잘 드러나는 이름과 구조를 지키자.

- 테스트는 유연하게 진화할 수 있어야 한다

제품 코드가 바뀌면 테스트도 바뀌어야 한다.
리팩터링과 캡슐화를 통해 테스트도 변화에 유연하게 대응할 수 있어야 한다.


10.2 피해야 할 테스트 냄새

테스트도 일반 코드처럼 안티패턴이나 냄새가 존재한다.
다음과 같은 냄새가 있다면 리팩터링을 고민해보자.

- 과도한 중복

여러 테스트에서 비슷한 코드가 반복된다면, 공통 설정이나 헬퍼 메서드로 추출하자.
테스트도 리팩터링의 대상이다.

- 불분명한 단언문

assertThat(...).isTrue()처럼 결과가 모호한 단언은 실패 원인을 찾기 어렵게 만든다.
무엇을 검증하는지 명확히 표현하자.

- 외부 자원에 대한 의존

네트워크, 파일 시스템, 데이터베이스 등 외부 환경에 의존하면 테스트가 불안정해진다.
가능하다면 Stub이나 Mock을 사용하거나, 테스트 전용 환경을 따로 구성하자.

- 지나치게 범용적인 픽스처

테스트 데이터를 너무 일반적으로 만들면, 각 테스트의 목적이 흐려진다.
필요한 만큼만 구성하고, 테스트에 맞는 픽스처를 명확히 나누자.

 

 

저작자표시 비영리 동일조건 (새창열림)
'💪 Practice' 카테고리의 다른 글
  • [추천시스템] 협업 필터링(Collaborative Filtering, CF)
  • Test: Test Container 도입 이후 겪은 문제들 😱 (느려진 테스트, 동시성 문제 등 해결하기 at: Kotlin Exposed + Kotest)
  • 💪우아한 종료Graceful shutdown을 위하여
  • 테스트 코드 작성 시 주의해야 하는 것들
Iirin
Iirin
별건 없고요, 조금씩 했던 것을 쌓아가고 있습니다. 이 블로그는 호기심과 재미로 추동됩니다 🚀
  • Iirin
    ✨ iirin.context
    Iirin
  • 전체
    오늘
    어제
    • ALL
      • 👩🏻‍💻 Computer Science
      • 💻 Operating System
      • ⚡️ Network
      • ☁️ Infra
      • 🥞 Database
      • 👽 Languages
        • ☕️ Java
        • ✔ Kotlin
      • ⚙️ Frameworks, Libraries
        • 🌱 Spring
      • 🧩 Algorithm
      • 🪲 bugs
      • 💪 Practice
      • 💬 Smalltalk
      • 🔧 Tools
  • 블로그 메뉴

    • 링크

      • github
      • github page
    • 공지사항

    • 인기 글

    • 태그

      회고
      진법연산
      Error
      log
      OS
      secondhand
      알고리즘
      Algorithm
      mysql
      test_container
      coroutine
      spring
      논리적주소
      leetcode
      redis
      top-interview-150
      cs
      운영체제
      SpringBoot
      참조투명성
      BUG
      cache
      주소바인딩
      원격SSH
      주간회고
      test
      진법
      Kotlin
      JPA
      Java
    • 최근 댓글

    • 최근 글

    • hELLO· Designed By정상우.v4.10.0
    Iirin
    이펙티브 소프트웨어 테스팅 정리하기
    상단으로

    티스토리툴바