안녕하세요. 우아한형제들 주문마케팅회원서비스팀 장진우입니다.

모든 테스트는 중요하고 가치가 높은 활동입니다. 특히 개발을 지원하기 위한 기술 중심의 테스트가 조금 더 중요하고 가능하다면 이런 테스트를 더 많이 해야 한다고 생각해 왔습니다. 왜냐하면, 버그가 나타나는 시점은 코드가 개발된 직후입니다. 이때 테스트가 수행되고 테스트 수행 결과를 피드백 받는 시간 간격이 짧을수록 버그의 원인을 쉽고 빠르게 찾을 수 있기 때문입니다.

하지만, 기술 중심의 자동화된 테스트 없이 수동으로 테스트를 하는 것은 테스트 수행 시간과 결과를 피드백 받는 시간이 더 오래 걸린다는 단점이 있습니다.

자동화된 회귀 테스트는 수행 속도가 빠르고 반복적인 테스트에 적합합니다. 사람의 인지 능력이 있어야 하는 영역에는 사용할 수 없고, 살충제 패러독스Pesticide paradox에 빠지기가 더 쉽습니다.

Effective Unit Testing이란 책에서도 “테스트의 실행 속도가 테스트의 피드백 주기를 감소시키고 생산성에 직접적인 영향을 준다”고 설명하고 있습니다.

담당하고 있는 제품(또는 서비스)의 품질을 유지하면서 업무 생산성을 높이기 위해 API 리그레션 테스트를 자동화했습니다. 그리고, 테스트 결과를 Elasticsearch로 전송하고 Kibana를 통해서 피드백 받을 수 있도록 시스템을 구성한 과정을 공유하고자 합니다.

Rest-Assured와 Spock으로 API 테스트 작성

Spock framework에 대해서는 이미 많은 분이 알고 계실 거라고 생각합니다. 아직 Spock에 대해 잘 모르시는 분들은 같은 팀 정용준 님이 쓴 Spock으로 테스트코드를 짜보자를 먼저 읽어보시면 도움 될 거라 생각합니다.

API 테스트를 위한 프로젝트 구성

Gradle로 프로젝트를 구성합니다. 프로젝트 구조는 service-api-test란 프로젝트와 하위 프로젝트(또는 모듈)인 member-internal-api, member-legacy-api, member-external-api, push-internal-api, push-legacy-api 그리고 commons로 구성됩니다.

# settings.gradle

rootProject.name = 'service-api-test'
include 'member-internal-api'
include 'member-external-api'
include 'member-legacy-api'
include 'push-legacy-api'
include 'push-internal-api'
include 'commons'

다음은 아래와 같이 테스트 코드를 작성하는데 필요한 의존성을 추가해줍니다.

# build.gradle

 dependencies {
     implementation (
           'org.codehaus.groovy:groovy-all:2.5.6',
           'org.spockframework:spock-core:1.3-groovy-2.5',
           'io.rest-assured:json-path:4.1.1',
           'io.rest-assured:rest-assured:4.1.1',
           'io.rest-assured:json-schema-validator:4.1.1',
           'com.konghq:unirest-java:2.3.15',
           'com.konghq:unirest-objectmapper-jackson:2.3.16',
           'com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.10.0.pr1',
           'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.10.0.pr1'
     )
     testImplementation (
           'org.hamcrest:hamcrest:2.1',
           'org.hamcrest:hamcrest-all:1.3',
     )
 }

예제 코드

코드에서 사용한 데이터는 테스트 용으로 생성된 데이터입니다.

// 테스트 목적이나 테스트 항목에 대해 설명
@Narrative(value = """
   회원 번호로 회원의 기본 속성 정보를 조회하는 API를 테스트 합니다.
   
   기본 속성 정보는 다음과 같습니다.
   - 회원번호: 
   - 휴대전화번호: 
   - 회원탈퇴여부: 
""")
class FindMemberDefaultFieldsByMemberNumberSpec extends TestConfiguration {
   
