배경: 테스트 코드 부재로 인해 마주친 실무적인 문제

이 글을 쓰는 시점은 입사 후 약 7개월 정도 된 시점이다. 우리 팀은 신입 개발자 두명이 FE, BE를 담당하고 있다. 나는 BE를 담당하고 있다. 내가 솔루션의 담당자가 되었을 때, 물려받은 기존 코드에서는 테스트 코드가 작성되지 않았다. (아예 없던 것은 아니지만 테스트가 작성되고 있다고 말하기는 너무 적은 양이었다.) 나는 여러 매체를 통해서 테스트 코드가 중요하다는 사실은 알고 있었지만 당시에는 그것이 피부로 와닿지 않았다. 팀의 선배 개발자나 기획자가 따로 없고 내가 생각보다 신경써야할 분야가 많았기 때문에 리소스가 부족했는데, 나는 솔루션의 CS를 담당했고 개발의 진행 스케줄도 관리했다. 버그가 발생하면 FE의 코드를 직접 봐야할 때도 있었고 CSV와 같은 QA에도 참여해야했다. 또한 요구사항을 위해 인터뷰도 진행하고 문서화하는 기획 업무도 자진해서 하게 되었다. 눈 앞에 벌어지는 일들을 처리하고 서버API를 개발하느라 테스트 코드의 필요성을 천천히 고민해볼 시간이 없었다. 그 때는 테스트 작성은 개발의 일부라는 것을 몰랐다. 그리고 점차 물리적인 어려움을 겪고 그 사실을 깨닫게 되었다. 점차 개발을 요구하는 기능과 복잡성이 점점 가중되었고 다음과 같은 문제들에 봉착했다.

  • 지금까지는 포스트맨으로 엔드포인트를 테스트 했다. 이 방법은 생산성이 낮았기 때문에 더이상 지속할 수 없었다.
  • 테스트 코드가 없기 때문에 요구사항의 변경으로 인한 일부 로직의 수정의 사이드 이팩트를 수동으로 평가해야 했다.
  • 시간이 지난 후 내가 작성한 코드를 다시 수정하게 되었을 때 코드의 동작을 잘 이해하지 못했다. 만약 테스트 코드가 있었다면 기능을 더 쉽게 파악할 수 있었을 것이다.
  • 스스로는 합리적인 구조로 코드를 작성했다고 생각했지만 실제로는 그렇지 않았고 이는 시험삼아 작성한 테스트 코드에서 의존성을 주입할 때 드러났다. 즉 테스트 코드를 작성하면서 기존 설계의 문제점을 파악했다.

나는 위와 같은 문제를 겪고 현재 프로젝트에 테스트를 도입하고 자동화해야겠다고 생각했다. 그래서 이에 관련한 여러 자료를 찾아 보게 되었다. 그 때 찾아본 자료들은 대략 다음과 같다.

Junit in Action 을 읽다가 깨달은 것

테스트를 공부하기 위해서 Junit in Action 을 읽다가 다음과 같이 깨달았다.

  • 세련된 기술이나 방법은 나중에 습득하면 된다. 처음엔 아무 생각없이 @SpringBootTest로 통합으로 시작해도 무방하다.
  • 정말 중요한 것은 무엇을 왜 테스트 하는지이다. 이것은 책이나 어디서든 쉽게 알려주지 않는다.
  • 쉽게 배울 수 없다는 것은 specific 하지 않고 Abstract 하다는 것이다. 테스트 원칙이나 철학은 Abstract에 해당하고 Concrete, Specific은 실제 업무 환경에서 찾아야 한다.

나는 책이 점차 지루해지는 것을 느낀 시점에서 책 읽기를 잠시 중지하고 실제로 내 담당 솔루션에 테스트 코드를 도입해면서 배우기로 했다. 나중에 지식이 더 필요할 때 책을 읽으면 더 재밌을 것이다.

어떤 테스트를 도입할 것인가?

