1. 개요

이 사용방법(예제)에서는 테스트 피라미드라고 하는 인기 있는 소프트웨어 테스트 모델을 이해합니다.

마이크로서비스 세계에서 이것이 어떻게 관련이 있는지 살펴보겠습니다. 그 과정에서 샘플 애플리케이션과 이 모델을 준수하는 관련 테스트를 개발할 것입니다. 또한 모델 사용의 이점과 경계를 이해하려고 노력할 것입니다.

2. 한 걸음 뒤로 물러서자

테스트 피라미드와 같은 특정 모델을 이해하기 전에 왜 이것이 필요한지 이해하는 것이 중요합니다.

소프트웨어 테스트의 필요성은 내재적이며 아마도 소프트웨어 개발 자체의 역사만큼 오래되었을 것입니다. 소프트웨어 테스팅은 수동에서 자동화에 이르기까지 먼 길을 왔습니다. 그러나 목표는 동일하게 유지됩니다. 즉, 사양을 준수하는 소프트웨어를 제공하는 것입니다 .

2.1. 테스트 유형

실제로 특정 목표에 초점을 맞춘 여러 유형의 테스트가 있습니다. 슬프게도 이러한 테스트에 대한 어휘와 이해도에는 상당한 차이가 있습니다.

인기 있고 모호하지 않을 수 있는 몇 가지를 검토해 보겠습니다.

  • 단위 테스트 : 단위 테스트는 코드의 작은 단위를 대상으로 하는 테스트입니다 . 여기서 목표는 나머지 코드베이스에 대해 걱정하지 않고 테스트 가능한 가장 작은 코드 조각의 동작을 검증하는 것입니다. 이것은 모든 의존성을 모의 객체나 스텁 또는 이와 유사한 구성으로 대체해야 함을 자동으로 의미합니다.
  • 통합 테스트 : 단위 테스트는 코드 조각의 내부에 초점을 맞추지만 많은 복잡성이 코드 외부에 있다는 사실이 남아 있습니다. 코드 단위는 함께 작동해야 하며 종종 데이터베이스, 메시지 브로커 또는 웹 서비스와 같은 외부 서비스와 함께 작동해야 합니다. 통합 테스트는 외부 의존성과 통합하는 동안 애플리케이션의 동작을 대상으로 하는 테스트입니다 .
  • UI 테스트 : 우리가 개발하는 소프트웨어는 종종 소비자가 상호 작용할 수 있는 인터페이스를 통해 소비됩니다. 종종 애플리케이션에는 웹 인터페이스가 있습니다. 그러나 API 인터페이스는 점점 대중화되고 있습니다. UI 테스트는 이러한 인터페이스의 동작을 대상으로 하며, 이러한 인터페이스는 본질적으로 대화형인 경우가 많습니다 . 이제 이러한 테스트를 종단 간 방식으로 수행하거나 사용자 인터페이스를 별도로 테스트할 수도 있습니다.

2.2. 수동 VS 자동 테스트

소프트웨어 테스트는 테스트 초기부터 수동으로 수행되었으며 오늘날에도 널리 시행되고 있습니다. 그러나 수동 테스트에 제한이 있음을 이해하는 것은 어렵지 않습니다. 테스트가 유용하려면 포괄적이고 자주 실행되어야 합니다.

이는 애자일 개발 방법론과 클라우드 네이티브 마이크로서비스 아키텍처에서 더욱 중요합니다. 그러나 테스트 자동화의 필요성은 훨씬 일찍 인식되었습니다.

앞에서 논의한 다양한 유형의 테스트를 기억한다면 단위 테스트에서 통합 및 UI 테스트로 이동함에 따라 복잡성과 범위가 증가합니다. 같은 이유로 단위 테스트의 자동화가 더 쉽고 대부분의 이점을 제공합니다 . 더 나아가면 틀림없이 적은 이점으로 테스트를 자동화하는 것이 점점 더 어려워집니다.

