군만두의 IT 공부 일지

[스터디2] 08. 자동 테스트 및 테스트 피라미드 본문

프로그래밍/객체지향

[스터디2] 08. 자동 테스트 및 테스트 피라미드

mandus 2025. 1. 31. 14:24

목차

     

    12 . 자동 테스트

    테스트는 시스템을 어떻게 검증하느냐에 따라 2가지로 분류함.

    • 수동 테스트(manual testing): 테스트 담당자가 소프트웨어를 직접 실행해보고 각각의 기능을 평가하며 구현된 기능이 요구사항에 부합하는지 검증하는 과정
    • 자동 테스트(automated testing): 테스트 스크립트나 도구를 사용해 소프트웨어를 자동으로 테스트하는 과정
      • 테스트 코드: 테스트를 위해 만들어진 코드. 어떤 클래스의 메서드가 제대로 동작하는지, 또는 어떤 서비스나 객체의 능동적인 행동의 결과를 확인하기 위해 작성함.
        • 장점: 코드가 추가되거나 변경될 때마다 시스템에 이상이 생겼는지 검사함으로써 시스템의 안정성을 보장함. 한 번 작성된 테스트는 의도적으로 지우지 않는 이상 서비스가 종료되는 순간까지 남아있음.
        • 깃허브 액션(GitHub Action) 같은 도구를 이용해 새로운 PR(Pull Request)이 올라오면 해당 코드베이스에서 전체 테스트를 실행하는 액션이 실행되게 테스트 실행을 자동화할 수 있음.
    3부에서 다루는 내용
    - 테스트는 왜 필요한가?
    - Regression(회귀)이란?
    - 단위 테스트란?
    - 통합 테스트란?
    - 소형/중형/대형 테스트란?
    - 테스트 대역이란?
    - 테스트 가능성이란?
    • 인수 테스트(acceptance test): 시스템이 비즈니스 요구사항을 만족해서 소유권을 넘기기 전에 수행하는 테스트 단계.
      • 수행할 테스트는 일반적으로 테스트 수행 절차와 기댓값이 적힌 체크리스트로 관리됨.
      • 테스트 단계 중 가장 마지막에 수행하는 단계이므로 수동 테스트자동 테스트가 있을 수 있음. 시스템을 고객에게 전달하기 전 사용자 관점에서 전체 시스템을 검증함.
      • 개발자들이 수동 테스트로만 구성하는 것처럼 인수 테스트 과정을 잘못 설계하면 많은 비용이 발생함.
        • 수동 테스트는 테스트 자체가 소모적인 작업임. 배포 버전에서 테스트를 통과해도 배포한 버전에서도 테스트를 해야 함. 
        • 지속 가능한 방법이 아니므로 자동 테스트로 인수 테스트의 일부를 대체하는 대안이 필요함. 책에서는 보편적인 자동 테스트인 테스트 코드에 대해 다룸.
    // 서비스 컴포넌트를 테스트하는 테스트 코드
    public class SampleUserTest {
    
        @Test
        void email_password로_회원가입을_할_수_있다() {
            // given
            UserCreateRequest userCreateRequest = UserCreateRequest.builder()
                .email("kok202@kakao.com")
                .password("foobar")
                .build();
    
            // when
            UserService userService = UserService.builder()
                .registerMessageSender(new DummyRegisterMessageSender())
                .userRepository(userRepository)
                .build();
            userService.register(userCreateRequest);
    
            // then
            Optional<User> result = userRepository.findByEmail("kok202@kakao.com");
            // 가입됐는지 확인함.
            assertThat(result.isEmpty()).isFalse();
            // 이메일 인증이 대기 상태인지 확인함.
            assertThat(result.get().isPending()).isTrue();
        }
    }

    12.1 Regression

    • Regression(회귀): 시스템에서 정상적으로 제공하던 기능이 어떤 배포 시점을 기준으로 제대로 동작하지 않게 되는 상황
      • 개발자들이 코드를 재활용하기 때문에 공통 코드의 수정은 조심스러움. 수정된 공통 코드가 개발 의도를 벗어나면서 의존하는 클래스들의 기대를 배신할 수 있음.
      • 회귀 버그 문제를 해결하기 위해 테스트를 이용해 공통 코드가 초기 개발 의도를 지키고 있는지 능동적으로 감시함.
    • 회귀 테스트(regression test): 회귀 버그를 탐지하고자 만들어진 테스트
      • 코드 변경에 따른 부작용이 생겼는지 여부를 판단할 수 있는 시스템적인 해결책이 자동화된 회귀 테스트임.
      • 회귀 테스트를 사람이 직접하는 것은 발생하는 비용이 크고, 부정확할 수 있음.
      • 자동화된 회귀 테스트는 프로젝트를 확장하고 유지보수하는 과정이 단순해지고 쉬워짐.

    12.2 의도

    • 타인이 작성한 코드에서 의도를 파악하는 방법 2가지
      1. 코드 작성자에게 물어보는 방법
        • 코드를 읽어보는 것만으로는 코드 작성자의 모든 의도를 파악하지 못할 수 있음.
      2. 테스트
        • 책임을 드러내는 수단, 책임에 대한 계약, 문서처럼 사용될 수 있음.
          • TDD(test-driven development: 테스트 주도 개발): 의도를 드러내는 테스트를 먼저 작성하고, 거기에 부합하는 내부 구현을 작성함. 객체에 할당된 책임과 의도를 기술할 수 있음. 즉, 테스트를 작성하면서 객체를 책임 단위로 볼 수 있음.           
          • 개발자는 의도를 드러내기 위해 메서드가 책임질 부분을 테스트로 작성함. 작성한 테스트는 똑같은 방식으로 테스트를 검증함.
          • 테스트는 어떤 출력을 원할 때 어떤 입력을 줘야 하는지, 어떤 입력을 주면 어떤 출력이 나오는지, 어떤 상황에서 에러가 발생하는지 등이 적혀 있음. 저자는 JavaDoc에 명시된 스펙을 테스트로 작성해 보는 것을 추천함.

    12.3 레거시 코드

    • 레거시 코드(legacy code): 오래된 소프트웨어 시스템에 존재하는 코드
    내게 레거시 코드란 단순히 테스트 루틴이 없는 코드다. 다만 이 정의는 다소 불완전하다.
    - 마이클 C. 페더스(Michael C. Feathers)
    • 코드를 유지보수하기 힘든 이유는 코드가 오래되어서가 아닌 예상치 못한 오류를 탐지할 수단이 없어서임.
    • 리팩터링하기 전에는 반드시 해당 기능을 대표하는 입력과 출력을 기록해야 함. 코드가 변경될 때도 입력과 출력에 변화가 생겼는지 여부를 확인할 수 있어야 함.

    13. 테스트 피라미드

    • 테스트를 분류하는 일반적인 분류 체계는 테스트 피라미드라고 불리는 3단 분류 체계임.
      1. 단위 테스트(unit test)
        • 소프트웨어를 구성하는 가장 작은 단위(unit)를 검증하는 테스트
        • Unit: 함수, 메서드, 클래스 같은 개별적이고 작은 코드 조각들
        • 객체나 컴포넌트에 할당된 작은 책임 하나가 예상대로 동작하는지 확인하는 테스트
      2. 통합 테스트(integration test)
        • 여러 컴포넌트나 객체가 협력하는 상황을 검증하는 테스트
        • 애플리케이션 서비스 관점에서 통합 테스트는 비즈니스 프로세스의 흐름을 검사하는 테스트로 볼 수 있음.
      3. E2E 테스트(end-to-end test)
        • 실제 사용자 시나리오에서 어떻게 동작하는지를 검증하는 테스트
        • 사용자의 실제 상황과 최대한 비슷한 환경에서 테스트가 이뤄짐.
        • 백엔드 개발자 입장에서 API 테스트라고 불리기도 함. 백엔드 서버를 실행하고 해당 서버에 필요한 하위 컴포넌트를 모두 구동한 뒤 API를 호출하는 방식으로 테스트를 작성하기 때문.

    ▲ 전통적인 테스트 피라미드

    • 테스트 피라미드에서 각 계층의 면적은 시스템에서 해당 유형의 테스트가 얼마나 분포해야 하는지를 표현함.
      • 단위 테스트 80%, 통합 테스트 15%. E2E 테스트 5%
    • 테스트를 작성할 때 어떤 테스트부터 작성하는 것이 좋은지 선행 관계도 표현함. 아래(단위 테스트)부터 만들어야 함.
    • 단위 테스트를 먼저 작성할 수 있어야 함. API 테스트만 작성하면 시스템이 예외 상황을 커버하지 못하고, 테스트 단위가 너무 커져서 실패 이유를 특정할 수 없게 됨.
    • 비결정적 테스트(non-deterministic test): 똑같은 테스트를 실행하더라도 어떤 때는 성공하고 어떤 때는 실패하는 테스트. 테스트를 작성하면서 피해야 할 테스트 안티패턴 중 하나임.
    • 전통적인 테스트 피라미드는 각 계층에 대한 정의가 모호한 문제가 있음. 이것을 보완한 새로운 인지 모델로 구글의 테스트 피라미드를 소개함.

    13.1 구글의 테스트 피라미드

    • 구글의 테스트 피라미드 모델에서는 테스트의 크기에 따라 테스트를 분류함.
      • 테스트의 크기: 테스트를 실행하는 데 사용되는 리소스의 크기

    ▲ 구글의 테스트 피라미드

    • 소형 테스트: 단일 서버, 단일 프로세스, 단일 스레드에서 동작하며, 디스크 I/O, 블로킹 호출(blocking call)이 없는 테스트
    • 중형 테스트: 단일 서버에서 동작하되 멀티 프로세스, 멀티 스레드를 사용할 수 있는 테스트
    • 대형 테스트: 멀티 서버에서 동작하는 테스트
      멀티 스레드 멀티 프로세스 멀티 서버
    소형 테스트 X X X
    중형 테스트 O O X
    대형 테스트 O O O
    구글은 왜 테스트를 실행하는 데 사용되는 리소스의 크기로 테스트를 분류했을까?

    13.2 테스트 분류 기준

    • 구글이 생각하는 테스트를 구분짓는 주요 특징: 테스트의 결정성과 속도
      • 비결정적인 테스트는 테스트의 신뢰도를 떨어뜨리고 테스트 실제 상황을 재현하기 어려움. 디버깅도 어려움.
      • 테스트를 실행했을 때 테스트의 성공, 실패 여부를 빠르게 확인할 수 있어야 함.
    비결정적인 테스트가 만들어지는 이유
    1. 테스트가 병렬 처리를 사용할 경우
    2. 테스트가 디스크 I/O를 사용할 경우
    3. 테스트가 다른 프로세스와 통신할 경우
    4. 테스트가 외부 서버와 통신할 경우
    속도가 느린 테스트가 만들어지는 이유
    1. 테스트가 블로킹 호출을 사용할 경우
    2. 테스트가 디스크 I/O를 사용할 경우
    3. 테스트가 다른 프로세스와 통신할 경우
    4. 테스트가 외부 서버와 통신할 경우

    13.3 소형 테스트의 중요성

    기억해둘 만한 테스트 관련 인사이트
    1. 시스템에는 소형 테스트가 많아야 함.
    2. 중형 테스트나 대형 테스트가 소형 테스트보다 많아지는 것은 바람직하지 않은 현상임.
    3. 소형 테스트는 단일 스레드, 단일 프로세스, 단일 서버에서 실행되며, 디스크 I/O, 블로킹 호출이 없는 테스트임.
    4. H2를 이용한 테스트는 중형 테스트임.
    • 소형 테스트가 중요한 이유는 빠르고 결정적인 것 이외에도 트랜잭션 스크립트 같은 코드가 나올 확률이 줄어든다는 것임.
    • 애플리케이션 서비스는 리포지터리와 통신하는 경우가 많아 중형 테스트가 될 확률이 높음.
    • 트랜잭션 스크립트에 있는 비즈니스 로직을 도메인으로 옮기고, 도메인을 테스트하면 소형 테스트로 만들 수 있음.
    • 소형, 중형, 대형 테스트는 어떤 컴포넌트를 테스트하느냐에 따라 결정되는 것이 아님. 어떻게 테스트하느냐에 따라 결정됨.

    17. 테스트와 개발 방법론

    • TDD(test-driven development): 개발자가 코드를 작성하기 전에 해당 코드의 테스트 케이스를 먼저 작성하게 한 후 해당 테스트를 통과할 수 있는 코드를 작성하는 개발 방법론
    • BDD(behavior-driven development): 소프트웨어 개발 과정에서 비즈니스 요구사항과 소프트웨어의 행동을 강조하는 개발 방법론. 테스트 케이스를 명세화할 때 Given-When-Then 같은 자연어로 구성된 시나리오를 사용할 것을 권장함.

    17.1 TDD

    • TDD는 개발 단계를 Red-Green-Refactor 3단계로 나누어 반복함.
    • Red 단계
      • 아직 구현되지 않는 기능을 테스트하는 케이스를 작성함. 당연히 테스트를 실패하기 때문에 Red 단계라고 부름.
    // 구현보다 테스트를 먼저 만드는 Red 단계
    public class CalculatorTest {
    
        @Test
        public void sumAtoB는_a부터_b까지_숫자를_모두_더한_값을_반환한다() {
            // given
            long a = 1;
            long b = 100;
    
            // when
            long result = Calculator.sumAtoB(a, b);
    
            // then
            assertThat(result).isEqualTo(5050);
        }
    }
    • Green 단계
      • 테스트를 통과시키기 위한 최소한의 코드를 작성함.
      • 코드의 품질에 신경쓰지 않음. 기능 구현에 집중함.
      • 이 단계에서 버그를 탐지할 수 있는 테스트를 확보하면, 자동 회귀 테스트로 리팩터링하면서 과감하게 코드를 변경할 수 있음.
    // 테스트를 통과하는 실제 구현을 만드는 Green 단계
    public class Calculator {
        public static long sumAtoB(long a, long b) {
            long result = 0;
            for (long i = a; i <= b; i++) {
                result += i;
            }
            return result;
        }
    }
    • Refactor 단계(Blue 단계)
      • Green 단계에서 작성한 코드를 리팩터링함.
      • 코드의 가독성과 유지보수성, 성능을 높이는 데 집중함.
      • 리팩터링: 기능은 그대로 유지된 상태에서 코드의 구조만 변경하는 작업
    // 구현 코드를 리팩터링하는 Refactor 단계
    public class Calculator {
        public static long sumAtoB(long a, long b) {
            return (b * (b + 1)) / 2 - (a * (a - 1)) / 2;
        }
    }
    • 장점: 개발 전에 테스트를 필수적으로 작성해야 하므로 소프트웨어의 기능을 견고하게 유지할 수 있음. 주요 기능에 테스트가 적용되어 있어 오류를 빠르게 감지해 수정할 수 있음.
    TDD 이전에 테스트를 잘 작성할 수 있어야 함.
    • TDD를 이용할 때 개발 속도가 빨라지는 이유
      1. TDD를 이용하면 디버깅 시간이 단축됨.
      2. 테스트가 있는 프로젝트에서 개발자는 코드 변경에 주저하지 않게 됨.
      3. 테스트는 코드의 문서 역할을 함.
    • TDD나 테스트의 사용은 상황에 따라 결정해야 함. 요구사항이 명확하지 않은 프로젝트에서 테스트 코드 작성 이후에 클라이언트가 요구사항을 변경한 경우에 테스트를 다시 작성하고, 기능을 개발하고, 리팩터링해야 하게 됨.

    17.2 BDD

    • BDD는 테스트가 요구사항 문서이자 기획 문서가 될 수 있게 만드는 것임.
    • BDD에서는 테스트의 단위가 사용자의 행동이며, 이에 맞춰 애플리케이션을 설계할 것을 강조함.
    Q. TDD를 따라 만들어진 코드는 객체지향적일까?
    A. 아님.
    BDD는 TDD를 하면서도 객체지향적인 설계를 얻기 위해 만든 이론으로 TDD에 DDD(도메인 주도 설계)를 얹은 것임.
    도메인 분석 단계에서 사용자 위주의 스토리를 만들고 이를 바탕으로 테스트 코드를 작성하도록 함.
    • BDD에서 강조하는 것 4가지
      1. 개발자와 비개발자 사이의 협업
      2. 행동 명세(사용자 스토리 기반의 요구사항) 작성
      3. 행동 명세의 테스트화
      4. 테스트의 문서화
    • 행동 명세: 어떤 사용자가 어떤 상황에서(given), 어떤 행동을 할 때(when), 그러면(then) 어떤 일이 발생한다를 기술함.
    // 행동 명세
    
    제목: 명시적인 제목
    
    서사
    - 주체는 누구인가
    - 주체가 원하는 것은 무엇인가
    - 주체의 행동 결과는 무엇인가
    
    시나리오 #1
    - Given: 주어진 상황
    - When: 시나리오가 발생하는 이벤트
    - Then: 시나리오 실행에 따른 기대값
    
    시나리오 #2
    - Given: 주어진 상황
    - When: 시나리오가 발생하는 이벤트
    - Then: 시나리오 실행에 따른 기대값
    • BDD를 선택한다고 해서 TDD를 적용하지 못하는 것이 아니며, TDD를 선택한다고 해서 BDD를 적용할 수 없는 것이 아님.
      • 사용자 스토리 기반으로 작성 → BDD
      • 함수 단위의 테스트 작성 → TDD

     

    프로젝트 진행하면서 가장 헷갈렸던 테스트에 대한 의문이 어느 정도 해결된 것 같습니다.  아직 14~16장이 남아 있지만, 테스트의 종류가 무엇이고, 왜 사용하는지 어떤 개발 방법론을 적용해야 하는 게 좋을지 고민을 하게 되었습니다. 책에 예시 코드로 설명이 잘 되어 있지만, 나중에 기회가 된다면 프로젝트에 적용해보고 싶습니다.

     

    이 글은 『자바/스프링 개발자를 위한 실용주의 프로그래밍』 책을 학습한 내용을 정리한 것입니다.
    Comments