실용적인 테스트 피라미드 (번역)

3개월간 개발한 마이크로서비스에 498개의 테스트 코드를 작성한 시니어 개발자가 추천해준 <The Practical Test Pyramid>을 번역했다. 테스트의 종류와 이유, 철학을 Java, SpringBoot, JUnit을 사용한 예시와 함께 설명한다.

실용적인 테스트 피라미드 (번역)
테스트 피라미드 (이미지 출처: https://martinfowler.com/articles/microservice-testing/#conclusion-test-pyramid)
원글: The Practical Test Pyramid
The Practical Test Pyramid
Find out what kinds of automated tests you should implement for your application and learn by examples what these tests could look like.

이 글을 번역하는 이유

최근에 이직한 미국 회사에 테스트에 진심인 시니어 개발자가 있었다. 그가 혼자서 약 3개월간 개발한 Java/Spring 기반의 마이크로서비스는 무려 498개의 테스트가 작성되었다.

물론 개수가 많다고 무조건 좋은 테스트는 아니지만, 그는 필요한 테스트들만 견고하게 작성했고, 다양한 종류와 범위의 테스트를 깔끔하게 구성하고 읽기 편하게 작성했으며, JUnit5와 Mockito 등의 테스트 라이브러리의 기능들을 풍부하게 활용해서 배울 점이 많았다.

심지어 테스트하기 쉬운 프로젝트도 아니었다:

  1. 외부 서비스들 및 사내 마이크로서비스들간의 복잡한 연계가 필요한 서비스였고,
  2. 1980년대부터 현재까지 운영중인 사내 레거시 시스템(BASIC과 COBOL로 개발됨)의 동작은 일정하지 않았으며,
  3. 암호화되지 않은 개인정보들 때문에 DB 접근 권한이 제한되어 실시간 데이터를 얻을 수 없는 반면, 비즈니스 관리자들은 데이터를 직접 변경할 수 있었으며,
  4. 다양한 이해관계자들에 의해 요구사항이 수없이 바뀌었고,
  5. 시간 및 위치 데이터에 민감한 서비스라 미국 각 지역의 시차에 따라 다른 동작이 필요한 까다로운 서비스였다.

이정도로 복잡한 백엔드 서비스들은 봐왔지만 이렇게 확실하게 테스트하는 경우는 본 적이 없어서, 이래서 차는 독일차를 사라는건가..🤔라는 구시대적 선입견이 스멀스멀 피어오르기도 했다. (그는 독일 혹은 폴란드 출신이다)

​그에게 너처럼 테스트 코드 작성하려면 어디서부터 시작해야해? 라고 물었는데, 그는 곧바로 Test Pyramid를 먼저 이해하는게 좋아라며 이 글과 Testing Strategies in a MicroService를 추천해줬다. 그래서 읽는 김에 번역! 🙃

모든 이미지의 출처는 원글입니다. (https://martinfowler.com/articles/practical-test-pyramid.html)

📌 목차

  1. 테스트 자동화의 중요성
  2. 테스트 피라미드
  3. 샘플 어플리케이션
  4. 단위 테스트 (Unit Test)
  5. 통합 테스트 (Integration Test)
  6. 계약 테스트 (Contract Test)
  7. UI 테스트
  8. End-to-End 테스트
  9. 인수 테스트 (Acceptance Test)
  10. 탐색 테스트 (Exploratory Testing)
  11. 테스트 용어에 대한 혼란
  12. 배포 파이프라인에 테스트 넣기
  13. 테스트 중복 피하기
  14. 깔끔한 테스트 코드 작성
  15. 결론

소프트웨어는 배포 전에 테스트가 필요합니다. 소프트웨어 개발이 발전함에 따라 소프트웨어 테스트 방식도 발전했습니다. 개발팀은 수많은 소프트웨어 테스터를 보유하는 대신 테스트를 자동화하는 방향으로 발전했습니다. 테스트를 자동화하면 며칠, 몇 주가 아니라 단 몇 초, 몇 분 만에 소프트웨어의 고장 여부를 알 수 있습니다.

이 글에서는 마이크로서비스 아키텍처, 모바일 앱, IoT 에코시스템 등 구축 대상에 관계없이 응답성, 안정성, 유지보수성을 갖춘 균형 잡힌 테스트 포트폴리오가 어떤 모습이어야 하는지 살펴봅니다. 또한 효과적이고 가독성 높은 자동화된 테스트를 구축하는 방법에 대해서도 자세히 살펴볼 것입니다.

테스트 자동화의 중요성

소프트웨어는 우리가 살고 있는 세상의 필수적인 부분이 되었습니다. 소프트웨어는 비즈니스의 효율성을 높인다는 초기의 목적을 넘어섰고, 혁신의 수레바퀴는 점점 더 빠르게 돌아가고 있습니다.

이에 발 맞추려면 품질 저하 없이 소프트웨어를 더 빠르게 제공할 수 있는 방법을 모색해야 합니다. 소프트웨어가 언제든지 배포될 수 있도록 자동으로 보장하는 지속적 배포를 사용하면, 빌드 파이프라인을 사용하여 소프트웨어를 자동으로 테스트하고 프로덕션 환경에 배포할 수 있습니다.

수동적이고 반복적인 작업에 시간을 허비하고 싶지 않다면 빌드부터 테스트, 배포 및 인프라에 이르기까지 모든 것을 자동화하는 것이 유일한 방법입니다.

빌드 파이프라인
Figure 1: Use build pipelines to automatically and reliably get your software into production

기존 소프트웨어 테스트는 어플리케이션을 테스트 환경에 배포한 다음 사용자 인터페이스를 클릭해 문제가 있는지 확인하는 등의 수작업으로 이루어졌습니다. 이러한 테스트는 보통 테스터가 일관된 검사를 수행할 수 있도록 테스트 스크립트에 의해 지정됩니다.

모든 변경 사항을 수동으로 테스트하는 것은 시간이 많이 걸리고 반복적이며 지루합니다. 반복은 지루하고 지루함은 실수로 이어지며 주말이 되면 다른 일을 찾게 됩니다.

다행히도 반복적인 작업에 대한 해결책이 있습니다: 자동화

반복적인 테스트를 자동화하면 더 이상 소프트웨어가 제대로 작동하는지 확인하기 위해 무의식적으로 테스트 절차를 따를 필요가 없습니다. 테스트를 자동화하면 눈 하나 깜짝하지 않고 코드베이스를 변경할 수 있습니다. 적절한 테스트 도구 없이 대규모 리팩토링을 시도해 본 적이 있다면 이것이 얼마나 무서운 경험인지 잘 알고 계실 것입니다.

도중에 실수로 무언가를 망가뜨렸는지 어떻게 알 수 있을까요? 모든 수동 테스트 케이스를 살펴보는 것이 바로 그 방법입니다. 하지만 솔직히 말씀드리자면 이 과정이 정말 즐겁나요? 커피를 한 모금 마시면서 큰 규모의 변경 사항을 적용하고 몇 초 안에 문제 여부를 알 수 있다면 어떨까요?


테스트 피라미드

자동화된 테스트를 위해서는 테스트 피라미드라는 핵심 개념을 알아야 합니다. Mike Cohn은 그의 저서 Succeeding with Agile에서 이 개념을 고안했습니다. 테스트 피라미드는 다양한 테스트 계층에 대해 생각하도록 돕는 훌륭한 시각적 은유입니다. 또한 각 계층에서 얼마나 많은 테스트를 수행해야 하는지도 알려줍니다.

테스트 피라미드
Figure 2: The Test Pyramid

Mike Cohn의 테스트 피라미드는 세 가지 계층으로 구성되어 있습니다:

  1. 단위 테스트
  2. 서비스 테스트
  3. 사용자 인터페이스 테스트

안타깝게도 테스트 피라미드의 개념은 자세히 살펴보면 약간 부족합니다. Mike Cohn의 테스트 피라미드의 이름이나 일부 개념적 측면이 이상적이지 않다고 주장하는 사람들도 있는데, 저도 동의합니다. 현대적인 관점에서 보면 이 테스트 피라미드는 지나치게 단순해 보이므로 오해의 소지가 있을 수 있습니다.

그럼에도 불구하고 테스트 피라미드의 본질은 단순하기 때문에 테스트를 구축할 때 도움이 됩니다. 가장 좋은 방법은 Cohn의 원래 테스트 피라미드에서 두 가지를 기억하는 것입니다:

  1. 다양한 테스트 작성
  2. 상위 계층으로 갈수록 더 적은 테스트 작성

작고 빠른 단위 테스트를 많이 작성하세요. 애플리케이션을 처음부터 끝까지 테스트하는 높은 수준의 테스트는 거의 작성하지 말고 좀 더 세분화된 테스트를 작성하세요. 유지 관리가 어렵고 실행하는 데 너무 오래 걸리는 테스트를 작성하지 않도록 주의하세요.

Cohn의 테스트 피라미드에서 각 계층의 이름에 너무 집착하지 마세요. 사실 서비스 테스트는 이해하기 어려운 용어입니다(많은 개발자가 이 계층을 완전히 무시하는 경우가 많다고 Cohn 자신도 이야기합니다). react, angular, ember.js 등과 같은 단일 페이지 애플리케이션 프레임워크의 시대에는 UI 테스트가 피라미드의 최상위 레벨에 있을 필요가 없으며 이러한 모든 프레임워크에서 UI를 완벽하게 단위 테스트할 수 있다는 것이 분명해졌습니다.

원래 이름의 단점을 고려할 때, 코드베이스와 팀의 논의에서 일관성을 유지한다면 테스트 계층에 다른 이름을 붙이는 것도 괜찮습니다.


샘플 어플리케이션

저는 테스트 피라미드의 여러 계층에 대한 테스트가 포함된 Spring-Testing 저장소를 만들었습니다. 👇

GitHub - hamvocke/spring-testing: A Spring Boot application with lots of test examples
A Spring Boot application with lots of test examples - hamvocke/spring-testing

이 샘플 애플리케이션은 일반적인 마이크로서비스의 특징을 보여줍니다. 이 애플리케이션은 REST 인터페이스를 제공하고 데이터베이스와 통신하며 타사 REST 서비스에서 정보를 가져옵니다. 이 애플리케이션은 Spring Boot로 구현되어 있으며 이전에 Spring Boot로 작업한 적이 없더라도 이해할 수 있습니다.

Github에서 코드를 확인하세요. README에는 어플리케이션을 실행하는 데 필요한 지침과 자동화된 테스트가 포함되어 있습니다.

기능

이 어플리케이션의 기능은 간단합니다. 세 개의 엔드포인트가 있는 REST 인터페이스를 제공합니다:

  • GET /hello 👉 “Hello World”를 반환합니다. 항상.
  • GET /hello/{성} 👉 제공된 성을 가진 사람을 조회합니다. 알려진 사람인 경우 “안녕하세요 {이름} {성}”을 반환합니다.
  • GET /weather 👉 독일 함부르크의 현재 날씨 상태를 반환합니다.

시스템 구조

시스템은 다음과 같은 구조를 가지고 있습니다:

마이크로서비스 시스템 구조
Figure 3: the high level structure of our microservice system

이 마이크로서비스는 HTTP를 통해 호출할 수 있는 REST 인터페이스를 제공합니다. 일부 엔드포인트의 경우 데이터베이스에서 정보를 가져옵니다. 그 외에​ HTTP를 통해 외부 날씨 API를 호출하여 현재 날씨 상태를 가져와 표시합니다.

내부 아키텍처

내부적으로 Spring의 전형적인 아키텍처를 가지고 있습니다:

Figure 4: the internal structure of our microservice
Figure 4: the internal structure of our microservice
  • Controller 클래스는 REST 엔드포인트를 제공하고 HTTP 요청 및 응답을 처리합니다.
  • Repository 클래스는 데이터베이스와 인터페이스하고 퍼시스턴트 스토리지에 데이터를 쓰고 읽는 작업을 처리합니다.
  • Client 클래스는 다른 API와 통신하며, 저희의 경우 darksky.net 날씨 API에서 HTTPS를 통해 JSON을 가져옵니다.
  • Domain 클래스는 도메인 로직을 포함한 도메인 모델을 캡처합니다(이 어플리케이션에서는 아주 사소한 부분입니다).

경험이 많은 Spring 개발자는 자주 사용하는 레이어가 누락된 것을 발견할 수 있습니다: Domain-Driven Design(도메인 중심 설계)에서 영감을 받아 많은 개발자들이 서비스 클래스로 구성된 서비스 레이어를 구축합니다. 저는 이 애플리케이션에 서비스 레이어를 포함하지 않기로 결정했습니다.

한 가지 이유는 애플리케이션이 충분히 단순하기 때문에 서비스 계층은 불필요했습니다. 다른 하나는 사람들이 서비스 계층을 지나치게 많이 사용한다고 생각하기 때문입니다. 저는 종종 전체 비즈니스 로직이 서비스 클래스 내에 캡처된 코드베이스를 접하곤 합니다. 도메인 모델은 동작을 위한 것이 아니라 데이터를 위한 계층이 될 뿐입니다(Anemic Domain Model). 일반적인 애플리케이션에서 이러한 방식은 코드를 잘 구조화하고 테스트할 수 있는 잠재력을 낭비하고 객체 지향의 힘을 충분히 활용하지 못합니다.

저희 repository는 간단하고 간단한 CRUD 기능을 제공합니다. 코드를 단순하게 유지하기 위해 Spring Data를 사용했습니다. Spring Data는 직접 롤링하는 대신 사용할 수 있는 간단하고 일반적인 CRUD repository 구현을 제공합니다. 또한 프로덕션에서와 같이 실제 PostgreSQL 데이터베이스를 사용하는 대신 테스트를 위해 인메모리 데이터베이스를 띄워주는 작업도 처리해 줍니다.

코드베이스를 살펴보고 내부 구조에 익숙해지도록 하세요. 다음 단계에 유용할 것입니다: 어플리케이션 테스트!


단위 테스트 (Unit Test)

테스트의 기초는 단위 테스트로 구성됩니다. 단위 테스트는 코드베이스의 특정 단위(테스트 대상)가 의도한 대로 작동하는지 확인합니다. 단위 테스트는 모든 테스트 중 범위가 가장 좁습니다. 단위 테스트는 다른 유형의 테스트보다 훨씬 더 많을 것입니다.

유닛 테스트
Figure 5: A unit test typically replaces external collaborators with test doubles

단위(Unit)란 무엇인가?

세 사람에게 단위 테스트의 맥락에서 '단위'가 무엇을 의미하는지 물어본다면 아마도 약간 미묘한 차이가 있는 네 가지 대답을 들을 수 있을 것입니다. 어느 정도는 각자의 정의에 따른 문제이며 정답이 없어도 괜찮습니다.

함수형 언어로 작업하는 경우 단위는 단일 함수가 될 가능성이 높습니다. 단위 테스트는 다양한 매개 변수를 사용하여 함수를 호출하고 예상되는 값을 반환하는지 확인합니다. 객체 지향 언어에서 단위는 단일 메서드부터 전체 클래스까지 다양할 수 있습니다.

사교적이거나 고독한 (Sociable and Solitary)

이 부분과 관련해서는 Martin Fowler의 Unit Test 글을 읽어보면 좋습니다.

어떤 사람들은 완벽한 격리와 복잡한 테스트 설정을 피하기 위해 테스트 대상 주제의 모든 공동 작업자(예: 테스트 대상 클래스에서 호출하는 다른 클래스)를 Mock 또는 Stub으로 대체해야 한다고 주장합니다. 반면, 어떤 사람들은 속도가 느리거나 부작용이 더 큰 공동 작업자(예: 데이터베이스에 접근하거나 네트워크 호출을 하는 클래스)만 Stub이나 Mock 테스트를 해야 한다고 주장합니다.

사람들은 모든 공동 작업자를 Stub하는 테스트는 고독한 단위 테스트, 실제 공동 작업자와 대화할 수 있는 테스트는 사교적인 단위 테스트라고 부릅니다(Jay Fields의 Working Effectively with Unit Tests 에서 이러한 용어가 만들어졌습니다). 시간이 여유가 있다면 다른 학파의 장단점(Martin Fowler의 Mocks Aren't Stubs)에 대해 자세히 읽어볼 수 있습니다.

결국, 고독한 단위 테스트를 할 것인지 사교적인 단위 테스트를 할 것인지는 중요하지 않습니다. 중요한 것은 자동화된 테스트를 작성하는 것입니다. 개인적으로 저는 두 가지 접근 방식을 항상 사용하고 있습니다. 실제 공동 작업자를 사용하는 것이 어색하다면 Mock 테스트와 Stub을 아낌없이 사용하죠. 실제 공동 작업자를 참여시키는 것이 테스트에 더 확신을 줄 것 같으면 서비스의 가장 바깥쪽 부분만 Stub을 사용합니다.

Mocking과 Stubbing

Mock과 Stub은 서로 다른 두 가지 종류의 테스트 더블입니다(이 두 가지 외에도 여러 가지가 있습니다). 많은 사람들이 Mock과 Stub이라는 용어를 혼용해서 사용합니다. 정확하게 구분하고 각각의 특성을 염두에 두는 것이 좋다고 생각합니다. 테스트 더블을 사용하면 프로덕션에서 사용할 객체를 테스트에 도움이 되는 구현으로 대체할 수 있습니다.

쉽게 말해 클래스, 모듈 또는 함수 등의 실제 객체를 가짜 버전으로 대체한다는 뜻입니다. 가짜 버전은 실제처럼 보이고 작동하지만(동일한 메서드 호출에 대한 응답), 단위 테스트를 시작할 때 직접 정의한 미리 준비된 응답으로 응답합니다.

테스트 더블을 사용하는 것은 단위 테스트에만 국한되지 않습니다. 보다 정교한 테스트 더블을 사용하여 시스템의 전체 부분을 제어된 방식으로 시뮬레이션할 수 있습니다. 그러나 단위 테스트에서는 많은 최신 언어와 라이브러리를 통해 Mock과 Stub을 쉽고 편하게 설정할 수 있기 때문에 (사교적인 개발자인지 고독한 개발자인지에 따라) Mock과 Stub을 많이 접하게 될 가능성이 높습니다.

어떤 기술을 선택하든 해당 언어의 표준 라이브러리나 인기 있는 타사 라이브러리에서 모형을 설정하는 우아한 방법을 제공할 가능성이 높습니다. 또한 처음부터 직접 모의 테스트를 작성하는 경우에도 실제와 동일한 서명을 가진 가짜 클래스/모듈/함수를 작성하고 테스트에서 가짜를 설정하기만 하면 됩니다.

그럼 단위 테스트가 매우 빠르게 실행될 겁니다. 괜찮은 머신에서는 몇 분 안에 수천 개의 단위 테스트를 실행할 수 있습니다. 코드베이스의 작은 부분을 따로 테스트하고 데이터베이스, 파일 시스템 또는 HTTP 쿼리 실행(이러한 부분에 모의와 스텁을 사용하여)을 피하면 테스트 속도를 빠르게 유지할 수 있습니다.

단위 테스트 작성에 익숙해지면 점점 더 유창하게 작성할 수 있게 됩니다. 외부 공동 작업자를 Stub으로 설정하고, 입력 데이터를 설정하고, 테스트 대상에 호출하여 반환된 값이 예상한 것과 일치하는지 확인하세요. Test-Driven Development(TDD, 테스트 중심 개발)을 살펴보고 단위 테스트가 개발을 가이드 하도록 하세요. 올바르게 적용하면 종합적이고 완전 자동화된 테스트를 자동으로 생성하면서 훌륭한 흐름에 빠져들고 유지 관리가 잘되는 좋은 디자인을 생각해내는 데 도움이 될 수 있습니다. 하지만 만병통치약은 아닙니다. 실제로 사용해 보고 자신에게 적합한지 확인해 보세요.

무엇을 테스트 할 것인가?

단위 테스트의 좋은 점은 기능이나 내부 구조의 어느 계층에 속해 있는지에 관계없이 모든 프로덕션 코드 클래스에 대해 작성할 수 있다는 것입니다. Repository, 도메인 클래스 또는 파일 리더를 단위 테스트하는 것처럼 컨트롤러를 단위 테스트할 수도 있습니다. 프로덕션 클래스당 하나의 테스트 클래스 규칙을 준수하기만 하면 좋은 출발을 할 수 있습니다.

단위 테스트 클래스는 최소한 클래스의 공용 인터페이스(public interface of the class)는 테스트해야 합니다. 비공개 메서드는 다른 테스트 클래스에서 호출할 수 없으므로 테스트할 수 없습니다. 테스트 클래스의 패키지 구조가 프로덕션 클래스와 동일하다면 테스트 클래스에서 보호되거나 패키지 비공개 메서드에 액세스할 수 있지만 이러한 메서드를 테스트하는 것은 이미 너무 멀리 갈 수 있습니다.

단위 테스트를 작성할 때는 미세한 경계가 있습니다:

  • 사소하지 않은 모든 코드 경로(happy path 및 edge case 포함)를 테스트해야 합니다.
  • 동시에 구현과 너무 밀접하게 연결되어서는 안 됩니다.

왜 그럴까요?

프로덕션 코드에 너무 근접한 테스트는 금방 성가시게 됩니다. 프로덕션 코드를 리팩토링(간단히 요약하자면, 리팩토링은 외부에 보이는 동작을 변경하지 않고 코드의 내부 구조를 변경하는 것을 의미합니다)하는 즉시 단위 테스트가 중단됩니다.

이렇게 하면 단위 테스트의 큰 장점 중 하나인 코드 변경에 대한 안전망 역할을 잃게 됩니다. 오히려 리팩토링할 때마다 실패하는 멍청한 테스트에 지쳐서 도움이 되기는커녕 더 많은 작업을 유발하게 됩니다.

단위 테스트에 내부 코드 구조를 반영하지 마세요. 대신 관찰 가능한 동작을 테스트하세요. 예를 들어,

값 x와 y를 입력하면 메서드가 A 클래스를 먼저 호출한 다음 B 클래스를 호출한 다음 A 클래스의 결과와 B 클래스의 결과를 더한 값을 반환할까? 대신

값 x와 y를 입력하면 결과가 z가 될까? 를 테스트해야 합니다.

비공개 메서드는 일반적으로 구현 세부 사항으로 간주해야 합니다. 그렇기 때문에 테스트하고 싶은 충동조차 느껴서는 안 됩니다.

비공개 메서드를 반드시 테스트하고 싶다면? (펼치기)

비공개 메소드를 정말 테스트해야 하는 상황에 처했다면 한 걸음 물러서서 그 이유를 자문해 보세요.

이것은 범위 지정 문제라기보다는 디자인 문제일 가능성이 높습니다. 아마도 비공개 메서드는 복잡하고 클래스의 공용 인터페이스를 통해 이 메서드를 테스트하려면 많은 어색한 설정이 필요하기 때문에 테스트할 필요성을 느끼는 것 같습니다.

이런 상황에 처할 때마다 저는 보통 테스트 중인 클래스가 이미 너무 복잡하다는 결론에 도달합니다. 너무 많은 작업을 수행하고 있으며 5가지 SOLID 원칙 중 S인 단일 책임 원칙을 위반하고 있습니다.

저에게 종종 효과가 있는 해결책은 원래 클래스를 두 개의 클래스로 분할하는 것입니다. 하나의 큰 클래스를 개별 책임이 있는 두 개의 작은 클래스으로 나누는 좋은 방법을 찾는 데는 1~2분 정도만 생각하면 되는 경우가 많습니다.

긴급하게 테스트하고 싶은 개인 메서드를 새 클래스로 옮기고 이전 클래스에서 새 메서드를 호출하도록 합니다. 짜잔, 테스트하기 어려웠던 비공개 메서드가 이제 공개 메서드가 되어 쉽게 테스트할 수 있게 되었습니다. 또한 단일 책임 원칙을 준수하여 코드의 구조를 개선했습니다.

단위 테스트(또는 TDD )에 반대하는 사람들은 단위 테스트를 작성하는 것이 높은 테스트 커버리지를 얻기 위해 모든 메서드를 테스트해야 하는 무의미한 작업이 된다고 주장하는 것을 종종 듣습니다. 그들은 종종 지나치게 열성적인 팀 리더가 테스트 커버리지 100%를 달성하기 위해 Getter와 Setter 및 기타 모든 종류의 사소한 코드에 대한 단위 테스트를 작성하도록 강요하는 시나리오를 예로 들기도 합니다.

이는 잘못된 것입니다.

예, 공용 인터페이스를 테스트해야 합니다. 하지만 더 중요한 것은 사소한 코드를 테스트하지 않는 것입니다. 켄트 벡이 괜찮다고 했으니 걱정하지 마세요. 조건부 로직이 없는 단순한 Getter나 Setter 또는 기타 사소한 구현을 테스트한다고 해서 얻을 수 있는 것은 아무것도 없습니다. 시간을 절약할 수 있으니 회의에 참석할 수 있는 시간이 하나 더 늘어납니다!

테스트 구조

모든 테스트에 적합한 구조(단위 테스트에만 국한되지 않음)는 다음과 같습니다:

  1. 테스트 데이터를 설정합니다.
  2. 테스트 중인 메서드를 호출합니다.
  3. 예상 결과가 반환되는지 확인합니다.

이 구조를 기억하기 위한 좋은 니모닉(mnemonic)이 있습니다: “Arrange, Act, Assert”. 사용할 수 있는 또 다른 니모닉은 BDD 에서 영감을 얻은 것입니다. “given”, “when”, “then” 3인조로, 여기서 given은 설정, when은 메서드 호출, then은 검증 부분을 반영합니다.

이 패턴은 더 높은 수준의 다른 테스트에도 적용할 수 있습니다. 어떤 경우든 테스트가 읽기 쉽고 일관성을 유지하도록 보장합니다. 또한 이 구조를 염두에 두고 작성된 테스트는 더 짧고 표현력이 뛰어난 경향이 있습니다.

단위 테스트 구현하기

이제 무엇을 테스트하고 단위 테스트를 구성하는 방법을 알았으니 이제 실제 예제를 살펴보겠습니다.

우선 ExampleController의 단순화된 버전을 살펴보겠습니다:

@RestController
public class ExampleController {

    private final PersonRepository personRepo;

    @Autowired
    public ExampleController(final PersonRepository personRepo) {
        this.personRepo = personRepo;
    }

    @GetMapping("/hello/{lastName}")
    public String hello(@PathVariable final String lastName) {
        Optional<Person> foundPerson = personRepo.findByLastName(lastName);

        return foundPerson
                .map(person -> String.format("Hello %s %s!",
                        person.getFirstName(),
                        person.getLastName()))
                .orElse(String.format("Who is this '%s' you're talking about?",
                        lastName));
    }
}

hello(lastName) 메서드에 대한 단위 테스트는 다음과 같습니다:

public class ExampleControllerTest {

    private ExampleController subject;

    @Mock
    private PersonRepository personRepo;

    @Before
    public void setUp() throws Exception {
        initMocks(this);
        subject = new ExampleController(personRepo);
    }

    @Test
    public void shouldReturnFullNameOfAPerson() throws Exception {
        Person peter = new Person("Peter", "Pan");
        given(personRepo.findByLastName("Pan"))
            .willReturn(Optional.of(peter));

        String greeting = subject.hello("Pan");

        assertThat(greeting, is("Hello Peter Pan!"));
    }

    @Test
    public void shouldTellIfPersonIsUnknown() throws Exception {
        given(personRepo.findByLastName(anyString()))
            .willReturn(Optional.empty());

        String greeting = subject.hello("Pan");

        assertThat(greeting, is("Who is this 'Pan' you're talking about?"));
    }
}

특화된 테스트 도우미 - MockMvc (펼치기)

어플리케이션 아키텍처의 어떤 계층에 있든 상관없이 전체 코드베이스에 대한 단위 테스트를 작성할 수 있다는 것은 정말 멋진 일입니다. 이 예는 컨트롤러에 대한 간단한 단위 테스트를 보여줍니다.

하지만 안타깝게도 Spring의 컨트롤러에는 이 접근 방식에 단점이 있습니다: Spring MVC의 컨트롤러는 어노테이션을 많이 사용하여 수신 중인 경로, 사용할 HTTP 동사, URL 경로에서 파싱할 매개변수 또는 쿼리 매개변수 등을 선언합니다. 단위 테스트 내에서 컨트롤러의 메서드를 호출하는 것만으로는 이러한 중요한 것들을 모두 테스트할 수 없습니다. 다행히도 Spring 개발자들은 더 나은 컨트롤러 테스트를 작성하는 데 사용할 수 있는 멋진 테스트 도우미를 만들어냈습니다.

MockMVC를 꼭 확인해 보세요. 컨트롤러에 대해 가짜 요청을 실행하고 모든 것이 정상인지 확인하는 데 사용할 수 있는 멋진 DSL을 제공합니다. 샘플 코드베이스에 예제가 포함되어 있습니다. 많은 프레임워크가 코드베이스의 특정 측면을 더 쉽게 테스트할 수 있도록 테스트 도우미를 제공합니다. 선택한 프레임워크의 설명서를 확인하여 자동화된 테스트에 유용한 헬퍼를 제공하는지 확인하세요.

사실상의 Java 표준 테스트 프레임워크인 JUnit을 사용하여 단위 테스트를 작성하고 있습니다. Mockito를 사용하여 실제 PersonRepository 클래스를 테스트용 Stub으로 대체합니다. 이 Stub을 사용하면 이 테스트에서 Stub된 메서드가 반환해야 하는 미리 준비된 응답을 정의할 수 있습니다. Stub을 사용하면 테스트가 더 간단하고 예측 가능하며 테스트 데이터를 쉽게 설정할 수 있습니다.

arrange, act, assert 구조에 따라 두 가지 단위 테스트, 즉 긍정적인 경우와 검색된 사람을 찾을 수 없는 경우를 작성합니다.

첫 번째 테스트 케이스는 새로운 Person 객체를 생성하고 mocked repository가 매개변수의 값으로 “Pan”을 사용하여 호출될 때 이 객체를 반환하도록 지시합니다. 그런 다음 테스트해야 하는 메서드를 호출합니다. 마지막으로 응답이 예상 응답과 동일한지 확인합니다. 두 번째 테스트는 비슷하게 작동하지만 테스트된 메서드가 주어진 매개 변수에 대한 결과를 찾지 못하는 시나리오를 테스트합니다.


통합 테스트 (Integration Test)

대부분의 어플리케이션은 다른 부분(데이터베이스, 파일 시스템, 다른 어플리케이션에 대한 네트워크 호출)과 통합됩니다. 단위 테스트를 작성할 때는 일반적으로 더 나은 격리 및 더 빠른 테스트를 위해 이러한 부분을 생략합니다. 하지만 어플리케이션은 다른 부분과 상호 작용하므로 이를 테스트해야 합니다. 통합 테스트가 도움이 될 수 있습니다. 통합 테스트는 어플리케이션 외부에 있는 모든 부분과 어플리케이션의 통합을 테스트합니다.

자동화된 테스트의 경우 자체 어플리케이션뿐만 아니라 통합하는 컴포넌트도 실행해야 한다는 의미입니다. 데이터베이스와의 통합을 테스트하는 경우 테스트를 실행할 때 데이터베이스를 실행해야 합니다. 디스크에서 파일을 읽을 수 있는지 테스트하려면 파일을 디스크에 저장하고 통합 테스트에서 로드해야 합니다.

앞서 '단위 테스트'가 모호한 용어라고 말씀드렸는데, '통합 테스트'의 경우 더욱 그렇습니다. 어떤 사람들에게 통합 테스트는 시스템 내의 다른 애플리케이션에 연결된 애플리케이션의 전체 스택을 통해 테스트하는 것을 의미합니다. 저는 통합 테스트를 좀 더 좁게 정의하고 별도의 서비스와 데이터베이스를 테스트 더블로 대체하여 한 번에 하나의 통합 지점을 테스트하는 것을 선호합니다. 계약 테스트와 함께 테스트 더블과 실제 구현에 대해 계약 테스트를 실행하면 더 빠르고 독립적이며 일반적으로 더 쉽게 추론할 수 있는 통합 테스트를 만들 수 있습니다.

좁은 통합 테스트는 서비스 경계에서 진행됩니다. 개념적으로는 항상 외부 부분(파일 시스템, 데이터베이스, 별도의 서비스)과의 통합으로 이어지는 작업을 트리거하는 것입니다. 데이터베이스 통합 테스트는 다음과 같습니다:

DB 통합 테스트
Figure 6: A database integration test integrates your code with a real database
  1. 데이터베이스 시작
  2. 어플리케이션을 데이터베이스에 연결합니다.
  3. 데이터베이스에 데이터를 쓰는 코드 내 함수를 트리거합니다.
  4. 데이터베이스에서 데이터를 읽어 예상 데이터가 데이터베이스에 기록되었는지 확인합니다.

또 다른 예로, 서비스가 REST API를 통해 별도의 서비스와 통합되는지 테스트하는 것은 다음과 같습니다:

Figure 7: This kind of integration test checks that your application can communicate with a separate service correctly
Figure 7: This kind of integration test checks that your application can communicate with a separate service correctly
  1. 어플리케이션을 시작합니다.
  2. 별도의 서비스 인스턴스(또는 동일한 인터페이스를 가진 테스트 더블)를 시작합니다.
  3. 코드 내에서 별도의 서비스 API에서 읽는 함수를 트리거합니다.
  4. 어플리케이션이 응답을 올바르게 구문 분석할 수 있는지 확인합니다.

통합 테스트는 내부 구조와 동작을 테스트하는 화이트박스 테스트가 될 수 있습니다. 일부 프레임워크에서는 어플리케이션을 시작하면서 어플리케이션의 다른 부분을 Mocking하여 올바른 상호 작용이 발생했는지 확인할 수 있습니다.

데이터를 직렬화하거나 역직렬화하는 모든 코드에 대해 통합 테스트를 작성하세요. 이런 일은 생각보다 자주 발생합니다. 생각해 보세요:

  1. 서비스의 REST API 호출
  2. 데이터베이스에서 읽기 및 쓰기
  3. 다른 어플리케이션의 API 호출
  4. 대기열에서 읽기 및 쓰기
  5. 파일시스템에 쓰기

이러한 경계를 중심으로 통합 테스트를 작성하면 이러한 외부 협업자에게 데이터를 쓰고 외부 협업자로부터 데이터를 읽는 것이 제대로 작동하는지 확인할 수 있습니다.

좁은 범위의 통합 테스트를 작성할 때는 외부 종속성을 로컬에서 실행하는 것을 목표로 해야 합니다. 로컬 MySQL 데이터베이스를 띄우고 로컬 ext4 파일시스템에 대해 테스트하세요. 별도의 서비스와 통합하는 경우에는 해당 서비스의 인스턴스를 로컬에서 실행하거나 실제 서비스의 동작을 모방한 가짜 버전을 빌드하여 실행하세요.

타사 서비스를 로컬에서 실행할 방법이 없는 경우 전용 테스트 인스턴스를 실행하도록 선택하고 통합 테스트를 실행할 때 이 테스트 인스턴스를 가리켜야 합니다. 자동화된 테스트에서 실제 프로덕션 시스템과 통합하지 마세요. 프로덕션 시스템에 대해 수천 개의 테스트 요청을 폭파하는 것은 로그를 복잡하게 만들거나(가장 좋은 경우) 최악의 경우 서비스를 디도스 공격하기 때문에 사람들을 화나게 할 수 있는 확실한 방법입니다. 네트워크를 통해 서비스와 통합하는 것은 광범위한 통합 테스트의 일반적인 특징이며 테스트 속도가 느려지고 일반적으로 작성하기가 더 어려워집니다.

테스트 피라미드에서 통합 테스트는 단위 테스트보다 상위 레벨에 있습니다. 파일 시스템 및 데이터베이스와 같이 느린 부분을 통합하는 것은 이러한 부분을 제외한 상태에서 단위 테스트를 실행하는 것보다 훨씬 느린 경향이 있습니다. 또한 테스트 내부에서 외부 파트를 띄워야 하기 때문에 소규모의 격리된 단위 테스트보다 작성하기가 더 어려울 수 있습니다. 하지만 어플리케이션이 대화해야 하는 모든 외부 부품과 올바르게 작동할 수 있다는 확신을 줄 수 있다는 장점이 있습니다. 단위 테스트는 이를 도와줄 수 없습니다.

데이터베이스 통합

PersonRepository는 코드베이스에서 유일한 repository 클래스입니다. Spring 데이터에 의존하며 실제 구현은 없습니다. CrudRepository 인터페이스를 확장하고 단일 메서드 헤더만 제공할 뿐입니다. 나머지는 Spring의 마법입니다.

public interface PersonRepository extends CrudRepository<Person, String> {
    Optional<Person> findByLastName(String lastName);
}

CrudRepository 인터페이스를 통해 Spring Boot는 findOne, findAll, save, updatedelete 메서드를 갖춘 완전한 기능의 CRUD 리포지토리를 제공합니다. 사용자 정의 메서드(findByLastName())는 이 기본 기능을 확장하여 성으로 사람을 가져올 수 있는 방법을 제공합니다. Spring Data는 메서드의 반환 유형과 메서드 이름을 분석하고 명명 규칙과 비교하여 메서드 이름을 확인하여 수행해야 할 작업을 파악합니다.

Spring Data가 데이터베이스 repository를 구현하는 무거운 작업을 수행하지만 저는 여전히 데이터베이스 통합 테스트를 작성했습니다. 이것은 프레임워크를 테스트하는 것이므로 테스트하는 코드가 아니므로 피해야 한다고 주장할 수도 있습니다. 하지만 저는 여기서 적어도 하나의 통합 테스트를 하는 것이 중요하다고 생각합니다. 먼저 사용자 정의 findByLastName 메서드가 실제로 예상대로 작동하는지 테스트합니다. 두 번째로 repository가 Spring의 배선을 올바르게 사용했으며 데이터베이스에 연결할 수 있음을 증명합니다.

사용자 컴퓨터에서 테스트를 더 쉽게 실행할 수 있도록(PostgreSQL 데이터베이스를 설치할 필요 없이) 테스트는 인메모리 H2 데이터베이스에 연결합니다.

build.gradle 파일에 테스트 종속성으로 H2를 정의했습니다. 테스트 디렉터리의 application.properties에는 spring.datasource 속성이 정의되어 있지 않습니다. 이는 Spring 데이터가 인메모리 데이터베이스를 사용하도록 지시합니다. 클래스 경로에서 H2를 찾으면 테스트를 실행할 때 H2를 사용하기만 하면 됩니다.

int 프로필을 사용하여 실제 애플리케이션을 실행할 때(예: 환경 변수로 SPRING_PROFILES_ACTIVE=int를 설정) application-int.properties에 정의된 대로 PostgreSQL 데이터베이스에 연결합니다.

알고 있고 이해해야 할 Spring의 세부 사항이 엄청나게 많다는 것을 알고 있습니다. 이에 도달하려면 많은 문서를 샅샅이 살펴봐야 합니다. 결과 코드는 보기에는 쉽지만 Spring의 세부적인 내용을 모르면 이해하기 어렵습니다.

게다가 인메모리 데이터베이스를 사용하는 것은 위험한 작업입니다. 결국, 통합 테스트는 프로덕션 환경과는 다른 유형의 데이터베이스를 대상으로 실행됩니다. 명시적이면서도 더 장황한 구현보다 Spring의 마법과 간단한 코드를 선호하시는지 직접 결정하세요.

설명은 충분했으니 이제 데이터베이스에 사람을 저장하고 성으로 사람을 찾는 간단한 통합 테스트를 해보겠습니다:

@RunWith(SpringRunner.class)
@DataJpaTest
public class PersonRepositoryIntegrationTest {
    @Autowired
    private PersonRepository subject;

    @After
    public void tearDown() throws Exception {
        subject.deleteAll();
    }

    @Test
    public void shouldSaveAndFetchPerson() throws Exception {
        Person peter = new Person("Peter", "Pan");
        subject.save(peter);

        Optional<Person> maybePeter = subject.findByLastName("Pan");

        assertThat(maybePeter, is(Optional.of(peter)));
    }
}

통합 테스트가 단위 테스트와 동일한 arrange, act, assert 구조를 따르고 있음을 알 수 있습니다. 이것이 보편적인 개념이라고 말씀드렸죠?

별도 서비스와의 통합

저희 마이크로서비스는 날씨 REST API인 darksky.net과 연동합니다. 물론 우리 서비스가 요청을 전송하고 응답을 올바르게 파싱하기를 원합니다.

자동화된 테스트를 실행할 때 실제 darksky 서버를 건드리지 않기를 원합니다. 무료 요금제의 쿼터 제한도 그 이유 중 하나지만, 진짜 이유는 디커플링입니다. 저희의 테스트는 darksky.net의 친절한 직원들이 하는 일과 독립적으로 실행되어야 합니다. 컴퓨터가 darksky 서버에 액세스할 수 없거나 darksky 서버가 유지보수를 위해 다운된 경우에도 마찬가지입니다.

통합 테스트를 실행하는 동안 자체적으로 가짜 darksky 서버를 실행하여 실제 darksky 서버를 공격하는 것을 피할 수 있습니다. 이것은 엄청난 작업처럼 들릴 수 있습니다. 하지만 WireMock과 같은 도구 덕분에 쉽게 할 수 있습니다. 이걸 보세요:

@RunWith(SpringRunner.class)
@SpringBootTest
public class WeatherClientIntegrationTest {

    @Autowired
    private WeatherClient subject;

    @Rule
    public WireMockRule wireMockRule = new WireMockRule(8089);

    @Test
    public void shouldCallWeatherService() throws Exception {
        wireMockRule.stubFor(get(urlPathEqualTo("/some-test-api-key/53.5511,9.9937"))
                .willReturn(aResponse()
                        .withBody(FileLoader.read("classpath:weatherApiResponse.json"))
                        .withHeader(CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                        .withStatus(200)));

        Optional<WeatherResponse> weatherResponse = subject.fetchWeather();

        Optional<WeatherResponse> expectedResponse = Optional.of(new WeatherResponse("Rain"));
        assertThat(weatherResponse, is(expectedResponse));
    }
}

Wiremock을 사용하려면 고정 포트(8089)에 WireMockRule을 인스턴스화합니다. DSL을 사용하여 Wiremock 서버를 설정하고, 수신해야 하는 엔드포인트를 정의하고, 응답해야 하는 미리 준비된 응답을 설정할 수 있습니다.

다음으로 테스트하려는 메서드, 즉 타사 서비스를 호출하는 메서드를 호출하고 결과가 올바르게 파싱되었는지 확인합니다.

테스트가 실제 darksky API 대신 가짜 Wiremock 서버를 호출해야 한다는 것을 어떻게 알 수 있는지 이해하는 것이 중요합니다. 그 비밀은 src/test/resources에 포함된 application.properties 파일에 있습니다. 이 파일은 Spring이 테스트를 실행할 때 로드하는 속성 파일입니다. 이 파일에서 API 키 및 URL과 같은 구성을 테스트 목적에 적합한 값으로 재정의합니다(예: 실제 서버 대신 가짜 Wiremock 서버를 호출):

weather.url = http://localhost:8089

여기서 정의한 포트는 테스트에서 WireMockRuel을 인스턴스화할 때 정의한 포트와 동일해야 한다는 점에 유의하세요. 테스트에서 실제 날씨 API의 URL을 가짜 URL로 대체하는 것은 WeatherClient 클래스의 생성자에 URL을 삽입하면 가능합니다:

@Autowired
public WeatherClient(final RestTemplate restTemplate,
                     @Value("${weather.url}") final String weatherServiceUrl,
                     @Value("${weather.api_key}") final String weatherServiceApiKey) {
    this.restTemplate = restTemplate;
    this.weatherServiceUrl = weatherServiceUrl;
    this.weatherServiceApiKey = weatherServiceApiKey;
}

이렇게 하면 application.properties에 정의한 weather.url 프로퍼티에서 weatherUrl 매개변수의 값을 읽도록 WeatherClient에 지시할 수 있습니다.

별도의 서비스에 대한 좁은 범위의 통합 테스트를 작성하는 것은 Wiremock과 같은 도구를 사용하면 매우 쉽습니다. 안타깝게도 이 접근 방식에는 단점이 있습니다: 우리가 설정한 가짜 서버가 실제 서버처럼 작동하는지 어떻게 확인할 수 있을까요? 현재 구현에서는 별도의 서비스가 API를 변경해도 테스트는 여전히 통과할 수 있습니다. 지금은 WeatherClient가 가짜 서버가 보내는 응답을 파싱할 수 있는지 테스트하고 있을 뿐입니다.

end-to-end 테스트를 사용하고 가짜 서비스를 사용하는 대신 실제 서비스의 테스트 인스턴스에 대해 테스트를 실행하면 이 문제를 해결할 수 있지만 테스트 서비스의 가용성에 의존하게 됩니다. 다행히도 이 딜레마에 대한 더 나은 해결책이 있습니다. 가짜 서버와 실제 서버에 대해 계약(contract) 테스트를 실행하면 통합 테스트에 사용하는 가짜 서버가 충실한 이중 테스트가 될 수 있습니다. 다음에는 어떻게 작동하는지 살펴보겠습니다.


계약 테스트 (Contract Test)

최근의 소프트웨어 개발 조직은 시스템 개발을 여러 팀에 분산하여 개발 노력을 확장하는 방법을 찾았습니다. 개별 팀은 서로의 영역을 침범하지 않고 느슨하게 결합된 개별 서비스를 구축하고 이러한 서비스를 하나의 큰 응집력 있는 시스템으로 통합합니다.

시스템을 여러 개의 작은 서비스로 분할한다는 것은 이러한 서비스들이 특정(잘 정의된, 때로는 실수로 생겨난) 인터페이스를 통해 서로 통신해야 한다는 것을 의미합니다.

서로 다른 어플리케이션 간의 인터페이스는 다양한 형태와 기술로 제공될 수 있습니다. 일반적인 인터페이스는 다음과 같습니다.

  1. HTTPS를 통한 REST 및 JSON
  2. gRPC와 같은 것을 사용하는 RPC
  3. Queue를 사용하여 이벤트 중심 아키텍처 구축

각 인터페이스에는 provider(공급자)consumer(소비자)라는 두 당사자가 관련되어 있습니다. provider는 consumer에게 데이터를 제공합니다. consumer는 provider로부터 얻은 데이터를 처리합니다. REST 세계에서 provider는 필요한 모든 엔드포인트를 갖춘 REST API를 구축하고, consumer는 이 REST API를 호출하여 데이터를 가져오거나 다른 서비스에서 변경을 트리거합니다. 비동기 이벤트 중심 세계에서는 provider(publisher라고도 함)가 데이터를 queue에 publish하고, consumer(subscriber라고도 함)가 이러한 queue를 subscribe하여 데이터를 읽고 처리합니다.

계약 테스트 (Contract Test)
Figure 8: Each interface has a providing (or publishing) and a consuming (or subscribing) party. The specification of an interface can be considered a contract.

여러 팀에 걸쳐 서비스를 소비하고 제공하는 경우가 많기 때문에 이러한 서비스 간의 인터페이스(소위 계약)를 명확하게 지정해야 하는 상황에 처하게 됩니다. 전통적으로 기업들은 이 문제에 다음과 같은 방식으로 접근해 왔습니다:

  1. 길고 상세한 인터페이스 사양(계약서)을 작성합니다.
  2. 정의된 계약에 따라 서비스 제공을 구현합니다.
  3. 인터페이스 사양을 소비하는 팀에 넘깁니다.
  4. 그들이 인터페이스를 소비하는 부분을 구현할 때까지 기다립니다.
  5. 대규모 수동 시스템 테스트를 실행하여 모든 것이 작동하는지 확인합니다.
  6. 두 팀이 인터페이스 정의를 영원히 고수하고 실수하지 않기를 바랍니다.

최근의 소프트웨어 개발팀은 5단계와 6단계를 보다 자동화된 방식으로 대체하고 있습니다: 자동화된 계약 테스트는 소비자와 공급자 측의 구현이 여전히 정의된 계약을 준수하는지 확인합니다. 이는 좋은 회귀 테스트의 역할을 하며 계약에서 벗어나는 것을 조기에 발견할 수 있도록 해줍니다.

보다 민첩한 애자일 조직에서는 보다 효율적이고 낭비가 적은 경로를 택해야 합니다. 같은 조직 내에서 어플리케이션을 구축합니다. 지나치게 상세한 문서를 울타리 너머로 던지는 대신 다른 서비스의 개발자와 직접 대화하는 것이 그리 어렵지 않습니다. 결국 그들은 고객 지원이나 법적으로 방탄 계약을 통해서만 대화할 수 있는 타사 공급업체가 아니라 동료이기 때문입니다.

소비자 주도 계약 테스트(CDC, Consumer-Driven Contract tests)

CDC를 사용하면 소비자가 계약의 구현을 주도할 수 있습니다. 인터페이스의 소비자는 CDC를 사용하여 해당 인터페이스에서 필요한 모든 데이터를 확인하는 테스트를 작성합니다. 그런 다음 소비 팀은 이러한 테스트를 게시하여 게시 팀이 이러한 테스트를 쉽게 가져와 실행할 수 있도록 합니다. 이제 제공 팀은 CDC 테스트를 실행하여 API를 개발할 수 있습니다. 모든 테스트가 통과되면 소비 팀이 필요로 하는 모든 것을 구현했음을 알 수 있습니다.

소비자 주도 계약 테스트 (CDC Test)
Figure 9: Contract tests ensure that the provider and all consumers of an interface stick to the defined interface contract. With CDC tests consumers of an interface publish their requirements in the form of automated tests; the providers fetch and execute these tests continuously

이 접근 방식을 사용하면 제공 팀은 정말 필요한 것만 구현할 수 있습니다(YAGNI 등 모든 것을 단순하게 유지). 인터페이스를 제공하는 팀은 빌드 파이프라인에서 이러한 CDC 테스트를 지속적으로 가져와 실행하여 변경 사항을 즉시 발견해야 합니다. 인터페이스가 중단되면 CDC 테스트가 실패하여 중단된 변경 사항을 라이브에 적용하지 못하게 됩니다. 테스트가 녹색으로 유지되는 한 팀은 다른 팀에 대해 걱정할 필요 없이 원하는 변경을 수행할 수 있습니다. 소비자 중심 계약 접근 방식은 다음과 같은 프로세스를 남깁니다:

  1. 소비 팀이 모든 소비자의 기대치를 반영하여 자동화된 테스트를 작성합니다.
  2. 소비 팀은 제공 팀을 위해 테스트를 게시합니다.
  3. 제공 팀은 CDC 테스트를 지속적으로 실행하고 녹색으로 유지합니다.
  4. CDC 테스트가 중단되면 두 팀이 서로 대화합니다.

조직에서 마이크로 서비스 접근 방식을 채택하는 경우, CDC 테스트는 자율적인 팀을 구축하는 데 큰 도움이 됩니다. CDC 테스트는 팀 커뮤니케이션을 촉진하는 자동화된 방법입니다. 팀 간의 인터페이스가 언제든지 작동하는지 확인할 수 있습니다. CDC 테스트에 실패하면 해당 팀에 가서 예정된 API 변경 사항에 대해 대화를 나누고 앞으로 어떻게 진행할지 결정해야 한다는 좋은 지표가 됩니다.

CDC 테스트의 간단한 구현은 API에 대해 요청을 실행하고 응답에 필요한 모든 것이 포함되어 있다고 주장하는 것만큼 간단할 수 있습니다. 그런 다음 이러한 테스트를 실행 파일(.gem, .jar, .sh)로 패키징하고 다른 팀이 가져올 수 있는 곳에 업로드합니다(예: Artifactory 같은 artifact repository).

요즘 가장 눈에 띄는 것은 아마도 Pact일 것입니다. 소비자와 공급자 측에 대한 테스트를 작성하는 정교한 접근 방식을 가지고 있으며, 별도의 서비스에 대한 stub을 즉시 제공하고 다른 팀과 CDC 테스트를 교환할 수 있습니다. Pact는 다양한 플랫폼에 포팅되어 있으며 JVM 언어, Ruby, .NET, JavaScript 등 다양한 언어에서 사용할 수 있습니다.

CDC를 시작하고 싶은데 방법을 모른다면 Pact가 현명한 선택이 될 수 있습니다. 처음에는 문서가 압도적으로 많을 수 있습니다. 인내심을 갖고 차근차근 읽어보세요. CDC를 제대로 이해하면 다른 팀과 함께 작업할 때 CDC 사용을 더 쉽게 옹호할 수 있습니다.

소비자 주도 계약 테스트는 자신감을 가지고 빠르게 움직일 수 있는 자율적인 팀을 구축하는 데 있어 진정한 게임 체인저가 될 수 있습니다. 이 개념에 대해 자세히 알아보고 직접 사용해 보세요. 견고한 CDC 테스트는 다른 서비스를 중단시키거나 다른 팀과 많은 불만을 일으키지 않고 빠르게 움직일 수 있는 데 매우 유용합니다.

공급자 테스트 (Provider Test) - 상대 팀

저희 마이크로서비스는 날씨 API를 사용합니다. 따라서 마이크로서비스와 날씨 서비스 간의 계약(API)에 대한 우리의 기대치를 정의하는 consumer 테스트를 작성하는 것은 우리의 책임입니다.

먼저 build.gradle에 계약 consumer 테스트를 작성하기 위한 라이브러리를 포함시킵니다:

testCompile('au.com.dius:pact-jvm-consumer-junit_2.11:3.5.5')

이 라이브러리 덕분에 소비자 테스트를 구현하고 pact의 mock services를 사용할 수 있습니다:

@RunWith(SpringRunner.class)
@SpringBootTest
public class WeatherClientConsumerTest {

    @Autowired
    private WeatherClient weatherClient;

    @Rule
    public PactProviderRuleMk2 weatherProvider =
            new PactProviderRuleMk2("weather_provider", "localhost", 8089, this);

    @Pact(consumer="test_consumer")
    public RequestResponsePact createPact(PactDslWithProvider builder) throws IOException {
        return builder
                .given("weather forecast data")
                .uponReceiving("a request for a weather request for Hamburg")
                    .path("/some-test-api-key/53.5511,9.9937")
                    .method("GET")
                .willRespondWith()
                    .status(200)
                    .body(FileLoader.read("classpath:weatherApiResponse.json"),
                            ContentType.APPLICATION_JSON)
                .toPact();
    }

    @Test
    @PactVerification("weather_provider")
    public void shouldFetchWeatherInformation() throws Exception {
        Optional<WeatherResponse> weatherResponse = weatherClient.fetchWeather();
        assertThat(weatherResponse.isPresent(), is(true));
        assertThat(weatherResponse.get().getSummary(), is("Rain"));
    }
}

자세히 보면 WeatherClientConsumerTest가 WeatherClientIntegrationTest와 매우 유사하다는 것을 알 수 있습니다. 이번에는 서버 stub에 Wiremock 대신 Pact를 사용했습니다. 실제로 consume 테스트는 통합 테스트와 똑같이 작동하며, 실제 타사 서버를 stub으로 대체하고 예상되는 응답을 정의한 다음 클라이언트가 응답을 올바르게 파싱할 수 있는지 확인합니다. 이런 의미에서 WeatherClientConsumerTest는 그 자체로 좁은 의미의 통합 테스트입니다.

Wiremock 기반 테스트에 비해 이 테스트의 장점은 테스트가 실행될 때마다 Pact 파일(target/pacts/&pact-name>.json에 있음)을 생성한다는 것입니다. 이 팩트 파일은 계약에 대한 기대치를 JSON 형식으로 설명합니다. 그런 다음 이 팩트 파일을 사용하여 stub 서버가 실제 서버처럼 작동하는지 확인할 수 있습니다. 이 계약 파일을 인터페이스를 제공하는 팀에 전달하면 됩니다. 팀은 이 팩트 파일을 가져와서 여기에 정의된 기대치를 사용하여 공급자 테스트를 작성합니다. 이렇게 하면 API가 우리의 모든 기대치를 충족하는지 테스트할 수 있습니다.

여기서 CDC의 consumer 주도적인 부분이 시작됩니다. consumer는 자신의 기대치를 설명함으로써 인터페이스 구현을 주도합니다. provider는 consumer가 모든 기대치를 충족하는지 확인해야 합니다.

Provider 팀에 계약 파일을 가져오는 방법은 여러 가지가 있습니다. 간단한 방법은 버전 컨트롤에서 확인하여 제공 팀에 항상 최신 버전의 계약 파일을 가져오도록 지시하는 것입니다. 좀 더 발전된 방법은 artifact repository, Amazon의 S3 또는 pact broker와 같은 서비스를 사용하는 것입니다. 간단하게 시작하여 필요에 따라 확장하세요.

실제 어플리케이션에서는 통합 테스트와 클라이언트 클래스에 대한 consumer 테스트가 모두 필요하지 않습니다. 샘플 코드베이스에는 둘 중 하나를 사용하는 방법을 보여주기 위해 둘 다 포함되어 있습니다. 협정을 사용하여 CDC 테스트를 작성하려면 후자를 사용하는 것이 좋습니다. 테스트를 작성하는 데 드는 노력은 동일합니다. pact를 사용하면 다른 팀이 provider 테스트를 쉽게 구현하는 데 사용할 수 있는 계약에 대한 기대치가 포함된 pact 파일을 자동으로 얻을 수 있다는 이점이 있습니다. 물론 이는 다른 팀도 pact 사용을 설득할 수 있는 경우에만 의미가 있습니다. 이 방법이 효과가 없다면 통합 테스트와 Wiremock을 함께 사용하는 것도 괜찮은 차선책입니다.

Provider Test

Provider 테스트는 날씨 API를 제공하는 사람들이 구현해야 합니다. 저희는 darksky.net에서 제공하는 퍼블릭 API를 사용하고 있습니다. 이론상으로는 darksky 팀이 provider 테스트를 구현하여 어플리케이션과 저희 서비스 간의 계약을 위반하지 않는지 확인해야 합니다.

당연히 그들은 우리의 빈약한 샘플 어플리케이션에는 관심이 없고 우리를 위해 CDC 테스트를 구현하지 않을 것입니다. 이것이 바로 공개 API와 마이크로 서비스를 채택하는 조직 간의 큰 차이점입니다. 공개 API는 모든 consumer를 고려할 수 없으며, 그렇지 않으면 앞으로 나아갈 수 없게 됩니다. 조직 내에서는 할 수 있고, 해야만 합니다. 여러분의 앱은 소수의 consumer에게 서비스를 제공할 가능성이 높습니다. 안정적인 시스템을 유지하기 위해 이러한 인터페이스에 대한 provider 테스트를 작성하는 것이 좋습니다.

Provider 팀은 계약 파일을 가져와서 제공 서비스에 대해 실행합니다. 이를 위해 provider 팀은 pact 파일을 읽고, 일부 테스트 데이터를 추출하고, pact 파일에 정의된 기대치를 서비스에 대해 실행하는 provider 테스트를 구현합니다.

Pact 담당자들은 provider 테스트를 구현하기 위한 여러 라이브러리를 작성했습니다. 그들의 GitHub repository에서 어떤 consumer 및 provider 라이브러리를 사용할 수 있는지 간략하게 살펴볼 수 있습니다. 여러분의 기술 스택에 가장 적합한 것을 선택하세요.

간단하게 하기 위해 Spring Boot에서도 darksky API가 구현되어 있다고 가정해 보겠습니다. 이 경우 Spring의 MockMVC 메커니즘에 잘 연결되는 Spring Pact Provider를 사용할 수 있습니다. darksky.net 팀이 구현할 가상의 provider 테스트는 다음과 같이 보일 수 있습니다:

@RunWith(RestPactRunner.class)
@Provider("weather_provider") // same as the "provider_name" in our clientConsumerTest
@PactFolder("target/pacts") // tells pact where to load the pact files from
public class WeatherProviderTest {
    @InjectMocks
    private ForecastController forecastController = new ForecastController();

    @Mock
    private ForecastService forecastService;

    @TestTarget
    public final MockMvcTarget target = new MockMvcTarget();

    @Before
    public void before() {
        initMocks(this);
        target.setControllers(forecastController);
    }

    @State("weather forecast data") // same as the "given()" in our clientConsumerTest
    public void weatherForecastData() {
        when(forecastService.fetchForecastFor(any(String.class), any(String.class)))
                .thenReturn(weatherForecast("Rain"));
    }
}

Provider 테스트는 pact 파일을 로드한 다음(예: 이전에 다운로드한 pact 파일을 로드하기 위해 @PactFolder 어노테이션 사용), 미리 정의된 상태에 대한 테스트 데이터를 제공하는 방법을 정의하기만 하면 됩니다(예: Mockito mock 테스트 사용). 구현할 consumer 지정 테스트는 없습니다. 이 모든 것은 pact 파일에서 파생됩니다. provider 테스트에는 consumer 테스트에서 선언한 provider namestatus와 일치하는 대응하는 테스트가 있어야 합니다.

공급자 테스트 (Provider Test) - 우리 팀

저희 서비스와 날씨 제공자 간의 계약을 테스트하는 방법을 살펴봤습니다. 이 인터페이스를 통해 우리 서비스는 consumer 역할을 하고 날씨 서비스는 provider 역할을 합니다. 조금 더 생각해보면 우리 서비스가 다른 서비스를 위한 provider 역할도 한다는 것을 알 수 있습니다: 우리는 다른 사람들이 사용할 수 있도록 준비된 몇 가지 엔드포인트를 제공하는 REST API를 제공합니다.

계약 테스트가 대세라는 것을 방금 배웠기 때문에 당연히 이 계약에도 계약 테스트를 작성합니다. 다행히도 우리는 consumer 주도형 계약을 사용하고 있기 때문에 모든 consumer 팀에서 REST API에 대한 provider 테스트를 구현하는 데 사용할 수 있는 계약을 보내주고 있습니다.

먼저 Spring용 Pact provider 라이브러리를 프로젝트에 추가해 보겠습니다:

testCompile('au.com.dius:pact-jvm-provider-spring_2.12:3.5.5')

provider 테스트를 구현하는 것은 앞서 설명한 것과 동일한 패턴을 따릅니다. 간단하게 하기 위해 simple consumer에서 서비스 repository로 pact 파일을 확인했습니다. 실제 시나리오에서는 아마도 더 정교한 메커니즘을 사용하여 계약 파일을 배포할 것입니다.

@RunWith(RestPactRunner.class)
@Provider("person_provider")// same as in the "provider_name" part in our pact file
@PactFolder("target/pacts") // tells pact where to load the pact files from
public class ExampleProviderTest {

    @Mock
    private PersonRepository personRepository;

    @Mock
    private WeatherClient weatherClient;

    private ExampleController exampleController;

    @TestTarget
    public final MockMvcTarget target = new MockMvcTarget();

    @Before
    public void before() {
        initMocks(this);
        exampleController = new ExampleController(personRepository, weatherClient);
        target.setControllers(exampleController);
    }

    @State("person data") // same as the "given()" part in our consumer test
    public void personData() {
        Person peterPan = new Person("Peter", "Pan");
        when(personRepository.findByLastName("Pan")).thenReturn(Optional.of
                (peterPan));
    }
}

표시된 ExampleProviderTest는 주어진 pact 파일에 따라 상태를 제공해야 합니다. provider 테스트를 실행하면 Pact가 pact 파일을 선택하고 서비스에 대해 HTTP 요청을 실행한 다음 설정한 상태에 따라 응답합니다.


UI 테스트

대부분의 애플리케이션에는 일종의 사용자 인터페이스가 있습니다. 일반적으로 우리는 웹 어플리케이션의 맥락에서 웹 인터페이스에 대해 이야기하고 있습니다. 사람들은 종종 REST API나 command line 인터페이스도 멋진 웹 사용자 인터페이스만큼이나 사용자 인터페이스에 해당한다는 사실을 잊곤 합니다.

UI 테스트는 어플리케이션의 사용자 인터페이스가 올바르게 작동하는지 테스트합니다. 사용자 입력이 올바른 동작을 트리거하고, 데이터가 사용자에게 표시되어야 하며, UI 상태가 예상대로 변경되어야 합니다.

Mike Cohn의 경우처럼 UI 테스트와 end-to-end 테스트는 때때로 같은 의미로 사용되기도 합니다. 저는 이 두 가지가 서로 다른 개념이라고 생각합니다.

네, 어플리케이션을 end-to-end 테스트한다는 것은 사용자 인터페이스를 통해 테스트를 진행하는 것을 의미합니다. 하지만 그 반대는 사실이 아닙니다.

사용자 인터페이스 테스트가 반드시 end-to-end 방식으로 수행될 필요는 없습니다. 사용하는 기술에 따라 사용자 인터페이스 테스트는 백엔드를 제외한 상태에서 프론트엔드 자바스크립트 코드에 대한 몇 가지 단위 테스트를 작성하는 것만큼 간단할 수 있습니다.

기존 웹 애플리케이션 테스트의 경우 Selenium과 같은 도구를 사용하여 사용자 인터페이스를 테스트할 수 있습니다. REST API를 사용자 인터페이스라고 생각한다면 API에 대한 적절한 통합 테스트를 작성하여 필요한 모든 것을 갖추고 있어야 합니다.

웹 인터페이스에는 동작, 레이아웃, 사용성 또는 기업 디자인 준수 등 UI와 관련하여 테스트해야 할 여러 측면이 있습니다.

다행히도 사용자 인터페이스의 동작을 테스트하는 것은 매우 간단합니다. 여기를 클릭하고 데이터를 입력한 다음 그에 따라 사용자 인터페이스의 상태가 변경되기를 원하면 됩니다. 최신 단일 페이지 어플리케이션 프레임워크(react, vue.js, Angular 등)에는 종종 이러한 상호작용을 매우 낮은 수준(단위 테스트)에서 철저하게 테스트할 수 있는 자체 도구와 헬퍼가 함께 제공됩니다. 바닐라 자바스크립트를 사용하여 자체 프론트엔드 구현을 롤링하는 경우에도 Jasmine 또는 Mocha와 같은 일반 테스트 도구를 사용할 수 있습니다. 보다 전통적인 서버 측 렌더링 어플리케이션의 경우, 셀레늄 기반 테스트가 최선의 선택이 될 것입니다.

웹 어플리케이션의 레이아웃이 그대로 유지되는지 테스트하는 것은 조금 더 어렵습니다. 어플리케이션과 사용자의 요구에 따라 코드 변경으로 인해 실수로 웹사이트 레이아웃이 손상되지 않는지 확인해야 할 수도 있습니다.

문제는 컴퓨터가 무언가가 “보기 좋은지”를 확인하는 데 악명이 높다는 것입니다(향후 영리한 머신러닝 알고리즘이 이를 바꿀 수 있을지도 모르죠).

빌드 파이프라인에서 웹 어플리케이션의 디자인을 자동으로 확인하려는 경우 사용해 볼 수 있는 몇 가지 도구가 있습니다. 이러한 도구의 대부분은 Selenium을 사용하여 다양한 브라우저와 형식으로 웹 애플리케이션을 열고 스크린샷을 찍고 이전에 찍은 스크린샷과 비교합니다. 이전 스크린샷과 새 스크린샷이 예상치 못한 방식으로 다르면 도구에서 알려줍니다.

Galen은 이러한 도구 중 하나입니다. 하지만 특별한 요구 사항이 있다면 자체 솔루션을 구축하는 것도 그리 어렵지 않습니다. 제가 함께 일했던 몇몇 팀에서는 비슷한 목적을 달성하기 위해 Lineup과 그 사촌인 Java 기반 jlineup을 구축했습니다. 두 도구 모두 앞서 설명한 것과 동일한 Selenium 기반 접근 방식을 취합니다.

사용성과 “보기 좋은” 요소를 테스트하려면 자동화된 테스트의 영역을 벗어나야 합니다. 탐색적 테스트, 사용성 테스트(복도 테스트처럼 간단한 테스트도 가능) 및 사용자와 함께 제품을 사용하는 것을 좋아하고 모든 기능을 사용할 수 있는지 확인하기 위한 쇼케이스에 의존해야 하는 영역이 바로 이 영역입니다.


End-to-End 테스트

배포된 어플리케이션을 사용자 인터페이스를 통해 테스트하는 것은 가장 end-to-end 방식에 가까운 테스트입니다. 앞서 설명한 웹 드라이버 기반 UI 테스트는 end-to-end 테스트의 좋은 예입니다.

end-to-end 테스트
Figure 11: End-to-end tests test your entire, completely integrated system

소프트웨어의 작동 여부를 결정해야 할 때 end-to-end 테스트(Broad Stack Test라고도 함)는 가장 큰 확신을 줍니다. Selenium과 WebDriver 프로토콜을 사용하면 배포된 서비스에 대해 (headless) 브라우저를 자동으로 구동하고, 클릭을 수행하고, 데이터를 입력하고, 사용자 인터페이스의 상태를 확인함으로써 테스트를 자동화할 수 있습니다. Selenium을 직접 사용하거나 Selenium을 기반으로 구축된 도구를 사용할 수 있으며, Nightwatch도 그 중 하나입니다.

End-to-end 테스트에는 고유한 문제가 있습니다. End-to-end 테스트는 예측할 수 없는 이유로 실패하는 것으로 악명이 높습니다. 이러한 실패는 오탐인 경우가 많습니다. 사용자 인터페이스가 정교할수록 테스트가 더 불안정해지는 경향이 있습니다. 브라우저의 결함, 타이밍 문제, 애니메이션, 예기치 않은 팝업 대화 상자 등은 디버깅에 많은 시간을 할애하게 만듭니다.

마이크로서비스 세계에서는 누가 이러한 테스트를 작성할 책임이 있는지에 대한 큰 문제도 있습니다. 여러 서비스(전체 시스템)에 걸쳐 있기 때문에 end-to-end 테스트 작성을 담당하는 단일 팀이 존재하지 않습니다.

중앙 집중식 품질 보증(Quality Assurance) 팀이 있다면 이 방식이 적합해 보입니다. 하지만 중앙 집중식 QA 팀을 두는 것은 안티 패턴이며, 팀이 진정한 교차 기능을 수행해야 하는 DevOps 환경에서는 적합하지 않습니다. 누가 end-to-end 테스트를 소유해야 하는지에 대한 쉬운 답은 없습니다. 조직에 이러한 문제를 처리할 수 있는 실무 커뮤니티나 우수한 길드가 있을 수도 있습니다. 정답을 찾는 것은 조직에 따라 크게 달라집니다.

또한 end-to-end 테스트는 많은 유지 관리가 필요하고 실행 속도가 매우 느립니다. 두 개 이상의 마이크로서비스가 있는 환경을 생각해보면 모든 마이크로서비스를 로컬에서 시작해야 하므로 end-to-end 테스트를 로컬에서 실행할 수도 없을 것입니다. 유지 관리 비용이 많이 들기 때문에 end-to-end 테스트 횟수를 최소한으로 줄이는 것을 목표로 해야 합니다.

사용자가 어플리케이션과 어떤 가치 있는 상호 작용을 하게 될지 생각해 보세요. 제품의 핵심 가치를 정의하는 사용자 여정을 생각해 내고 이러한 사용자 여정의 가장 중요한 단계를 자동화된 end-to-end 테스트로 전환하세요.

E-commerce 사이트를 구축하는 경우, 가장 가치 있는 고객 여정은 사용자가 제품을 검색하고 장바구니에 담아 결제를 진행하는 것일 수 있습니다. 이것이 전부입니다. 이 여정만 제대로 작동한다면 큰 문제가 없을 것입니다.

테스트 피라미드에는 이미 모든 종류의 엣지 케이스와 시스템의 다른 부분과의 통합을 테스트한 하위 레벨이 많이 있다는 점을 기억하세요. 이러한 테스트를 더 높은 수준에서 반복할 필요가 없습니다. 높은 유지 관리 노력과 많은 오탐은 속도를 늦추고 조만간 테스트에 대한 신뢰를 잃게 만들 것입니다.

사용자 인터페이스 End-to-End 테스트

End-to-end 테스트를 위해 많은 개발자가 선택하는 도구는 Selenium과 WebDriver 프로토콜입니다. Selenium을 사용하면 원하는 브라우저를 선택하여 웹사이트를 자동으로 호출하고, 여기저기를 클릭하고, 데이터를 입력하고, 사용자 인터페이스에서 변경 사항을 확인할 수 있습니다.

Selenium은 테스트를 실행하기 위해 시작하고 사용할 수 있는 브라우저가 필요합니다. 여러 브라우저에 사용할 수 있는 소위 '드라이버'가 여러 개 있습니다. 하나(또는 여러 개)를 선택하여 build.gradle에 추가하세요. 어떤 브라우저를 선택하든 팀과 CI 서버의 모든 개발자가 로컬에 올바른 버전의 브라우저를 설치했는지 확인해야 합니다. 동기화 상태를 유지하는 것은 꽤 번거로울 수 있습니다. Java의 경우 사용하려는 브라우저의 올바른 버전을 자동으로 다운로드하고 설정할 수 있는 Webdrivermanager라는 멋진 라이브러리가 있습니다. 이 두 종속 요소를 build.gradle에 추가하면 완료됩니다:

testCompile('org.seleniumhq.selenium:selenium-chrome-driver:2.53.1')
testCompile('io.github.bonigarcia:webdrivermanager:1.7.2')

테스트에서 완전한 브라우저를 실행하는 것은 번거로울 수 있습니다. 특히 연속 배포를 사용하는 경우 파이프라인을 실행하는 서버가 사용자 인터페이스를 포함한 브라우저를 실행하지 못할 수 있습니다(예: 사용 가능한 X-Server가 없기 때문). 이 문제는 xvfb와 같은 가상 X-서버를 시작하여 해결할 수 있습니다.

보다 최근의 접근 방식은 headless 브라우저(즉, 사용자 인터페이스가 없는 브라우저)를 사용하여 웹 드라이버 테스트를 실행하는 것입니다. 최근까지 브라우저 자동화에 사용되는 대표적인 headless 브라우저는 PhantomJS 였습니다. 하지만 크롬파이어폭스가 브라우저에 headless 모드를 구현했다고 발표한 이후 PhantomJS는 갑자기 쓸모없게 되었습니다. 결국 개발자에게 편리하다고 해서 인위적인 브라우저를 사용하는 대신 사용자가 실제로 사용하는 브라우저(예: Firefox 및 Chrome)로 웹 사이트를 테스트하는 것이 좋습니다.

Headless 파이어폭스와 크롬은 모두 새로운 브라우저로 아직 웹 드라이버 테스트를 구현하는 데 널리 채택되지 않았습니다. 우리는 일을 단순하게 유지하고자 합니다. 최첨단 headless 모드를 사용하려고 애쓰는 대신 Selenium과 일반 브라우저를 사용하는 고전적인 방법을 고수해 봅시다. Chrome을 실행하고 서비스로 이동하여 웹사이트의 콘텐츠를 확인하는 간단한 end-to-end 테스트는 다음과 같습니다:

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class HelloE2ESeleniumTest {

    private WebDriver driver;

    @LocalServerPort
    private int port;

    @BeforeClass
    public static void setUpClass() throws Exception {
        ChromeDriverManager.getInstance().setup();
    }

    @Before
    public void setUp() throws Exception {
        driver = new ChromeDriver();
    }

    @After
    public void tearDown() {
        driver.close();
    }

    @Test
    public void helloPageHasTextHelloWorld() {
        driver.get(String.format("http://127.0.0.1:%s/hello", port));

        assertThat(driver.findElement(By.tagName("body")).getText(), containsString("Hello World!"));
    }
}

이 테스트는 이 테스트를 실행하는 시스템(로컬 컴퓨터, CI 서버)에 Chrome이 설치된 경우에만 시스템에서 실행된다는 점에 유의하세요.

테스트는 간단합니다. SpringBootTest를 사용하여 임의의 포트에서 전체 Spring 어플리케이션을 띄웁니다. 그런 다음 새 Chrome 웹 드라이버를 인스턴스화하고, 마이크로서비스의 /hello 엔드포인트로 이동하여 브라우저 창에 “Hello World!”가 출력되는지 확인합니다. 멋지네요!

REST API End-to-End 테스트

어플리케이션을 테스트할 때 그래픽 사용자 인터페이스를 피하면 전체 end-to-end 테스트보다 덜 복잡하면서도 어플리케이션 스택의 광범위한 부분을 커버할 수 있는 테스트를 만드는 데 도움이 될 수 있습니다. 이 방법은 어플리케이션의 웹 인터페이스를 통한 테스트가 특히 어려울 때 유용할 수 있습니다. 웹 UI가 없고 대신 REST API를 제공할 수도 있습니다. 그래픽 사용자 인터페이스 바로 아래에서 테스트하는 피하(Subcutaneous) 테스트는 자신감을 크게 손상시키지 않으면서도 정말 멀리 갈 수 있습니다. 예제 코드에서와 같이 REST API를 제공하는 경우에 딱 맞는 방법입니다:

@RestController
public class ExampleController {
    private final PersonRepository personRepository;

    // shortened for clarity

    @GetMapping("/hello/{lastName}")
    public String hello(@PathVariable final String lastName) {
        Optional<Person> foundPerson = personRepository.findByLastName(lastName);

        return foundPerson
             .map(person -> String.format("Hello %s %s!",
                     person.getFirstName(),
                     person.getLastName()))
             .orElse(String.format("Who is this '%s' you're talking about?",
                     lastName));
    }
}

REST API를 제공하는 서비스를 테스트할 때 유용한 라이브러리를 하나 더 소개해드리겠습니다. REST-assured는 API에 대해 실제 HTTP 요청을 실행하고 수신한 응답을 평가할 수 있는 멋진 DSL을 제공하는 라이브러리입니다.

build.gradle에 아래 의존성을 추가하세요.

testCompile('io.rest-assured:rest-assured:3.0.3')

이 라이브러리를 사용하면 REST API에 대한 end-to-end 테스트를 구현할 수 있습니다:

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class HelloE2ERestTest {

    @Autowired
    private PersonRepository personRepository;

    @LocalServerPort
    private int port;

    @After
    public void tearDown() throws Exception {
        personRepository.deleteAll();
    }

    @Test
    public void shouldReturnGreeting() throws Exception {
        Person peter = new Person("Peter", "Pan");
        personRepository.save(peter);

        when()
                .get(String.format("http://localhost:%s/hello/Pan", port))
        .then()
                .statusCode(is(200))
                .body(containsString("Hello Peter Pan!"));
    }
}

다시, @SpringBootTest를 사용하여 전체 Spring 애플리케이션을 시작합니다. 이 경우 테스트 데이터를 데이터베이스에 쉽게 쓸 수 있도록 PersonRepository를 @Autowire합니다.


인수 테스트 (Acceptance Test) - 기능이 정상 동작하나요?

테스트 피라미드에서 위로 올라갈수록 빌드 중인 기능이 사용자 관점에서 올바르게 작동하는지 테스트하는 영역으로 진입할 가능성이 높아집니다. 어플리케이션을 블랙박스로 간주하고 테스트의 초점을 다음과 같이 전환할 수 있습니다.

값 x와 y를 입력하면 반환값은 z가 되어야 합니다.

given - 로그인한 사용자가 있고

and - "자전거"라는 글이 있고

when - 사용자가 '자전거' 기사의 상세 페이지로 이동하여

and - "장바구니에 추가" 버튼을 클릭하면

then - "자전거" 문서가 장바구니에 있어야 합니다.

이러한 종류의 테스트는 기능 테스트(functional test) 또는 인수 테스트(acceptance test)라고 불리워집니다. 간혹 기능 테스트와 인수 테스트가 서로 다르다고 말하는 사람들도 있고, 때로는 두 용어가 혼용되기도 합니다. 때때로 사람들은 표현과 정의에 대해 끝없이 논쟁을 벌이기도 합니다. 종종 이러한 논의는 꽤 큰 혼란의 원인이 됩니다.

기술적인 관점뿐만 아니라 사용자 관점에서 소프트웨어가 올바르게 작동하는지 테스트해야 합니다. 이러한 테스트를 무엇이라고 부르느냐는 사실 그다지 중요하지 않습니다. 하지만 이러한 테스트를 수행하는 것은 중요합니다. 용어를 정하고 그 용어를 고수하며 테스트를 작성하세요.

사람들이 BDD와 BDD 방식으로 테스트를 구현할 수 있는 도구에 대해 이야기하는 순간이기도 합니다. BDD 또는 BDD 스타일의 테스트 작성 방식은 구현 세부 사항에서 사용자의 요구 사항으로 사고방식을 전환하는 좋은 방법이 될 수 있습니다. 한 번 시도해 보세요.

Cucumber와 같은 본격적인 BDD 도구를 채택할 필요는 없습니다(할 수는 있지만). chai.js와 같은 일부 assertions 라이브러리를 사용하면 테스트를 보다 BDD처럼 읽을 수 있는 should 스타일 키워드로 assertions을 작성할 수 있습니다. 이 표기법을 제공하는 라이브러리를 사용하지 않더라도 잘 구성된 코드를 사용하면 사용자 행동에 초점을 맞춘 테스트를 작성할 수 있습니다. 일부 헬퍼 메서드/함수는 매우 큰 도움이 될 수 있습니다:

# a sample acceptance test in Python

def test_add_to_basket():
    # given
    user = a_user_with_empty_basket()
    user.login()
    bicycle = article(name="bicycle", price=100)

    # when
    article_page.add_to_.basket(bicycle)

    # then
    assert user.basket.contains(bicycle)

인수 테스트는 다양한 수준으로 세분화될 수 있습니다. 대부분의 경우 다소 높은 수준에서 사용자 인터페이스를 통해 서비스를 테스트합니다. 그러나 기술적으로 테스트 피라미드의 최상위 수준에서 인수 테스트를 작성할 필요는 없다는 점을 이해하는 것이 좋습니다.

어플리케이션 설계와 현재 시나리오에 따라 더 낮은 수준에서 수락 테스트를 작성할 수 있다면 그렇게 하세요. 낮은 수준의 테스트가 높은 수준의 테스트보다 낫습니다. 기능이 사용자에게 올바르게 작동하는지 증명하는 인수 테스트의 개념은 테스트 피라미드와 완전히 직교합니다.


탐색 테스트 (Exploratory Testing)

아무리 부지런한 테스트 자동화 노력도 완벽할 수는 없습니다. 때로는 자동화된 테스트에서 특정 에지 케이스를 놓칠 수도 있습니다. 단위 테스트를 작성하여 특정 버그를 발견하는 것이 거의 불가능한 경우도 있습니다. 특정 품질 문제는 자동화된 테스트에서 드러나지 않을 수도 있습니다(디자인이나 사용성에 대해 생각해 보세요). 테스트 자동화와 관련하여 최선의 의도를 가지고 있다 하더라도, 수동 테스트는 여전히 좋은 생각입니다.

탐색적 테스트 (Exploratory Testing)
Figure 12: Use exploratory testing to spot all quality issues that your build pipeline didn't spot

테스트 포트폴리오에 탐색 테스트를 포함하세요. 이는 테스터의 자유와 창의성을 강조하여 실행 중인 시스템에서 품질 문제를 발견하는 수동 테스트 접근 방식입니다. 정기적인 일정에 맞춰 어플리케이션을 깨뜨리는 시도를 해보세요. 파괴적인 사고방식을 사용하여 애플리케이션의 문제와 오류를 유발할 수 있는 방법을 생각해 보세요. 나중에 사용할 수 있도록 발견한 모든 것을 문서화하세요. 버그, 디자인 문제, 느린 응답 시간, 누락되거나 오해의 소지가 있는 오류 메시지 등 소프트웨어 사용자를 짜증나게 하는 모든 것을 주의하세요.

좋은 소식은 자동화된 테스트를 통해 대부분의 발견 사항을 손쉽게 자동화할 수 있다는 것입니다. 발견한 버그에 대해 자동화된 테스트를 작성하면 향후 해당 버그가 재발하지 않도록 방지할 수 있습니다. 또한 버그를 수정하는 동안 문제의 근본 원인을 좁히는 데 도움이 됩니다.

탐색 테스트 중에는 빌드 파이프라인에서 눈에 띄지 않는 문제를 발견할 수 있습니다. 좌절하지 마세요. 이는 빌드 파이프라인의 성숙도에 대한 훌륭한 피드백입니다. 다른 피드백과 마찬가지로 반드시 조치를 취하세요: 앞으로 이런 종류의 문제를 피하기 위해 무엇을 할 수 있을지 생각해 보세요. 특정 자동화된 테스트 세트를 놓치고 있을 수도 있습니다. 이번 반복 작업에서 자동화된 테스트에 소홀했을 수도 있고, 앞으로는 더 철저하게 테스트해야 할 수도 있습니다. 앞으로 이러한 문제를 피하기 위해 파이프라인에 사용할 수 있는 멋진 새 도구나 접근 방식이 있을 수도 있습니다. 시간이 지날수록 파이프라인과 전체 소프트웨어 배포가 더욱 성숙해질 수 있도록 반드시 실행에 옮기세요.


테스트 용어에 대한 혼란

서로 다른 테스트 분류에 대해 이야기하는 것은 항상 어렵습니다. 단위 테스트에 대해 이야기할 때 제가 의미하는 바가 여러분의 이해와 약간 다를 수 있습니다. 통합 테스트는 더 심합니다. 어떤 사람들에게 통합 테스트는 전체 시스템의 다양한 부분을 테스트하는 매우 광범위한 활동입니다. 저에게는 한 번에 하나의 외부 부분과의 통합만 테스트하는 다소 좁은 의미의 활동입니다. 어떤 사람들은 통합 테스트라고 부르기도 하고, 어떤 사람들은 구성 요소 테스트라고 부르기도 하며, 어떤 사람들은 서비스 테스트라는 용어를 선호하기도 합니다. 심지어 이 세 가지 용어가 모두 완전히 다른 것이라고 주장하는 사람들도 있습니다. 옳고 그름은 없습니다. 소프트웨어 개발 커뮤니티는 테스트에 대한 명확한 용어를 정하지 못했을 뿐입니다.

모호한 용어에 너무 집착하지 마세요. end-to-end 테스트, broad stack 테스트, functional 테스트 등 어떤 용어로 부르든 상관없습니다. 통합 테스트가 다른 회사의 직원들과 여러분에게 다른 의미로 다가오더라도 상관없습니다. 네, 우리 업계가 잘 정의된 용어를 정하고 모두 이를 고수할 수 있다면 정말 좋을 것 같습니다. 안타깝게도 아직 그런 일은 일어나지 않았습니다. 그리고 테스트를 작성할 때는 많은 뉘앙스가 있기 때문에 어차피 여러 개의 개별 버킷이라기보다는 스펙트럼에 가깝기 때문에 일관된 이름을 붙이는 것이 훨씬 더 어렵습니다.

중요한 점은 여러분과 여러분의 팀에 적합한 용어를 찾아야 한다는 것입니다. 작성하려는 다양한 유형의 테스트에 대해 명확히 하세요. 팀 내에서 이름 지정에 동의하고 각 테스트 유형의 범위에 대한 합의를 찾아보세요. 팀 내에서(또는 조직 내에서) 일관성을 유지할 수 있다면 그것만으로도 충분합니다. Simon Stewart가 Google에서 사용하는 테스트 접근 방식을 설명할 때 이를 아주 잘 요약했습니다. 그리고 이름과 명명 규칙에 너무 집착하는 것이 얼마나 번거로운 일인지 완벽하게 보여줍니다.


배포 파이프라인에 테스트 넣기

지속적 통합(CI) 또는 지속적 배포(CD)를 사용하는 경우 소프트웨어를 변경할 때마다 자동화된 테스트를 실행하는 배포 파이프라인이 마련되어 있을 것입니다. 일반적으로 이 파이프라인은 여러 단계로 나뉘며, 점차적으로 소프트웨어가 프로덕션에 배포할 준비가 되었다는 확신을 갖게 됩니다. 이렇게 다양한 종류의 테스트에 대해 들으셨다면 배포 파이프라인 내에서 테스트를 어떻게 배치해야 하는지 궁금하실 것입니다. 이 질문에 답하려면 지속적 배포의 가장 기본적인 가치 중 하나(실제로 익스트림 프로그래밍과 애자일 소프트웨어 개발의 핵심 가치 중 하나)에 대해 생각해 보면 됩니다: 빠른 피드백.

좋은 빌드 파이프라인은 가능한 한 빨리 문제가 생겼음을 알려줍니다. 최근 변경 사항으로 인해 간단한 단위 테스트가 중단되었다는 사실을 알기 위해 한 시간이나 기다리는 것은 원치 않으실 겁니다. 빠르게 실행되는 테스트를 파이프라인의 초기 단계에 배치하면 몇 초, 아니 몇 분 안에 이 정보를 얻을 수 있습니다. 반대로 실행 시간이 오래 걸리는 테스트(일반적으로 범위가 더 넓은 테스트)는 빠르게 실행되는 테스트의 피드백을 지연시키지 않도록 후반 단계에 배치합니다.

배포 파이프라인의 단계를 정의하는 것은 테스트의 유형이 아니라 속도와 범위에 따라 결정된다는 것을 알 수 있습니다. 이를 염두에 두면 범위가 매우 좁고 빠르게 실행되는 통합 테스트 중 일부를 단위 테스트와 같은 단계에 배치하는 것이 매우 합리적인 결정일 수 있습니다. 단순히 더 빠른 피드백을 제공하기 때문이지 테스트의 형식적인 유형에 따라 선을 긋고 싶어서가 아닙니다.


테스트 중복 피하기

이제 다양한 유형의 테스트를 작성해야 한다는 것을 알았으니 피해야 할 함정이 하나 더 있습니다. 바로 피라미드의 여러 계층에 걸쳐 테스트를 중복하는 것입니다. 직감적으로 테스트가 너무 많으면 안 된다고 생각할 수도 있지만, 실제로는 그렇지 않습니다. 테스트를 작성하고 유지 관리하는 데는 시간이 걸립니다. 다른 사람의 테스트를 읽고 이해하는 데도 시간이 걸립니다. 물론 테스트를 실행하는 데도 시간이 걸립니다.

프로덕션 코드와 마찬가지로 단순성을 추구하고 중복을 피하기 위해 노력해야 합니다. 테스트 피라미드를 구현할 때 두 가지 경험 법칙을 염두에 두어야 합니다:

  1. 상위 수준 테스트에서 오류를 발견했지만 실패한 하위 수준 테스트가 없는 경우 하위 수준 테스트를 작성해야 합니다.
  2. 테스트를 테스트 피라미드에서 가능한 한 아래로 밀어 넣으십시오.

첫 번째 규칙이 중요한 이유는 하위 수준 테스트를 사용하면 오류를 더 잘 좁히고 고립된 방식으로 오류를 복제할 수 있기 때문입니다. 더 빠르게 실행되고 당면한 문제를 디버깅할 때 덜 부풀어 오를 것입니다. 그리고 향후에 좋은 회귀 테스트가 될 것입니다.

두 번째 규칙은 전체 테스트를 빠르게 유지하는 데 중요합니다. 하위 수준 테스트에서 모든 조건을 자신 있게 테스트했다면 전체 테스트에 상위 수준 테스트를 유지할 필요가 없습니다. 모든 것이 제대로 작동한다는 확신을 더해주지 못할 뿐입니다. 테스트가 중복되면 일상 업무에서 성가신 일이 될 것입니다. 전체 테스트의 속도가 느려지고 코드의 동작을 변경할 때 더 많은 테스트를 변경해야 합니다.

이를 다르게 표현해 봅시다: 더 높은 수준의 테스트를 통해 어플리케이션이 올바르게 작동한다는 확신을 더 많이 얻을 수 있다면 그 테스트를 사용해야 합니다. 컨트롤러 클래스에 대한 단위 테스트를 작성하면 컨트롤러 자체 내의 로직을 테스트하는 데 도움이 됩니다. 하지만 컨트롤러가 제공하는 REST 엔드포인트가 실제로 HTTP 요청에 응답하는지 여부는 알 수 없습니다. 따라서 테스트 피라미드 위로 이동하여 정확히 이를 확인하는 테스트를 추가하면 됩니다. 하위 수준 테스트에서 이미 다루고 있는 모든 조건부 로직과 에지 케이스를 상위 수준 테스트에서 다시 테스트하지는 않습니다. 상위 수준 테스트가 하위 수준 테스트에서 다루지 못한 부분에 초점을 맞추도록 하세요.

저는 가치를 제공하지 않는 테스트는 엄격하게 제거합니다. 이미 낮은 수준에서 다루고 있는 높은 수준의 테스트는 추가적인 가치를 제공하지 않는다면 삭제합니다. 가능하면 상위 레벨 테스트를 하위 레벨 테스트로 대체합니다. 특히 테스트를 고안하는 것이 힘든 일이라는 것을 알고 있다면 더욱 그렇습니다. 매몰 비용 오류를 조심하고 삭제 키를 누르세요. 더 이상 가치를 제공하지 않는 테스트에 귀중한 시간을 낭비할 이유가 없습니다.


깔끔한 테스트 코드 작성

일반적으로 코드를 작성할 때와 마찬가지로 훌륭하고 깔끔한 테스트 코드를 작성하려면 세심한 주의가 필요합니다. 다음은 자동화된 테스트를 해킹하기 전에 유지 관리가 가능한 테스트 코드를 작성하기 위한 몇 가지 힌트입니다:

  1. 테스트 코드는 프로덕션 코드만큼이나 중요합니다. 동일한 수준의 주의와 관심을 기울이세요. "이것은 테스트 코드일 뿐입니다"라는 변명은 엉성한 코드를 정당화할 수 있는 유효한 변명이 될 수 없습니다.
  2. 테스트당 하나의 조건을 테스트하세요. 이렇게 하면 테스트를 짧고 추론하기 쉽게 유지할 수 있습니다.
  3. "arrange, act, assert" 또는 "given, when, then"는 테스트를 체계적으로 유지하는 데 좋은 니모닉(mnemonics)입니다.
  4. 가독성이 중요합니다. 지나치게 건조하게 만들지 마세요. 가독성을 향상시킨다면 중복도 괜찮습니다. DRY 코드와 DAMP 코드 사이의 균형을 찾으세요.
  5. 확실하지 않은 경우 The Rule of Three을 사용하여 리팩토링 시기를 결정하세요. Use before reuse

결론

여기까지입니다! 소프트웨어를 테스트해야 하는 이유와 방법을 설명하기 위해 길고 어려운 글을 작성했다는 것을 알고 있습니다. 좋은 소식은 이 정보가 어떤 종류의 소프트웨어를 빌드하든 관계없이 시대를 초월하는 정보라는 것입니다. 마이크로서비스 환경, IoT 디바이스, 모바일 앱 또는 웹 어플리케이션 등 어떤 환경에서 작업하든 이 글에서 얻은 교훈은 모두 적용될 수 있습니다.

이 글이 도움되었기를 바랍니다. 이제 샘플 코드를 확인하여 여기에 설명된 몇 가지 개념을 테스트 포트폴리오에 적용해 보세요. 탄탄한 테스트 포트폴리오를 구축하려면 약간의 노력이 필요합니다. 하지만 장기적으로 보면 보람이 있고 개발자로서의 삶을 더 평화롭게 만들어 줄 것입니다.