   @Unroll
   @Timeout(value = 3, unit = TimeUnit.SECONDS)
   @Retry(condition = { failure.message.contains('spock.lang.Timeout') }, mode = Retry.Mode.ITERATION, delay = 10000)
   def "회원정보 > 원하는 속성의 회원 기본정보 조회 [회원번호: #memberNo]"() {
      given: // API 호출을 위한 요청을 생성
      request = given()

      when: // 요청에 필요한 인증정보, Content-Type, 요청 파라미터를 입력하고 요청을 전송
      response = request.config(restAssuredConfig)
            .header(authorize)
            .contentType(ContentType.JSON)
            .queryParams(TestHelper.requestParams(["memberNo", "mobileNumber", "fields"], [memberNo, mobileNumber, "memberNo,mobileNumber,isWithdrawn"]))
            .get("/blahblah")

      if (isPrint) {
         response.prettyPrint()
      }
      
      // 요청에 대한 응답을 JSON 객체로 변환
      JsonPath payload = new JsonPath(response.body().asString())

      then: // 기대했던 값과 실제 응답을 검증
      response.then().statusCode(status)
      payload.getList("data.findAll { x -> x.isWithdrawn == false }.collect {x -> x.memberNo} ") == _memberNo

      where: // 테스트 데이터 : API 호출을 위한 요청 데이터와 기대 결과 
      memberNo     || status | mobileNumber   | _memberNo
      ""           || 200    | "01000000000"  | [191011000004, 171025000004, 161123000379, 141124003077]
      141124003077 || 200    | ""             | [141124003077]
      180221000001 || 200    | ""             | []
   }
}

위 코드처럼 각 API에 대한 테스트케이스를 작성하고 로컬에서 테스트를 수행하는 것은 테스터 입장에서 큰 도움이 됩니다. 하지만, 로컬에서 실행되는 테스트는 개발자가 테스트 환경에 배포한 후에 즉시 실행하기 어렵고 테스트가 수행된 이후에 테스트 결과를 개발자가 피드백 받는데도 많은 시간이 소요됩니다.

배포된 후에 테스트가 자동으로 수행되고 수행된 결과를 개발자가 거의 실시간으로 확인할 수 있다면 조금 더 빠르게 문제점을 발견하고 코드를 수정할 수 있습니다.

Spock Extensions

Spock Framework에는 강력한 확장extension 메커니즘이 있으며 크게 Built-in extension과 사용자가 직접 목적에 맞게 작성할 수 있는 Custom extension으로 구분할 수 있습니다.

Built-in Extensions

Built-in extension은 대부분 어노테이션annotation 기반 확장으로 Spec 클래스나 메서드에 어노테이션을 추가하면 활성화 됩니다.
@Ignore, @Timeout, @Rule, @Retry, @Stepwise

Custom Extensions

Spock과 함께 사용할 수 있도록 직접 만들 수 있는 확장은 Global ExtensionsAnnotation Driven Local Extensions이 있습니다. 이 두 확장 유형 모두 콜백 메서드를 정의하는 특정 인터페이스를 구현하여 작성할 수 있습니다.

  • Global Extensions은 Spock 테스트의 시작부터 종료까지 Spec에 작업을 추가할 필요가 있을 때 선택해야 합니다.
  • Annotation Driven Local Extensions는 사용자가 어떤 작업을 선택적으로 추가할 필요가 있을 때 선택해야 합니다.

이 글에서는 작성된 모든 테스트가 시작되고 종료될 때까지 과정을 기록하기 위해 Global Extensions을 활용합니다.

IGlobalExtension

Global Extension 생성을 위해 클래스 패스에 META-INF/services/org.spockframework.runtime.extension.IGlobalExtension 파일을 생성합니다.

그리고, IGlobalExtension interface를 구현하는 클래스를 생성합니다.

class TestReportExtension implements IGlobalExtension {
   @Override
   void start() {
      // spock 테스트가 맨 처음 시작될 때 한번 호출 됩니다
   }

   @Override
   void visitSpec(SpecInfo specInfo) {
      // 각 spec마다 한번씩 호출 됩니다
   }

   @Override
   void stop() {
      // spock 테스트가 맨 마지막 종료될 때 한번 호출 됩니다
   }
}

생성된 TestReportExtension 클래스 이름을 IGlobalExtension 파일에 추가합니다.

/*
   /META-INF/services/org.spockframework.runtime.extension.IGlobalExtension
*/
com.woowabros.service.commons.TestReportExtension

이 두 가지 조건이 충족되면 Spock 테스트가 실행될 때 TestReportExtension이 자동으로 실행됩니다.

Listener 추가하기

일반적으로 테스트 프레임워크에서 Listener는 테스트가 시작, 성공, 실패, 종료 등 상태에 따라 특정 작업을 수행해야 할 때 사용하며 Spock에서는 추상 클래스인 AbstractRunListener를 상속받아 구현할 수 있습니다.

package org.spockframework.runtime;

import org.spockframework.runtime.model.*;

public class AbstractRunListener implements IRunListener {
  // Spec 실행 전에 수행해야할 작업
  public void beforeSpec(SpecInfo spec) {}
  