특정 측면을 제외하고 오늘날 대부분의 소프트웨어 동작 테스트를 자동화하는 것이 가능합니다. 그러나 이것은 자동화에 필요한 노력과 비교하여 이점을 합리적으로 평가해야 합니다.

3. 테스트 피라미드란?

테스트 유형 및 도구에 대한 충분한 컨텍스트를 수집했으므로 테스트 피라미드가 정확히 무엇인지 이해할 시간입니다. 우리는 우리가 작성해야 하는 다양한 유형의 테스트가 있음을 확인했습니다.

그러나 각 유형에 대해 몇 개의 테스트를 작성해야 하는지 어떻게 결정해야 합니까? 주의해야 할 이점이나 함정은 무엇입니까? 이들은 테스트 피라미드와 같은 테스트 자동화 모델이 해결하는 몇 가지 문제입니다.

Mike Cohn은 그의 저서 “ Succeeding with Agile ” 에서 Test Pyramid라는 구조를 제시했습니다 . 이는 서로 다른 세분성 수준에서 작성해야 하는 테스트 수를 시각적으로 나타냅니다 .

아이디어는 가장 세분화된 수준에서 가장 높아야 하고 테스트 범위를 넓힐수록 감소하기 시작해야 한다는 것입니다. 이것은 피라미드의 전형적인 모양을 제공하므로 이름은 다음과 같습니다.

피라미드

개념은 매우 간단하고 우아하지만 이를 효과적으로 채택하는 것은 종종 어려운 일입니다. 모델의 모양과 모델이 언급하는 테스트 유형에 집착해서는 안 된다는 점을 이해하는 것이 중요합니다. 핵심 내용은 다음과 같아야 합니다.

  • 다양한 수준의 세분성으로 테스트를 작성해야 합니다.
  • 테스트 범위가 거칠수록 더 적은 수의 테스트를 작성해야 합니다.

4. 테스트 자동화 도구

다양한 유형의 테스트를 작성하기 위해 모든 주류 프로그래밍 언어에서 사용할 수 있는 몇 가지 도구가 있습니다. Java 세계에서 널리 사용되는 몇 가지 선택 사항을 다룰 것입니다.

4.1. 단위 테스트

  • 테스트 프레임워크: Java에서 가장 인기 있는 선택은 JUnit5 로 알려진 차세대 릴리스가 있는 JUnit 입니다 . 이 영역에서 인기 있는 다른 선택으로는 JUnit5와 비교하여 몇 가지 차별화된 기능을 제공하는 TestNG 가 있습니다. 그러나 대부분의 애플리케이션에서 이 두 가지 모두 적합한 선택입니다.
  • Mocking: 앞에서 본 것처럼 단위 테스트를 실행하는 동안 전부는 아니더라도 대부분의 의존성을 확실히 제거하고 싶습니다. 이를 위해서는 모의 또는 스텁과 같은 테스트 더블로 의존성을 대체하는 메커니즘이 필요합니다. Mockito 는 Java에서 실제 객체에 대한 목을 프로비저닝하는 훌륭한 프레임워크입니다.

4.2. 통합 테스트

  • 테스트 프레임워크: 통합 테스트의 범위는 단위 테스트보다 넓지만 진입점은 더 높은 추상화에서 동일한 코드인 경우가 많습니다. 이러한 이유로 단위 테스트에 사용되는 동일한 테스트 프레임워크가 통합 테스트에도 적합합니다.
  • Mocking: 통합 테스트의 목적은 실제 통합으로 애플리케이션 동작을 테스트하는 것입니다. 그러나 테스트를 위해 실제 데이터베이스나 메시지 브로커를 사용하고 싶지 않을 수 있습니다. 많은 데이터베이스 및 유사한 서비스는 통합 테스트를 작성할 수 있는 포함 가능한 버전을 제공합니다.

