본문 바로가기
책 소개

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

by Devsong26 2024. 6. 25.
반응형

TDD 이전의 개발

  • 만들 기능에 대해 설계와 구현에 대해서 고민
  • 기능에 대한 구현을 완료한 것 같으면 기능을 테스트

 

TDD란?

  • 기능을 검증하는 테스트 코드를 먼저 작성하고 테스트를 통과시키기 위해 개발을 진행
    • 테스트를 먼저 한다는 것은 기능이 올바르게 동작하는지 검증하는 테스트 코드를 작성한다는 것을 의미
  • TDD를 접할 때는 작은 단계를 차근차근 밟아 나가야 함

 

TDD 흐름

  • 기능을 검증하는 테스트를 먼저 작성
  • 테스트를 통과하지 못하면 테스트를 통과할 만큼만 코드를 작성
  • 테스트 통과 후 개선할 코드가 있으면 리팩터링
  • 테스트 코드를 만들면 다음 개발 범위가 정해짐
  • 테스트 코드가 추가되면서 검증하는 범위가 넓어질수록 구현도 점진적으로 완성
  • TDD의 장점은 코드 수정에 대한 빠른 피드백이며 잘못된 코드가 배포되는 것을 방지

 

테스트 코드 작성 순서

  • 쉬운 경우에서 어려운 경우로 진행
  • 예외적인 경우에서 정상적인 경우로 진행
  • 가장 쉬운 경우부터 시작해야 빠르게 테스트를 통과시킬 수 있음
  • 한 번에 구현하는 시간이 짧아지면 디버깅하는 시간도 짧아짐
  • 예외 상황을 먼저 찾고 테스트에 반영하면 예외 상황을 처리하지 않아 발생하는 버그를 줄여줌
  • TDD 학습 시 도움이 되는 단계
    • 정해진 값을 리턴
    • 값 비교를 이용해서 정해진 값을 리턴
    • 다양한 테스트를 추가하면서 구현을 일반화
  • 테스트 시작이 안될 때는 검증문부터 작성하면 도움이 됨 

 

TDD 기능 명세 설계

  • 설계는 기능 명세로부터 시작
  • 기능 명세를 구체화하는 동안 입력과 결과를 도출하고 코드에 반영
  • 입력과 결과를 코드에 반영하는 과정에서 기능의 이름, 파라미터, 리턴 타입 등이 결정

  • 타입의 이름을 정의하고, 타입이 제공할 기능을 결정하는 것은 기본적인 설계 행위
  • TDD 자체가 설계는 아니지만 테스트 코드를 작성하는 과정에서 일부 설계를 진행하게 됨
  • TDD로 개발을 진행하면 현시점에서 테스트를 통과시키는데 필요한 만큼의 코드만 구현하며 유연한 설계는 필요한 시점에 추가하는 것
  • 기능 명세가 모호한 상황이면 구체적인 예로 바꾸어 테스트 코드에 반영해야 함

 

Junit 5 기초

  • 구성
    • JUnit 플랫폼: 테스팅 프레임워크를 구동하기 위한 런처와 테스트 엔진을 위한 API를 제공
    • JUnit 주피터(Jupiter): JUnit 5를 위한 테스트 API와 실행 엔진을 제공
    • JUnit 빈티지(Vintage): JUnit 3과 4로 작성된 테스트를 JUnit5 플랫폼에서 실행하기 위한 모듈을 제공
  • JUnit 기본 구조
    • 테스트를 진행할 메서드에 @Test 애노테이션을 붙이며 private이면 안 됨
    • 클래스 명의 접미사에 'Test' 접미사를 붙임
  • 주요 단언 메서드
    • assertEquals(expected, actual)
      • 실제 값(actual)이 기대하는 값(expected)과 같은지 검사
    • assertNotNull(unexpected, actual)
      • 실제 값(actual)이 특정 값(unexpected)과 같지 않은지 검사
    • assertSame(Object expected, Object actual)
      • 두 객체가 동일한 객체인지 검사
    • assertNotSame(Object unexpected, Object actual)
      • 두 객체가 동일하지 않은 객체인지 검사
    • assertTrue(boolean condition)
      • 값이 true인지 검사
    • assertFalse(boolean condition)
      • 값이 false인지 검사
    • assertNull(Object actual)
      • 값이 null인지 검사
    • assertNotNull(Object actual)
      • 값이 null이 아닌지 검사
    • fail()
      • 테스트를 실패 처리
    • assertThrows(Class<T> expectedType, Executable executable)
      • executable을 실행한 결과로 지정한 타입의 익셉션이 발생하는지 검사
    • assertDoesNotThrow(Executable executable)
      • executable을 실행한 결과로 익셉션이 발생하지 않는지 검사
  • 테스트 라이프사이클
    • JUnit은 각 테스트 메서드마다 다음 순서대로 코드를 실행
      • 테스트 메서드를 포함한 객체 생성
      • (존재하면) @BeforeEach 애노테이션이 붙은 메서드 실행
      • @Test 애노테이션이 붙은 메서드 실행
      • (존재하면) @AfterEach 애노테이션이 붙은 메서드 실행
    • @BeforeEach는 테스트를 실행하는데 필요한 준비 작업을 할 때 사용
    • @AfterEach는 테스트를 실행한 후에 정리할 것이 있을 때 사용
    • @BeforeAll은 클래스의 모든 테스트 메서드를 실행하기 전에 한 번 실행
    • @AfterAll은 클래스의 모든 테스트 메서드를 실행한 뒤에 실행
    • 각 테스트는 상호 독립적이어야 하고 실행 순서에 의존하지 않으며 필드를 공유하면 안 됨
    • @DisplayName은 테스트의 표시 이름을 붙임
    • @Disable은 테스트 실행 대상에서 제외시킬 때 사용

 