  // Feature 실행 전에 수행해야할 작업
  public void beforeFeature(FeatureInfo feature) {}
  
  // Iteration 실행 전에 수행해야할 작업 
  public void beforeIteration(IterationInfo iteration) {}
  
  // Iteration 실행 후에 수행해야할 작업
  public void afterIteration(IterationInfo iteration) {}
  
  // Feature 실행 후에 수행해야할 작업
  public void afterFeature(FeatureInfo feature) {}
  
  // Spec 실행 후에 수행해야할 작업
  public void afterSpec(SpecInfo spec) {}
  
  // 테스트가 실패했을 때 수행해야할 작업
  public void error(ErrorInfo error) {}
  
  // Spec을 건너뛰어야 할 때 수행해야할 작업
  public void specSkipped(SpecInfo spec) {}
  
  // Feature를 건너뛰어야 할 때 수행해야할 작업
  public void featureSkipped(FeatureInfo feature) {}
}

위 코드를 보면 SpecInfo, FeatureInfo, IterationInfo, ErrorInfo~Info 클래스들이 각 메서드의 파라미터로 전달되고 있는데 이 클래스들이 어떤 정보들을 담고 있는지 아래 코드와 설명을 참고해주세요.

  • SpecInfo는 Spock 테스트 클래스의 패키지 정보, 클래스 명 등의 정보를 담고 있습니다.
  • FeatureInfo Spock 테스트에 정의된 feature 메서들에 대한 정보를 담고 있습니다.
  • IterationInfo Spock 테스트의 feature 메서드 내에서 Data Driven Testing을 가능하게 해주는 데이터 세트에 대한 정보를 담고 있습니다.
  • ErrorInfo Spock 테스트가 실패했을 때 정보를 담고 있습니다.
import spock.lang.Specification
import spock.lang.Unroll

// Specification : SpecInfo
class MyFirstSpecification extends Specification {
   // fixture methods
   def setup() {
   }

   def cleanup() {
   }

   // feature methods : FeatureInfo
   @Unroll
   def "length of language’s name: [#name], [#length]"() {
      expect:
      name.size() == length

      // iteration : IterationInfo
      where:
      name << ['groovy', 'java', 'javascript', 'swift', 'c']
      length << [6, 4, 10, 5, 1]
   }
}

AbstractListener를 상속받아서 Spec, Feature, Iteration 시작 전/후로 해야 할 작업을 추가한 TestListener 코드의 일부입니다.

class TestListener extends AbstractRunListener {

   ... 생략
   
   @Override
   void beforeSpec(SpecInfo specInfo) {
      initError()
      specFailed = false
      className = specInfo.getName()
      filename = specInfo.getFilename()
      pkg = specInfo.getPackage()
   }

   @Override
   void beforeFeature(FeatureInfo featureInfo) {
      initError()
      featureFailed = false
   }

   @Override
   void beforeIteration(IterationInfo iterationInfo) {
      initError()
      iterationFailed = false
   }

   @Override
   void afterIteration(IterationInfo iterationInfo) {
      // 테스트 케이스가 수행된 결과를 Elasticsearch로 전송합니다
      sendStatus(iterationInfo, iterationFailed ? Status.FAILED : Status.PASSED)
   }

   @Override
   void afterFeature(FeatureInfo featureInfo) {
   }

   @Override
   void afterSpec(SpecInfo specInfo) {
      if ( specInfo.getFilename() != "TestConfiguration.groovy") {
         sendStatus(specInfo, specFailed ? Status.FAILURE : Status.SUCCESS)
      }
   }

   @Override
   void error(ErrorInfo errorInfo) {
      specFailed = true
      // 테스트가 실패한 feature와 iteration 정보 확인
      FeatureInfo feature = errorInfo.getMethod().getFeature()
      IterationInfo iteration = errorInfo.getMethod().getIteration()
      if (feature != null) {
         featureFailed = true
      }

      if (iteration != null) {
         iterationFailed = true
         error = errorInfo.getException()
      }
   }

   @Override
   void specSkipped(SpecInfo specInfo) {
      // skip
   }

   @Override
   void featureSkipped(FeatureInfo featureInfo) {
      sendStatus(featureInfo, Status.SKIPPED)
   }

   private void sendStatus(Object spec, String status) {
      testStatus.setProject(System.getProperty("project.name"))
      testStatus.setPkg(pkg)
      testStatus.setTestClass(filename)
      if (spec instanceof SpecInfo) {
         testStatus.setDescription(spec.getNarrative())
      } else {
         testStatus.setDescription(((IterationInfo) spec).getName())
      }
      testStatus.setStatus(status)
      testStatus.setExecutionTime(LocalDateTime.now().toDate())
      testStatus.setError(error)
      
      // Elasticsearch로 테스트 결과 전송
      TestResultSender.send(testStatus)
   }
   