4.3. UI 테스트

  • 테스트 프레임워크: UI 테스트의 복잡성은 소프트웨어의 UI 요소를 처리하는 클라이언트에 따라 다릅니다. 예를 들어 웹 페이지의 동작은 장치, 브라우저 및 운영 체제에 따라 다를 수 있습니다. Selenium은 웹 애플리케이션으로 브라우저 동작을 에뮬레이트하는 데 널리 사용되는 선택입니다. 그러나 REST API의 경우 REST 보장 과 같은 프레임워크가 더 나은 선택입니다.
  • Mocking: 사용자 인터페이스는 AngularReact 와 같은 JavaScript 프레임워크를 사용하여 더욱 상호 작용하고 클라이언트 측에서 렌더링되고 있습니다 . JasmineMocha 와 같은 테스트 프레임워크를 사용하여 이러한 UI 요소를 격리하여 테스트하는 것이 더 합리적입니다 . 분명히 우리는 종단 간 테스트와 함께 이것을 수행해야 합니다.

5. 실제 원칙 채택

지금까지 논의한 원칙을 보여주는 작은 응용 프로그램을 개발해 보겠습니다. 작은 마이크로서비스를 개발하고 테스트 피라미드에 부합하는 테스트를 작성하는 방법을 이해할 것입니다.

마이크로서비스 아키텍처는 애플리케이션을 도메인 경계 주위에 느슨하게 결합된 서비스 모음으로 구성하는 데 도움이 됩니다 . Spring Boot는 사용자 인터페이스와 데이터베이스와 같은 의존성이 있는 마이크로서비스를 거의 즉시 부트스트랩할 수 있는 탁월한 플랫폼을 제공합니다.

이를 활용하여 테스트 피라미드의 실제 적용을 시연합니다.

5.1. 애플리케이션 아키텍처

우리는 본 영화를 저장하고 쿼리할 수 있는 기본 응용 프로그램을 개발할 것입니다.

영구 데이터 저장소

우리가 볼 수 있듯이 세 개의 Endpoints을 노출하는 간단한 REST 컨트롤러가 있습니다.

@RestController
public class MovieController {
 
    @Autowired
    private MovieService movieService;
 
    @GetMapping("/movies")
    public List<Movie> retrieveAllMovies() {
        return movieService.retrieveAllMovies();
    }
 
    @GetMapping("/movies/{id}")
    public Movie retrieveMovies(@PathVariable Long id) {
        return movieService.retrieveMovies(id);
    }
 
    @PostMapping("/movies")
    public Long createMovie(@RequestBody Movie movie) {
        return movieService.createMovie(movie);
    }
}

컨트롤러는 데이터 마샬링 및 역마샬링을 처리하는 것 외에 적절한 서비스로 라우팅할 뿐입니다.

@Service
public class MovieService {
 
    @Autowired
    private MovieRepository movieRepository;

    public List<Movie> retrieveAllMovies() {
        return movieRepository.findAll();
    }
 
    public Movie retrieveMovies(@PathVariable Long id) {
        Movie movie = movieRepository.findById(id)
          .get();
        Movie response = new Movie();
        response.setTitle(movie.getTitle()
          .toLowerCase());
        return response;
    }
 
    public Long createMovie(@RequestBody Movie movie) {
        return movieRepository.save(movie)
          .getId();
    }
}

또한 지속성 계층에 매핑되는 JPA 저장소가 있습니다.

@Repository
public interface MovieRepository extends JpaRepository<Movie, Long> {
}

마지막으로 영화 데이터를 보유하고 전달하는 간단한 도메인 엔터티:

@Entity
public class Movie {
    @Id
    private Long id;
    private String title;
    private String year;
    private String rating;

    // Standard setters and getters
}

이 간단한 응용 프로그램을 통해 이제 다양한 세부 수준과 수량으로 테스트를 탐색할 준비가 되었습니다.

5.2. 단위 테스트

먼저 애플리케이션에 대한 간단한 단위 테스트를 작성하는 방법을 이해합니다. 이 애플리케이션에서 알 수 있듯이 대부분의 로직은 서비스 계층에 축적되는 경향이 있습니다 . 이것은 우리가 이것을 광범위하고 더 자주 테스트하도록 요구합니다. 단위 테스트에 매우 적합합니다.