다양한 Scope의 테스트가 있고 어떤 테스트를 도입할 것인지에 대해서 고민이 되었지만 나는 우선 필요한 영역의 테스트만 도입하는 것이 좋겠다고 생각했다.

  • 우선 기능을 구현하면서 보다 작은 단위의 로직을 검증하기 위한 단위테스트가 필요했다.
  • 그리고 사용자 시나리오에 따라서 기능 작동을 검증할 인수테스트가 필요했다.
    • 단 E2E의 UI는 포함하지 않고 API를 테스트 하려고 생각했다.
    • UI를 포함하지 않는 테스트를 인수테스트라고 부를 수 있을지 고민을 했지만 서버사이드 개발의 종단이 API이기 때문에 많은 사례들이 서버 사이드의 인수테스트를 API까지 테스트하고 있었다. 나는 이를 서버사이드 인수테스트라고 구분하면 좋겠다고 생각했다. [3월 우아한테크세미나] 우아한ATDD

아래는 챗 지피티가 알려준 각 테스트의 차이다.

구분 단위 테스트 (Unit Test) 기능 테스트 (Functional Test) 인수 테스트 (Acceptance Test)
목적 개별 메서드나 클래스의 동작을 검증 특정 기능(API나 모듈)이 설계된 대로 동작하는지 검증 시스템이 사용자 요구사항을 충족하는지 종합적으로 검증
관점 개발자 관점, 코드 수준에서 테스트 개발자 관점, 기능 단위로 테스트 사용자 또는 비즈니스 관점, 시스템 전체 테스트
테스트 범위 단일 메서드, 클래스, 작은 단위의 모듈 API, 서비스 등 개별 기능이나 모듈 사용자 시나리오 기반의 전체 흐름(종단 간 테스트)
테스트 대상 코드의 개별 요소 하나의 기능(예: API 엔드포인트, 서비스 로직) 전체 시스템, 여러 기능 간의 상호작용, UI와 데이터 흐름 등
사용 도구 JUnit, Mockito 등 MockMvc, RestAssured 등 RestAssured, Selenium 등(전체 흐름을 테스트하는 도구)
테스트 속도 매우 빠름 비교적 빠름 상대적으로 느림 (종단 간 테스트이기 때문에 더 복잡함)
의존성 테스트 대상 클래스 외에는 의존하지 않음 API나 모듈이 서로 어떻게 동작하는지 일부 의존성 있음 전체 시스템 의존 (데이터베이스, 외부 시스템 등 포함 가능)
실제 환경 반영 실제 환경을 반드시 반영하지 않음 일부 실제 환경 반영 가능(Mock 사용 가능) 실제 환경과 유사하게 테스트, 데이터베이스와 실제 네트워크 호출 가능
예시 특정 계산 메서드가 두 숫자를 더하는지 검증 API 엔드포인트가 올바르게 작동하고, 응답을 예상대로 반환하는지 검증 사용자가 로그인하고 상품을 장바구니에 추가한 후 결제까지 완료하는 시나리오 테스트

나는 기존의 프로젝트에 테스트 코드가 없기 때문에 현재 개발을 하고 있는 기능에 대해서 우선 API 인수 테스트를 도입하고 특별히 로직이 어렵고 복잡하고 자세한 검증이 필요한 경우 단위테스트를 도입하고자 했다.

우선 요구사항에 따라 작성된 API 유즈케이스 시나리오를 작성했고 해당 시나리오에 맞도록 인수테스트를 작성했다. 그리고 상대적으로 로직이 중요한 경우나 API 인수테스트 만으로는 로직의 안정성을 보장할 수 없는 복잡도가 높은 로직의 경우는 단위테스트를 작성하기로 했다.

테스트 도입하기 1: 테스트에 필요한 Application Properties로 분리하기

내가 맡은 프로젝트는 application.yml, application-local.yml, application-prod.yml, application-dev.yml 로 각 Stage 마다 설정파일을 구분하고 있다.

application.yml은 기본으로 공통로드 되는 설정이며 application.yml에 Stage마다 spring.profiles.active={profileName}를 변경해서 다른 설정을 덮어 씌운다.
보통 Docker로 서버를 올리기 때문에 각 인스턴스의 docker-compose.yml에 해당 설정 값을 변경해서 사용한다.

테스트용 설정파일은 src/test/resources/에 위치시킨다. 기본적으로 @SpringBootTest는 자동으로 application.properties 를 로드한다. 내가 쓰는 인텔리제이의 환경에서는 src/test/resources/application.properties 를 찾고 없으면 src/main/resources/application.properties 를 가져온다.

