«1. Обзор
В этом руководстве мы познакомимся с популярной моделью тестирования программного обеспечения, называемой тестовой пирамидой.
Посмотрим, насколько это актуально в мире микросервисов. В процессе мы разработаем образец приложения и соответствующие тесты для соответствия этой модели. Кроме того, мы попытаемся понять преимущества и границы использования модели.
2. Давайте сделаем шаг назад
Прежде чем мы начнем разбираться в какой-либо конкретной модели, такой как тестовая пирамида, необходимо понять, зачем она вообще нам нужна.
Необходимость тестировать программное обеспечение неотъемлема и, возможно, так же стара, как и сама история разработки программного обеспечения. Тестирование программного обеспечения прошло долгий путь от ручного до автоматизации и далее. Однако цель остается прежней — поставлять программное обеспечение, соответствующее спецификациям.
2.1. Типы тестов
На практике существует несколько различных типов тестов, ориентированных на определенные цели. К сожалению, словарный запас и даже понимание этих тестов сильно различаются.
Давайте рассмотрим некоторые из популярных и, возможно, недвусмысленных:
-
Модульные тесты. Модульные тесты — это тесты, нацеленные на небольшие блоки кода, желательно изолированно. Цель здесь состоит в том, чтобы проверить поведение наименьшего тестируемого фрагмента кода, не беспокоясь об остальной кодовой базе. Это автоматически означает, что любая зависимость должна быть заменена макетом, заглушкой или подобной конструкцией. Интеграционные тесты. В то время как модульные тесты сосредоточены на внутренней части кода, факт остается фактом: большая часть сложности лежит за его пределами. Элементы кода должны работать вместе и часто с внешними службами, такими как базы данных, брокеры сообщений или веб-службы. Интеграционные тесты — это тесты, нацеленные на поведение приложения при интеграции с внешними зависимостями. Тесты пользовательского интерфейса: программное обеспечение, которое мы разрабатываем, часто потребляется через интерфейс, с которым могут взаимодействовать потребители. Довольно часто приложение имеет веб-интерфейс. Однако API-интерфейсы становятся все более популярными. Тесты пользовательского интерфейса нацелены на поведение этих интерфейсов, которые часто носят интерактивный характер. Теперь эти тесты можно проводить сквозным образом или пользовательские интерфейсы также можно тестировать изолированно.
2.2. Ручное и автоматизированное тестирование
Тестирование программного обеспечения проводилось вручную с самого начала тестирования, и оно широко применяется на практике даже сегодня. Однако нетрудно понять, что ручное тестирование имеет ограничения. Чтобы тесты были полезными, они должны быть всеобъемлющими и выполняться часто.
Это еще более важно в гибких методологиях разработки и архитектуре облачных микросервисов. Однако потребность в автоматизации тестирования была осознана гораздо раньше.
Если мы вспомним различные типы тестов, которые мы обсуждали ранее, их сложность и масштабы возрастают по мере того, как мы переходим от модульных тестов к интеграционным и UI-тестам. По той же причине автоматизация модульных тестов проще и также имеет большинство преимуществ. По мере того, как мы идем дальше, становится все труднее автоматизировать тесты с, возможно, меньшими преимуществами.
За исключением некоторых аспектов, на сегодняшний день можно автоматизировать тестирование поведения большинства программ. Однако это должно быть рационально взвешено с учетом преимуществ по сравнению с усилиями, необходимыми для автоматизации.
3. Что такое тестовая пирамида?
Теперь, когда мы собрали достаточно информации о типах тестов и инструментах, пришло время понять, что такое тестовая пирамида. Мы видели, что существуют разные типы тестов, которые мы должны писать.
Однако как решить, сколько тестов нужно написать для каждого типа? На какие преимущества или подводные камни обратить внимание? Вот некоторые из проблем, которые решает модель автоматизации тестирования, такая как тестовая пирамида.
Майк Кон придумал конструкцию, названную тестовой пирамидой, в своей книге «Успех с Agile». Это представляет собой визуальное представление количества тестов, которые мы должны написать на разных уровнях детализации.
«Идея состоит в том, что он должен быть самым высоким на самом детальном уровне и должен начать уменьшаться по мере того, как мы расширяем масштаб теста. Это дает типичную форму пирамиды, отсюда и название:
Несмотря на то, что концепция довольно проста и элегантна, ее эффективное применение часто является проблемой. Важно понимать, что мы не должны зацикливаться на форме модели и типах тестов, которые в ней упоминаются. Ключевым выводом должно быть следующее:
-
Мы должны писать тесты с разным уровнем детализации Мы должны писать меньше тестов по мере того, как мы становимся более грубыми с их масштабом
4. Инструменты автоматизации тестирования
Есть несколько инструментов, доступных во всех основных языки программирования для написания различных типов тестов. Мы рассмотрим некоторые популярные варианты в мире Java.
4.1. Модульные тесты
-
Тестовая среда: наиболее популярным выбором здесь, в Java, является JUnit, у которого есть версия следующего поколения, известная как JUnit5. Среди других популярных вариантов в этой области — TestNG, предлагающий несколько отличных от JUnit5 функций. Однако для большинства приложений подходят оба варианта. Насмешка: как мы видели ранее, мы определенно хотим вычесть большинство зависимостей, если не все, при выполнении модульного теста. Для этого нам нужен механизм замены зависимостей тестовым двойником, таким как mock или stub. Mockito — отличный фреймворк для создания моков для реальных объектов в Java.
4.2. Интеграционные тесты
-
Тестовая среда. Объем интеграционного теста шире, чем у модульного теста, но точкой входа часто является тот же код с более высокой степенью абстракции. По этой причине те же тестовые фреймворки, которые работают для модульного тестирования, подходят и для интеграционного тестирования. Насмешка: цель интеграционного теста — проверить поведение приложения с реальной интеграцией. Однако мы можем не захотеть использовать для тестов реальную базу данных или брокер сообщений. Многие базы данных и подобные сервисы предлагают встраиваемую версию для написания интеграционных тестов.
4.3. Тесты пользовательского интерфейса
-
Структура тестирования: сложность тестов пользовательского интерфейса зависит от клиента, обрабатывающего элементы пользовательского интерфейса программного обеспечения. Например, поведение веб-страницы может различаться в зависимости от устройства, браузера и даже операционной системы. Selenium — популярный выбор для эмуляции поведения браузера в веб-приложении. Однако для REST API лучшим выбором являются такие фреймворки, как REST-assured. Насмешки: пользовательские интерфейсы становятся более интерактивными и отображаются на стороне клиента с помощью фреймворков JavaScript, таких как Angular и React. Более разумно тестировать такие элементы пользовательского интерфейса изолированно, используя тестовую среду, такую как Jasmine и Mocha. Очевидно, мы должны делать это в сочетании со сквозными тестами.
5. Применение принципов на практике
Давайте разработаем небольшое приложение для демонстрации принципов, которые мы уже обсуждали. Мы разработаем небольшой микросервис и поймем, как писать тесты, соответствующие тестовой пирамиде.
Микросервисная архитектура помогает структурировать приложение как набор слабо связанных сервисов, очерченных границами домена. Spring Boot предлагает отличную платформу для быстрой загрузки микросервиса с пользовательским интерфейсом и зависимостями, такими как базы данных.
Мы воспользуемся ими, чтобы продемонстрировать практическое применение тестовой пирамиды.
5.1. Архитектура приложения
Мы разработаем элементарное приложение, которое позволит нам хранить и запрашивать фильмы, которые мы смотрели:
Как мы видим, оно имеет простой контроллер 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. Тестирование пользовательского интерфейса
Наконец, у нашего приложения есть конечные точки REST для использования, которые могут иметь свои нюансы для тестирования. Поскольку это пользовательский интерфейс для нашего приложения, мы сосредоточимся на его рассмотрении в нашем тестировании пользовательского интерфейса. Давайте теперь воспользуемся REST-assured для тестирования приложения:
@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. Однако мы должны понимать, что это увеличивает время выполнения конвейера. Одной из основных целей непрерывной интеграции является быстрая обратная связь. Это может привести к конфликту, если мы начнем добавлять тесты, которые замедляют работу.
Ключевым выводом должно быть добавление быстрых тестов, таких как модульные тесты, в конвейер, который, как ожидается, будет выполняться чаще. Например, мы не можем получить выгоду от добавления тестов пользовательского интерфейса в конвейер, который срабатывает при каждой фиксации. Но это всего лишь рекомендация, и, наконец, все зависит от типа и сложности приложения, с которым мы имеем дело.
8. Заключение
В этой статье мы рассмотрели основы тестирования программного обеспечения. Мы поняли разные типы тестов и важность их автоматизации с помощью одного из доступных инструментов.
Кроме того, мы поняли, что означает тестовая пирамида. Мы реализовали это с помощью микросервиса, созданного с помощью Spring Boot.
Наконец, мы рассмотрели актуальность тестовой пирамиды, особенно в контексте такой архитектуры, как микросервисы.