public class MovieServiceUnitTests {
 
    @InjectMocks
    private MovieService movieService;
 
    @Mock
    private MovieRepository movieRepository;
 
    @Before
    public void setUp() throws Exception {
        MockitoAnnotations.initMocks(this);
    }
 
    @Test
    public void givenMovieServiceWhenQueriedWithAnIdThenGetExpectedMovie() {
        Movie movie = new Movie(100L, "Hello World!");
        Mockito.when(movieRepository.findById(100L))
          .thenReturn(Optional.ofNullable(movie));
 
        Movie result = movieService.retrieveMovies(100L);
 
        Assert.assertEquals(movie.getTitle().toLowerCase(), result.getTitle());
    }
}

여기서는 JUnit을 테스트 프레임워크로 사용하고 Mockito를 사용하여 의존성을 모의합니다. 이상한 요구 사항으로 인해 우리 서비스는 소문자로 영화 제목을 반환할 것으로 예상되었으며 여기에서 테스트하려는 것입니다. 이러한 단위 테스트로 광범위하게 다루어야 하는 몇 가지 동작이 있을 수 있습니다.

5.3. 통합 테스팅

단위 테스트에서 우리는 지속성 계층에 대한 의존성인 리포지토리를 조롱했습니다. 서비스 계층의 동작을 철저히 테스트했지만 데이터베이스에 연결할 때 여전히 문제가 있을 수 있습니다. 여기에서 통합 테스트가 시작됩니다.

@RunWith(SpringRunner.class)
@SpringBootTest
public class MovieControllerIntegrationTests {
 
    @Autowired
    private MovieController movieController;
 
    @Test
    public void givenMovieControllerWhenQueriedWithAnIdThenGetExpectedMovie() {
        Movie movie = new Movie(100L, "Hello World!");
        movieController.createMovie(movie);
 
        Movie result = movieController.retrieveMovies(100L);
 
        Assert.assertEquals(movie.getTitle().toLowerCase(), result.getTitle());
    }
}

여기에 몇 가지 흥미로운 차이점이 있습니다. 이제 우리는 의존성을 조롱하지 않습니다. 그러나 상황에 따라 몇 가지 의존성을 조롱해야 할 수도 있습니다 . 또한 SpringRunner 로 이러한 테스트를 실행하고 있습니다 .

이는 본질적으로 이 테스트를 실행할 Spring 애플리케이션 컨텍스트와 라이브 데이터베이스가 있음을 의미합니다. 당연히 더 느리게 실행됩니다! 따라서 여기에서 테스트할 시나리오를 훨씬 적게 선택합니다.

5.4. UI 테스트

마지막으로 우리 애플리케이션에는 사용할 REST 엔드포인트가 있으며 테스트할 고유한 뉘앙스가 있을 수 있습니다. 이것은 우리 애플리케이션의 사용자 인터페이스이므로 UI ​​테스트에서 집중적으로 다룰 것입니다. 이제 REST 보장을 사용하여 애플리케이션을 테스트해 보겠습니다.

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class MovieApplicationE2eTests {
 
    @Autowired
    private MovieController movieController;
 
    @LocalServerPort
    private int port;
 
    @Test
    public void givenMovieApplicationWhenQueriedWithAnIdThenGetExpectedMovie() {
        Movie movie = new Movie(100L, "Hello World!");
        movieController.createMovie(movie);
 
        when().get(String.format("http://localhost:%s/movies/100", port))
          .then()
          .statusCode(is(200))
          .body(containsString("Hello World!".toLowerCase()));
    }
}

보시다시피 이러한 테스트는 실행 중인 애플리케이션과 함께 실행되며 사용 가능한 엔드포인트를 통해 액세스합니다 . 응답 코드와 같은 HTTP와 관련된 일반적인 시나리오를 테스트하는 데 중점을 둡니다. 명백한 이유로 실행하기에 가장 느린 테스트가 될 것입니다.