따라서 굳이 src/test/resources/ 에 프로퍼티 공통 프로퍼티 파일을 위치시키지 않고 application-test.properties 만 저장해두고 @SpringBootTest( properties = “spring.profiles.active=test”)로 테스트용 프로퍼티 파일만 따로 불러오는 것이 좋을 수 있다.
이 경우 공통 프로퍼티는 src/main/resources/application.properties 의 전적인 책임으로 맡기고 테스트용 프로퍼티만 명시적으로 애노테이션에서 지정해두는 것으로 메인 공통 프로퍼티의 변경으로 인한 환경 변화를 즉시 테스트 환경에 적용 가능하다.

만약 테스트 환경의 변화로 src/main/resources/application.properties 가 아닌 다른 것을 사용해야 한다면 src/test/resources/application.properties 를 새롭게 둘 수 있다. 이런 경우 main 프로퍼티의 변경이 즉시 테스트 환경에 반영되지 않으므로 주의해야한다. 따라서 꼭 구분해둘 것이 아니면 변경된 프로퍼티는 src/test/resources/application-test.properties 에 지정해 두는 것이 좋다고 생각한다.

또한 @ActiveProfile({profileName}) 을 사용하면 기본 프로퍼티 외 다른 프로퍼티파일을 로드할 수 있다. 하지만 이는 런타임에 애노테이션을 읽고 로드하므로 프로퍼티 변수인 ${spring.profiles.active}에 값이 지정되지 않는다. 따라서 어플리케이션에서 해당 변수를 사용하여 스프링 빈 등의 configuration이나 초기화를 실행하는 경우 문제가 생길 수 있다. 따라서 이런 경우 테스트 환경구축을 위해서 처음부터 일반적인 운영이나 개발 서버의 동작환경인 -Dspring.profiles.active 과 유사하도록 @SpringBootTest(properties = "spring.profiles.active=") attribute 를 지정하는 것이 좋다고 생각한다.

references

나는 위에서 공부한 내용을 바탕으로 환경변수 영역에서 실제 서비스 환경과 같은 테스트 환경을 구축했다.

테스트 도입하기 2: 테스트 데이터 베이스 환경 선택하기

흔히 데이터 베이스에 대해서 다음과 같은 2가지 방식 중 하나를 고른다.

항목 로컬 데이터베이스 컨테이너 기반 데이터베이스
설치 및 설정 개발자의 로컬 환경에 직접 설치 Docker 등 컨테이너로 설정 자동화
환경 일관성 개발자별 환경 차이 발생 가능 모든 환경에서 일관성 유지
속도 설정이 되어 있다면 빠르게 실행 가능 컨테이너 생성 및 실행 시간 필요
자원 관리 개발자 로컬 자원 사용 컨테이너에 할당된 자원만 사용
초기화 용이성 데이터 초기화 및 리셋이 번거로울 수 있음 컨테이너 재실행으로 쉽게 초기화 가능
CI/CD 통합 별도의 설정 필요 쉽게 통합 가능, 자동화에 유리

나는 연습차원에서 둘을 모두 현제 프로젝트에 적용해 보았다. 테스트 컨테이너로 데이터베이스 환경을 구축하는 것은 뚜렷한 장점은 있지만 아직까지 꼭 필요한 기술은 아니었다. 또한 프로젝트의 현재 수준에서는 로컬 환경으로 테스트틑 해보는 것으로 충분했다. 나중에 필요하다면 컨테이너 기반으로 고도화를 할 수도 있을 것이다.

따라서 나는 테스트 데이터베이스 환경 구축을 로컬 데이터 베이스로 결정했다.

테스트 도입하기 3: 픽스쳐의 중요성