테스트 코드의 구성

  • 기능은 상황에 따라 결과가 달라짐
  • 어떤 상황이 주어지고, 그 상황에서 기능을 실행하고, 실행한 결과를 확인하는 세 가지가 테스트 코드의 기본 골격
  • 실행 결과가 항상 리턴 값으로 존재하지 않는 경우도 있음 (예: 예외 발생)
  • 상황과 결과에 영향을 주는 외부 요인은 파일, DBMS, 외부 서버 등 다양하며 외부 환경을 테스트에 맞게 구성하는 것이 항상 가능하지는 않음
  • 테스트 대상이 아닌 외부 요인은 테스트 코드에서 다루기 힘들며 테스트 코드에서 마음대로 제어할 수 없는 경우가 있음
  • 테스트 코드에서 생성한 외부 결과를 마음대로 초기화하기 힘들 때도 있음 
  • 대역은 테스트 대상이 의존하는 대상의 실제 구현을 대신하는 구현인데 이를 통해서 외부 상황이나 결과를 대체 가능

 

대역

  • 외부 요인은 테스트 작성을 어렵게 만들 뿐만 아니라 테스트 결과도 예측할 수 없게 만듦
  • 외부 요인 때문에 테스트가 어려울 때는 대역을 써서 테스트를 진행
  • 대역의 종류
    • 스텁(stub)
      • 구현을 단순한 것으로 대체
      • 테스트에 맞게 단순히 원하는 동작을 수행
    • 가짜(Fake)
      • 제품에는 적합하지 않지만, 실제 동작하는 구현을 제공
    • 스파이(Spy)
      • 호출된 내역을 기록하며 테스트 결과를 검증할 때 사용
    • 모의(Mock)
      • 기대한 대로 상호작용하는지 행위를 검증하며 기대한 대로 동작하지 않으면 익셉션을 발생할 수 있음
  • 제어하기 힘든 외부 상황이 존재하면 다음과 같은 방법으로 의존을 도출하고 이를 대역으로 대신할 수 있음
    • 제어하기 힘든 외부 상황을 별도 타입으로 분리
    • 테스트 코드는 별도로 분리한 타입의 대역을 생성
    • 생성한 대역을 테스트 대상의 생성자 등을 이용해서 전달
    • 대역을 이용해서 상황 구성
    • 구현이 오래 걸리는 로직을 분리
  • 결과 값을 확인하는 수단으로 모의 객체를 사용하기 시작하면 결과 검증 코드가 길어지고 복잡해짐
  • 모의 객체는 메서드 호출 여부를 검증하는 수단으로 테스트 대상과 모의 객체 간의 상호 작용이 조금만 변경되어도 테스트가 깨지기 쉬움
  • 모의 객체의 메서드 호출 여부를 결과 검증 수단으로 사용하는 것은 주의해야 하며 대안으로 가짜 대역을 구현해서 테스트하는 방법이 있음

 

테스트 가능한 설계

  • 테스트가 어려운 코드는 다음과 같음
    • 하드 코딩된 경로
    • 의존 객체를 직접 생성
    • 정적 메서드 사용
    • 실행 시점에 따라 달라지는 결과
    • 역할이 섞여 있는 코드
    • 메서드 중간에 소켓 통신 코드가 포함
    • 콘솔에서 입력을 받거나 결과를 콘솔에 출력
    • 테스트 대상이 사용하는 의존 대상 클래스나 메서드가 final
    • 테스트 대상의 소스를 소유하고 있지 않은 경우
  • 테스트가 어려운 주된 이유는 의존하는 코드를 교체할 수 있는 수단이 없기 때문
  • 테스트 가능한 설계는 다음과 같음
    • 하드 코딩된 상수를 생성자나 메서드 파라미터로 받기
    • 의존 대상을 주입 받기
      • 의존 대상은 주입 받을 수 있는 수단을 제공해서 교체할 수 있도록 해야 함
    • 테스트하고 싶은 코드를 분리
    • 시간이나 임의 값 생성 기능 분리
    • 외부 라이브러리는 직접 사용하지 말고 감싸서 사용
      • 외부 라이브러리를 대체하기 어렵다면 외부 라이브러리와 연동하기 위한 타입을 따로 만듦

 