따라서 여기에서 테스트할 시나리오를 선택하는 데 매우 까다롭습니다. 이전의 보다 세분화된 테스트에서 다룰 수 없었던 복잡성에만 집중해야 합니다.

6. 마이크로서비스용 테스트 피라미드

이제 다양한 세분성으로 테스트를 작성하고 적절하게 구조화하는 방법을 살펴보았습니다. 그러나 주요 목표는 보다 세분화되고 빠른 테스트를 통해 대부분의 애플리케이션 복잡성을 캡처하는 것입니다.

모놀리식 애플리케이션 에서 이 문제를 해결하면 원하는 피라미드 구조가 제공되지만 다른 아키텍처에는 필요하지 않을 수 있습니다 .

아시다시피 마이크로서비스 아키텍처는 애플리케이션을 사용하여 느슨하게 결합된 애플리케이션 집합을 제공합니다. 그렇게 함으로써 애플리케이션 고유의 복잡성 중 일부를 외부화합니다.

이제 이러한 복잡성은 서비스 간의 통신에서 나타납니다. 단위 테스트를 통해 캡처하는 것이 항상 가능한 것은 아니며 더 많은 통합 테스트를 작성해야 합니다.

이것은 우리가 고전적인 피라미드 모델에서 벗어난다는 것을 의미할 수 있지만 원칙에서도 벗어난다는 의미는 아닙니다. 우리는 여전히 가능한 한 세분화된 테스트를 통해 대부분의 복잡성을 포착하고 있음을 기억하십시오 . 우리가 그것에 대해 명확히 알고 있는 한, 완벽한 피라미드와 일치하지 않을 수 있는 모델은 여전히 ​​가치가 있을 것입니다.

여기서 이해해야 할 중요한 점은 모델이 가치를 제공하는 경우에만 유용하다는 것입니다. 종종 값은 컨텍스트에 따라 달라지며, 이 경우 애플리케이션에 대해 선택한 아키텍처입니다. 따라서 모델을 지침으로 사용하는 것이 도움이 되지만 기본 원칙에 초점을 맞추고 마지막으로 아키텍처 컨텍스트에서 의미 있는 것을 선택해야 합니다.

7. CI와의 통합

자동화된 테스트의 힘과 이점은 지속적 통합 파이프라인에 통합할 때 대부분 실현됩니다. Jenkins는 빌드 및 배포 파이프라인을 선언적으로 정의하는 데 널리 사용되는 선택입니다 .

Jenkins 파이프라인에서 자동화한 모든 테스트를 통합할 수 있습니다 . 그러나 이것이 파이프라인 실행 시간을 증가시킨다는 점을 이해해야 합니다. 지속적인 통합의 주요 목표 중 하나는 빠른 피드백입니다. 속도를 저하시키는 테스트를 추가하기 시작하면 충돌이 발생할 수 있습니다.

중요한 점은 더 자주 실행될 것으로 예상되는 파이프라인에 단위 테스트와 같이 빠른 테스트를 추가하는 것입니다 . 예를 들어 모든 커밋에서 트리거되는 파이프라인에 UI 테스트를 추가하면 이점이 없을 수 있습니다. 그러나 이것은 단지 지침일 뿐이며 마지막으로 우리가 다루고 있는 애플리케이션의 유형과 복잡성에 따라 다릅니다.

8. 결론

이 기사에서는 소프트웨어 테스트의 기본 사항을 살펴보았습니다. 다양한 테스트 유형과 사용 가능한 도구 중 하나를 사용하여 테스트를 자동화하는 것의 중요성을 이해했습니다.

또한 테스트 피라미드가 무엇을 의미하는지 이해했습니다. Spring Boot를 사용하여 구축된 마이크로서비스를 사용하여 이를 구현했습니다.

마지막으로, 특히 마이크로서비스와 같은 아키텍처의 맥락에서 테스트 피라미드의 관련성을 살펴보았습니다.

res – REST with Spring (eBook) (everywhere)