   ... 생략
}

테스트해야 할 대상 API들에 대해 테스트 코드를 작성하고 테스트 결과를 Elasticsearch로 전송할 수 있도록 준비가 끝났습니다. 전송되는 JSON 형태는 아래와 같습니다.

{
    "project": "member-legacy-api",
    "pkg": "com.woowabros.service.member.legacy.member.modify",
    "class": "UpdateMembershipByNothingSpec.groovy",
    "description": "회원정보 수정 > 유효한 사용자액세스토큰으로 회원정보를 수정할 수 있어야 합니다.",
    "status": "SUCCESS",
    "executionTime": "2019-11-07T01:36:27",
    "error": "-"
  }

Elasticsearch 및 Kibana 설치

Elasticsearch는 문서 저장소이면서 검색엔진입니다. Kibana는 Elasticsearch에 저장된 데이터를 시각화해서 표현할 수 있는 시각화 도구입니다. 테스트 수행 결과를 실시간으로 시각화하기 위해서 Elasticsearch와 Kibana를 활용하고 있습니다.

docker 환경에서 Elasticsearch와 Kibana를 설치하고 실행합니다.

Elasticsearch docker 이미지 다운로드

Elasticsearch docker 이미지를 다운로드합니다.

$ docker pull docker.elastic.co/elasticsearch/elasticsearch:7.3.1

Elasticsearch 컨테이너 실행

$ docker run -d -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" --name elasticsearch docker.elastic.co/elasticsearch/elasticsearch:7.3.1

Kibana docker 이미지 다운로드

$ docker pull docker.elastic.co/kibana/kibana:7.3.1

Kibana 컨테이너 실행

$ docker run -d --link elasticsearch:elasticsearch -p 5601:5601 --name kibana docker.elastic.co/kibana/kibana:7.3.1

Jenkins Job 등록

API를 테스트하기 가장 적절한 시점은 코드가 작성되고 테스트 서버에 배포된 직후입니다. 테스트 서버에 코드가 배포되면 자동으로 테스트가 수행될 수 있도록 Jenkins에 Job을 등록합니다.

테스트 결과 확인하기

테스트 서버에 새로운 코드가 배포되면 수분 이내에 테스트가 실행되고 그 결과를 거의 실시간으로 Kibana를 통해 확인할 수 있게 됩니다.

이렇게 반복적으로 수행되는 회귀 테스트는 예상하지 않은 변경이나 부작용을 발견 하기 위해 수행합니다. 이 회귀 테스트를 통해서 최근에 배포된 서버에서 이전 테스트에서는 발견되지 않았던 문제가 발견됐고 빠르게 테스트 결과를 피드백 받고 조치할 수 있었습니다.

아래 이미지처럼 테스트가 실패했을 때는 테스트가 왜 실패했는지 상세 내용을 확인할 수 있고 한눈에 테스트 상태를 확인할 수도 있습니다.

마무리

최근 두어 달 정도 테스트를 자동화하기 위해 틈틈이 코드를 쓰고 시스템을 운영하고 있습니다. 종종 이렇게 만들어 놓은 것이 ‘과연 동료에게 도움이 될까?’ 혹은 ‘과연 개발 생산성이 좋아지는 데 기여하고 있는 게 맞나?’라는 생각이 들때가 있었는데요. 마침 관련된 시스템의 RDS를 교체하는 과제를 진행하면서 RDS 교체 후 약 10분 만에 부작용이 없음을 확인할 수 있었고 가장 많은 도움을 주신 동료분도 만족(?)하는 것 같았습니다.

비록 아직까지 팀 업무 도메인의 특정 영역에 대해서만 적용된 상태라 팀에 큰 기여를 못 하고 있지만, 회귀 테스트를 수동으로 하는 횟수와 노력이 이전에 비해 줄어들고, 테스트 결과를 이전보다 빠르게 피드백 받고, 테스트에 대해 개발자분들과 더 많은 이야기를 나누고 새롭게 알게 된 것들을 다시 테스트에 적용할 수 있어서 만족하고 있습니다.

무엇보다도 이 과정에서 저 스스로 부족한 것이 무엇인지 명확하게 알게 되었고 개선하기 위한 노력을 시작했다는 점은 개인적으로 큰 성과라고 생각하고 있습니다.

부족한 글 읽어주셔서 고맙습니다.