테스트 데이터의 fixture도 또한 중요하다. 일반적으로 특정 기능을 테스트하는 시나리오에는 사전에 준비해야할 것이 많다. 예를 들어 특정 사용자가 게시판에 게시글을 등록하는 기능을 테스트한다면 사용자가 사전에 회원으로 등록되어 있어야 한다. 또한 사용자가 게시글을 작성할 수 있는 권한이 부여되어 있어야 한다. 이전 사전 조건은 흔히 Given. When. Then. 3step behavior 에서 Given에 해당하는 테스트 환경이기 때문에 각 테스트 별로 준비해야한다. 일반적으로 When 절은 API를 호출하는 동작이기 때문에 Given이나 Then의 영역이 중점적으로 개발자의 리소스가 투여되는 부분이다. 많은 사례에서도 Fixture에 대한 관리에 대한 어려움을 토로하는 얘기들이 많지만 정작 각 개발자의 속한 프로젝트의 도메인별로 통일된 fixture 관리 방법이 따로 없고 각 상황에 맞추어 준비하는 경우가 대부분이었다. 그럼에도 공통적으로 자주 쓰는 방법이 있는데 1. SQL 스크립트를 테스트 전에 실행하거나 2.Json 또는 Xml과 같은 데이터를 deserialize 하여 POJO fixture로 만들거나 3. Fixture용 클래스를 따로 만들고 각 테스트에서 초기화를 수행 등의 여러 방법이 있다. 여기서 3번인 POJO가 현대적이고 IDE의 도움을 받을 수 있긴 하지만 준비 코드가 장황해지고 실제 테스트 코드의 가독성이 떨어지는 문제도 있다. 이를 도와주는 한 라이브러리가 있다. 그것은 Naver의 최우성 개발자님께서 만드신 Fixture Monkey인데 Fixture Monkey는 리플렉션과 자바표준빈 규약에 따른 Setter 등으로 쉽게 Fixture를 만들어준다. 나중에 fixture관리가 어려워지는 시기에는 도입해볼 예정이다.

인수테스트, RestAssured 로 시작하기

인수 테스트는 클라이언트 입장에서의 서버 api 를 테스트한다. 따라서 인터페이스는 프레젠테이션 영역인 웹 영역이 되는데 이 영역을 테스트하기 위한 여러 기술들이 있다 주로 비교되는 것이 MockMvc, TestRestTemplate , RestAssured 그러나 MockMvc는 사실 슬라이스 테스트이기 때문에 제외해야 되는 게 맞으나, 공부를 하는 차원에서 아래의 표와 같이 챗지피티를 이용하여 작성하였다.

특성 MockMvc TestRestTemplate RestAssured
주요 목적 컨트롤러 단위 테스트, 내부 요청 테스트 통합 테스트, 전체 애플리케이션 요청 통합 테스트, 실제 HTTP 요청 테스트
테스트 대상 DispatcherServlet을 통한 스프링 MVC 계층 테스트 전체 애플리케이션의 컨트롤러와 서비스 계층까지 포함 전체 애플리케이션, 실제 네트워크 통신 모사
실제 서버 필요 여부 필요하지 않음 (내부적으로 MVC 호출) 필요 (SpringBootTest와 함께 사용 시 내장 서버 구동) 필요 (서버가 실제로 구동되어야 함)
성능 빠름 (내부적으로 실행하므로 네트워크 없음) 느림 (내장 서버 구동으로 실제 HTTP 요청) 느림 (실제 HTTP 요청을 수행)
주요 사용 사례 빠른 피드백이 필요한 컨트롤러 단위 테스트 실제 애플리케이션의 통합 테스트 (내장 서버와 함께 사용) 외부 API 테스트 또는 실제 HTTP 요청을 통한 통합 테스트
REST API 지원 REST API 요청을 모킹하여 처리 REST API 요청 가능, HTTP 상태 코드 및 응답 데이터 확인 REST API 요청 가능, 요청 구성 및 응답 처리 용이
주요 제한 사항 실제 네트워크 요청 불가, Spring MVC에 종속 외부 API 호출 테스트 어려움 내장 서버 필요, 실제 HTTP 요청으로 성능 저하 가능
설정 및 사용 난이도 상대적으로 쉬움 (스프링 컨텍스트 내부 사용) 쉬움 (SpringBootTest와 함께 쉽게 사용) 다소 복잡 (다양한 설정 옵션 제공)
추천 상황 컨트롤러 단위 테스트 및 빠른 피드백이 필요할 때 전반적인 서비스 통합 테스트, 실제 애플리케이션 테스트 필요할 때 외부 API와의 통합 테스트 또는 실제 네트워크 요청 테스트

테스트 레스트 템플릿은 아주 구체적인 세부 설정이 가능하지만 조금 복잡하며 가독성이 떨어진다. 그에 비해 RestAssured는 BDD 에 따라서 좀더 가독성이 있는 형태로서 표현되기 좋다. 아래는 RestAssured을 사용하는 예시이다.


@Test
public void getMember() {
    given().
            accept(MediaType.APPLICATION_JSON_VALUE).
            when().
            get("/members/1").
            then().
            log().all().
            statusCode(HttpStatus.OK).
            assertThat().body("id", equalTo(1));
}