테스트 범위와 종류

테스트 범위에 따른 테스트 종류

  • 기능 테스트(Functional Testing)
    • 사용자 입장에서 시스템이 제공하는 기능이 올바르게 동작하는지 확인
    • 이 테스트를 수행하려면 시스템을 구동하고 사용하는데 필요한 모든 구성요소가 필요
    • 끝(브라우저)에서 끝(데이터베이스)까지 올바른지 검사하기 때문에 E2E(End to End) 테스트
  • 통합 테스트(Integration Testing)
    • 시스템의 각 구성 요소가 올바르게 연동되는지 확인
    • 소프트웨어 코드를 직접 테스트
  • 단위 테스트(Unit Testing)
    • 개별 코드나 컴포넌트가 기대한대로 동작하는지 확인
    • 한 클래스나 한 메서드와 같은 작은 범위를 테스트

  • 통합 테스트는 기능 데스트에 비해 제약이 덜하며 시스템의 내부 구성 요소에 대한 테스트도 가능
  • 단위 테스트는 통합 테스트로도 만들기 힘든 상황을 쉽게 구성할 수 있음
  • 테스트 속도는 통합 테스트보다 단위 테스트가 빠르기 때문에 가능하면 단위 테스트에서 다양한 상황을 다루고,
    통합 테스트나 기능 테스트는 주요 상황에 초점을 맞춰야 함

 

테스트 코드와 유지보수

  • 테스트 코드는 그 자체로 코드이기 때문에 제품 코드와 동일하게 유지보수 대상
  • 테스트를 유지보수하지 않을 경우 발생하는 문제
    • 실패한 테스트가 새로 발생해도 무감각해짐
    • 테스트 실패 여부에 상관없이 빌드하고 배포
    • 빌드를 통과시키기 위해 실패한 테스트를 주석 처리 후 고치지 않음
  • 테스트 유지보수성이 떨어지지 않게 하려면 아래와 같이 테스트 코드를 작성해야 함
    • 변수나 필드를 사용해서 기대값 표현하지 않기
    • 두 개 이상을 검증하지 않기
    • 정확하게 일치하는 값으로 모의 객체 설정하지 않기
      • 모의 객체는 가능한 범용적인 값을 사용해서 기술해야 함
      • 한정된 값에 일치하도록 모의 객체를 사용하면 약간의 코드 수정만으로도 테스트는 실패하게 됨
    • 과도하게 구현 검증하지 않기
      • 내부 구현을 조금만 변경해도 테스트가 깨질 가능성이 있음
    • 셋업을 이용해서 중복된 상황을 설정하지 않기
      • 테스트 메서드는 자체적으로 검증하는 내용을 완전히 기술하고 있어야 테스트 코드를 유지보수하는 노력을 줄일 수 있음
    • 통합 테스트에서 데이터 공유 주의하기
      • 모든 테스트가 같은 값을 사용하는 데이터와 특정 테스트에서만 필요한 데이터를 나눠서 생각해야 함
    • 통합 테스트의 상황 설정을 위한 보조 클래스 사용하기
  • 실행 환경이 다르다고 실패하면 안 됨
    •  @EnabledOnOs, @DisabledOnOs 애노테이션을 사용해서 OS에 따른 테스트 실행 여부를 지정
  • 실행 시점이 다르다고 실패하지 않기
  • 랜덤하게 실패하지 않기
  • 필요하지 않은 값은 설정하지 않기
  • 테스트 대상의 필수 속성이 많다면 테스트를 위한 객체 생성 보조 클래스 사용하기
  • 조건부로 검증하지 않기
  • 통합 테스트는 필요한 범위까지만 연동하기
  • 학습 테스트 코드 및 테스트 커버리지를 높이기 위한 테스트 코드는 장기적으로 유지할 필요 없음

 

반응형

'책 소개' 카테고리의 다른 글

[요약] 자바 ORM 표준 JPA 프로그래밍  (0) 2024.07.11
[요약] 자바 8 인 액션  (0) 2024.06.30
[요약] 도메인 주도 개발 시작하기  (0) 2024.06.22
마음챙김  (0) 2023.11.07
오펜하이머 각본집  (1) 2023.10.29