테스트 도입하기 4: 도입과정

  • 나는 RestAssured 를 인수테스트에 도입하기로 했다.
    • BDD 스타일로 가독성이 좋아 테스트 케이스를 문서화하기 좋다고 판단했다.
    • ObjectMapper를 사용하지 않더라도 요청과 응답간의 json 역-직렬화를 기본 제공하기 때문에 객체지향적인 Assertion이 가능하서 좋다고 판단했다.
  • 그리고 나는 @AfterEach 메소드에서 전체 테이블에 대해 Trancate를 실시했다.
    • 로컬데이터베이스를 테스트 인프라로 사용하므로 잔존 데이터의 영향을 없고자 했다.
  • 그리고 나는 @BeforeEach 메소드에서 jdbcTemplate로 각 테스트 필요한 시드 데이터를 초기화 했다.
    • SQL 스크립트 로딩은 사용하지 않았다. 시드 데이터의 양이 적어 외부화시킬 필요가 없었기 때문이었다.
    • Repository 의 Save(Entity) 를 사용하지 않았다. 빌더를 통한 엔티티의 생성은 가독성이 떨어졌기 때문이다.
    • 픽스쳐 몽키나 다른 픽스처를 위한 외부 라이브러리를 사용하지 않았다. 그것을 사용할 만큼 복잡하지 않았기 때문이다.
  • 그리고 나는 테스트 시나리오에 따라서 준비돼야 할 갖춰야 할 조건을 만들기 위한 각각의 요청을 RestAssured을 활용한 작은 메소드들로 구성했다.
    • 각 API요청에 필요한 요청 페이로드나 변수들을 파라미터라이즈 했다.
    • 각 요청을 수행한 응답을 POJO로 받을 수 있도록 메소드를 구성했다. 단, 단순히 응답이 필요없는 경우 성공 HTTP Status인 200만을 확인하고 넘어가도록 했다.
    • 여러 API가 시간차를 두고 협동할 때를 가정한 시나리오를 테스트 하기 위함이었다.

테스트 코드 보다 선행해야할 것

관행이 있다면 기본적으로 따르는 것이 좋다고 생각한다. 예를 들어 개발팀 안에서 이미 테스트 코드를 작성하는 원칙이나 컨벤션이 존재한다면 우선 그 관행을 따라 순서대로 작성을 하는 것이 권장된다. 그러나 관행이 없는 경우는 어떻게 할까? 바로 내가 처한 상황과 같다. 레거시 프로젝트이고 테스트 코드가 없는 이 상황에서는 관행에 해당하는 흐름의 통로, 물길이 없기 때문에 직접 도랑을 파야만 했다. 테스트 코드를 작성하는 것은 테스트 업무 중 하나의 유닛에 불과하다. 테스트 코드를 작성하기 전에 무엇을 테스트할지 그리고 어떤 시나리오에서 테스트를 해야하는지에 대한 맥락적인 이해가 필요하다. 아래는 챗 지피티를 활용하여 각 테스트 단계를 추상화한 것이다.

단계 내용
1. 테스트 계획 및 요구사항 분석 - 목표와 범위 정의
- 요구사항 수집 및 이해
2. 테스트 설계 - 테스트 전략 수립
- 테스트 시나리오 및 케이스 작성
- 입력과 예상 결과 명시
3. 테스트 환경 및 데이터 준비 - 환경 구성
- 테스트 데이터 준비
4. 테스트 실행 및 결과 분석 - 테스트 수행
- 결과 기록 및 비교
- 이상 탐지 및 버그 보고
5. 버그 수정 및 회귀 테스트 - 버그 수정 추적
- 회귀 테스트 수행
6. 테스트 종료 및 문서화 - 성과 평가
- 보고서 작성
- 프로세스 개선

자동화 테스트 환경을 구성하고 실제로 RestAssured를 통해서 테스트 코드를 작성하는 것은 위 단계 중 3과 4에 해당한다. 테스트의 과정은 순열적이기 때문에 1, 2 과정을 거치지 않고 테스트코드가 작성된다면 자칫 통과를 하기 위한 테스트 코드로 전락할 수도 있다. 따라서 조금 아이러니한 부분이지만 테스트 코드를 잘 작성하기 위해서는 다소 기획적인 부분을 많이 들여다 봐야했다.

돌아가기 : 요구사항 명세서를 뜯어보기

나는 우선 요구사항명세를 정리했다. 우리팀의 요구사항정의서는 관리되지 않고 있었고 작성되어 있더라도 간결하지 못했다. 나는 비기능적인 요소와 GUI와 관련된 내용을 Figma와 같은 화면설계서에 옮길 것을 제안했다. 예를 들어

기존 요구사항명세서 : 상품목록 페이지에서는 여러개의 상품이 3열 횡대로 사용자에게 보여지고 사용자는 각 상품 아래에 달린 추가 버튼을 눌러서 장바구니에 상품을 하나씩 담을 수 있다.

수정 제안한 요구사항 명세서 : 사용자는 구매 가능한 상태의 상품을 하나씩 장바구니에 담을 수 있다.

화면설계서로 넘어간 부분: 상품목록 페이지에서 상품이 어떻게 잘 보이게 할것인지, 추가 버튼의 위치와 눌렀을 때의 사용자에게 보일 반응과 동작

이러한 수정 제안은 기존 과거의 요구사항명세서에 전체적으로 적용하지 않았다. 당장은 특정 신규 도메인에 대한 개발이 진행중이었으므로 당장 작성되고 있는 요구사항 명세서만 그렇게 적용시켰다.

돌아가기 : API 명세서를 뜯어보기

이렇게 요구사항 명세서를 간결하게 정리했으므로 핵심적인 비지니스 기능에 대해서 집중할 수 있었고 각 기능에 필요한 API 명세서를 작성하기가 수월했다.

기존에는

요구사항 -> 서버 구현 -> API 명세서 작성

으로 했다.

이런 방식은 잦은 API명세서 수정과 잦은 API구현 지연을 초래했다. 또한 다소 서버 중심적이기 때문에 협업에 유리하지 못했다. 따라서 다음과 같은 방식을 취했다.

요구사항 -> API 명세서 작성 -> 서버 구현

이 방식은 요구사항을 토대로 클라이언트 사이의 인터페이스를 먼저 정한다는 측면에서 협업에 유리했다. 또한 이러한 방식은 ATDD(인수테스트 주도 개발)에 적합했다. 앞서 #어떤-테스트를-도입할-것인가 에서 얘기한 것처럼 서버사이드 인수테스트에서는 API를 테스트 하는 것이기 때문이다.

따라서 나는 각 API 별로 명세서를 작성하고나서 곧바로 해당 기능이 정상적으로 작동하는지 인수테스트를 작성할 수 있었다.

따라서 업무 방식이 이렇게 바뀌었다.

요구사항 -> API 명세서 작성 -> 인수테스트 작성 –> 테스트에 통과하기 위한 서버 구현

돌아가기 : 테스트 시나리오, 테스트 케이스 고찰하기

인수테스트를 작성하기 위해서는 사용자의 시나리오와 각 시나리오에 따른 테스트 케이스를 도출해야한다.

GPT가 설명해주는 각 차이는 다음과 같다.


테스트 시나리오와 테스트 케이스는 소프트웨어 테스트에서 자주 사용되는 용어로, 각기 다른 목적과 구체성을 가지고 있습니다. 이해를 돕기 위해 각각의 개념과 예시를 통해 설명드리겠습니다.

1. 테스트 시나리오 (Test Scenario)  
   테스트 시나리오는 시스템에서 테스트할 기능이나 목표를 큰 틀에서 정의한 것입니다. 사용자의 관점에서 기능이 어떻게 동작해야 하는지를 중심으로 설계되며, 주로 시스템이 수행해야 하는 주요 기능을 중심으로 정의됩니다. 테스트 시나리오는 특정 기능에 대한 전반적인 검증 목표를 포괄적으로 서술하기 때문에, 세부적인 절차나 값보다는 테스트의 방향을 제시합니다.

예시: 쇼핑몰 웹사이트에서의 “장바구니 기능 테스트”
시나리오 1: 사용자가 상품을 장바구니에 추가할 수 있는지 확인
시나리오 2: 사용자가 장바구니에서 상품을 삭제할 수 있는지 확인
시나리오 3: 장바구니에 추가된 상품의 수량을 변경할 수 있는지 확인
시나리오 4: 장바구니에 있는 상품을 주문으로 연결할 수 있는지 확인

이처럼 테스트 시나리오는 전체적인 기능을 큰 범위에서 검토할 수 있도록 설정됩니다.

2. 테스트 케이스 (Test Case)
   테스트 케이스는 테스트 시나리오를 구체화한 테스트 절차로, 특정 기능이나 시나리오를 검증하기 위해 필요한 입력 데이터, 실행 조건, 예상 결과를 상세히 명시한 것입니다. 테스트 케이스는 테스트 시나리오를 이루는 세부적인 단위이며, 실제 테스트 수행 시 참고하는 구체적인 가이드입니다. 각 테스트 케이스는 단위별로 성공 여부를 판별할 수 있는 명확한 기준을 포함합니다.

예시: “장바구니에 상품 추가” 시나리오에 대한 테스트 케이스

테스트 케이스 1: 사용자가 로그인한 상태에서 상품 상세 페이지에서 “장바구니에 추가” 버튼을 클릭했을 때, 상품이 장바구니에 정상적으로 추가되는지 확인
입력 데이터: 로그인된 사용자, 특정 상품 ID
테스트 절차:
웹사이트에 로그인
특정 상품의 상세 페이지로 이동
“장바구니에 추가” 버튼 클릭
예상 결과: 장바구니에 해당 상품이 추가되고, 장바구니 수량이 증가됨

테스트 케이스 2: 로그아웃 상태에서 “장바구니에 추가” 버튼을 클릭했을 때, 로그인 페이지로 리다이렉트되는지 확인
입력 데이터: 로그아웃 상태, 특정 상품 ID
테스트 절차:
웹사이트에 로그인하지 않고 특정 상품의 상세 페이지로 이동
“장바구니에 추가” 버튼 클릭
예상 결과: 로그인 페이지로 이동됨

요약
테스트 시나리오: 시스템이 특정 상황에서 어떻게 동작해야 하는지, 큰 틀에서 기능을 검토하는 목적을 가지고 있습니다.
테스트 케이스: 테스트 시나리오를 구체화하여, 실행 절차와 예상 결과를 명확하게 작성한 것으로, 실제 테스트 단계에서 상세 가이드 역할을 합니다.
결론적으로, 테스트 시나리오는 "무엇을 테스트할 것인가"를 설명하는 반면, 테스트 케이스는 "어떻게 테스트할 것인가"를 설명합니다.

그렇다면 어떻게 이 시나리오, 테스트 문서를 명세화할 수 있을까?

우선 이 문서를 어디서 관리하는지도 중요하다. 테스트 시나리오와 테스트 케이스의 명세서를 Jira Confluence 등으로 테스트가 실제 실행되는 곳이 아닌 외부에 작성될 수도 있다. 또는 테스트가 실제 작성되는 코드 안에 작성할 수도 있다. 각각 장단점이 있을 것이다. 전자는 다소 연결이 느슨하지만 그만큼 유연할 것이다. 후자는 반대로 통합성이 뛰어나지만 코드에 접근해야만 문서를 볼 수 있다. 그 밖에 여러 세부옵션이 있을 것이다. 현재 프로젝트의 성숙도나 규모와 같이 개발환경을 둘러싼 환경을 바탕으로 여러 옵션 중 하나를 선택하는 편이 좋다고 생각한다.

내가 맡은 프로젝트에 테스트 시나리오와 테스트 케이스를 처음에는 마크다운으로 외부 문서화하였지만 생산성이 좋지 못했다. 따라서 나는 테스트 코드의 @DisplayName 등을 이용하여 관련한 설명을 상세하게 붙이기 위해 노력했다.

또한 인수테스트의 다음과 같은 테스트 패키지구성을 정했다.


acceptance/test/ : 인수테스트를 담을 루트 패키지
acceptance/test/{domdain}/ : 각 도메인 별로 인수테스트를 분할하는 부분
acceptance/test/{domdain}/{testScenario}.class : 각 도메인 안에서의 테스트 시나리오를 담는 클래스. 클래스의 멤버 또는 주석으로서 시나리오를 설명한다. 
각 시나리오 클래스의 메소드 또는 @Nested : 좀더 세부적인 테스트 케이스에 해당하며 Nested로 묶음 . @displayName 으로 테스트 케이스를 설명한다. @ParameterizeTest를 사용하여 각 테스트 케이스에 대한 테스트 조건을 기입한다.

시작하기, 그리고 글을 마무리하기 : 인수테스트 작성하기

테스트 시나리오와 테스트 케이스를 작성하고 나면 자연스럽게 어떻게 테스트 코드를 작성해야할지 알게된다. 나는 처음에 테스트 코드를 잘 모를 때는 코드를 작성하는 행위가 중요하다고 생각했지만 이제는 요구사항과 테스트 시나리오와 테스트 케이스를 설계하는 행위가 7할은 되는 것 같다는 생각이 든다. 그리고 그게 중요하다는 사실을 깨닫고 있다. 이제는 테스트 코드 작성은 IDE와 GPT가 도와주기 때문에 막힐 때마다 천천히 물어보며 작성하면 된다. 점차 정말 중요한 것은 어떤 예외 케이스를 검증할 것인지, 아니면 검증하지 않고 넘어갈 것인지와 같은 좀 더 테스트를 설계하는 측면이라고 생각되었다.

이 글은 약 2달간 작성되었다. 처음 글을 쓴 것은 10월 무렵이고 퇴고가 마친 것은 12월이다. 그 사이에도 나는 성장한 것을 느낀다. 하나를 알면 더욱 새로운 무지의 영역을 발견하곤 한다. 나는 최근에는 스케쥴링된 작업 코드를 테스트 하는 것에 많은 고민을 하고 있다. 그리고 테스트 가능한 코드에 대해서 고민하고 있다. 이 글은 내가 테스트 코드 작성을 시작한 기념비적인 글이 될 것 같다.

이제 글을 마치고자 한다. “독자 여러분께 감사합니다.”

아래는 내가 현재 인수테스트를 작성하는 스타일로 작성된 테스트 코드 예시이다. 실제로는 조금 더 복잡하지만 큰 틀에서는 비슷하다.


/**
 * 예제 테스트 시나리오 1
 * 사용자는 배달음식을 주문한다. 
 * {자세한 내용 생략}
 */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderDeliveryTest {

  @Autowired
  private JdbcTemplate jdbcTemplate; 

  @BeforeEach
  void setUp(@LocalServerPort int port) {
    RestAssured.port = port;

    // 데이터 초기화
    jdbcTemplate.execute("INSERT INTO users (id, email, password, active) VALUES (1, 'testuser@example.com', 'password123', true)");

  }

  @AfterEach
  void tearDown() {
    jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0"); // 외래키 제약조건 무시
    jdbcTemplate.execute("TRUNCATE TABLE users");
    jdbcTemplate.execute("TRUNCATE TABLE orders");
    jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1"); // 외래키 제약조건 복구
  }

  @Test
  @DisplayName("""
          테스트케이스 1:
          로그인되지 않은 사용자가 주문을 시도하면 401 코드를 응답한다.""")
  void nonAuthenticatedUserGetsUnauthorized() {
    //given
    String jwtToken = null;
    
    //when //then
    CreateOrderRequest orderRequest = CreateOrderRequest.builder().targetItemPk(1).amount(1).build();
    배달주문을_시도한다(jwtToken, orderRequest).statusCode(401);
  }

  @Test
  @DisplayName("""
          테스트케이스 2:
          로그인된 사용자가 주문을 시도하면 201로 응답하고 주문에 성공한다.""")
  void authenticatedUserCreatesOrderSuccessfully() {
    //given
    LoginRequest loginRequest = new LoginRequest("testuser@example.com","password123");
    String jwtToken = 로그인을_시도한다(loginRequest).statusCode(200).extract().as(LoginResponse.class).getJwtToken();
    
    //when //then
    CreateOrderRequest orderRequest = CreateOrderRequest.builder().targetItemPk(1).amount(1).build();
    배달주문을_시도한다(jwtToken, orderRequest).statusCode(201); 
  }
  
  
  
  private ValidatableResponse 배달주문을_시도한다(String jwtToken, CreateOrderRequest request) {
    return RestAssured
            .given()
            .header("Authorization", "Bearer " + jwtToken)
            .contentType("application/json")
            .body(createOrderRequest())

            .when().log().all()
            .post("/orders")

            .then().log().all();
  }

  private ValidatableResponse 로그인을_시도한다(LoginRequest request) {
    return RestAssured
            .given()
            .contentType("application/json")
            .body(createOrderRequest())

            .when().log().all()
            .post("/login")

            .then().log().all();
  }
  
}