Что пишут в блогах

Подписаться

Что пишут в блогах (EN)

Разделы портала

Онлайн-тренинги

.
Бюджетное тестирование облачных приложений: Testcontainers и LocalStack
04.06.2026 00:00

Автор: Фернандо Тексейра (Fernando Teixeira)
Оригинал статьи
Перевод: Ольга Алифанова

Облачные приложения: текущая ситуация

В наши дни многие корпоративные приложения работают в облаке и используют множество сервисов, доступных через облачных провайдеров. По состоянию на 2024 год 94 процента компаний по всему миру в той или иной степени внедрили облачные вычисления. Эти облачные сервисы включают виртуальные машины, файловые хранилища, очереди сообщений, базы данных, мониторинг, логирование, инструменты безопасности и многое другое.

Сегодня вы можете построить приложение, на 100 процентов работающее в облаке. Наиболее популярны сейчас облачные провайдеры AWS, Google Cloud и Azure. Именно эти трое во многом обеспечивают работу большинства облачных приложений. В 2023 году компании потратили примерно 270 миллиардов долларов США на облачную инфраструктуру — на 45 миллиардов больше, чем годом ранее.

Большинство облачных провайдеров взимают плату в зависимости от объёма использования сервисов. Чем больше вы используете сервисы провайдера, тем серьезнее будет счёт в конце месяца. Как правило, провайдеры всё ещё предлагают бесплатный тариф, но при превышении определённого лимита вам всё равно придётся платить. Проблема в том, что многие компании сильно зависят от этих сервисов и активно их используют, из-за чего сложно оставаться в рамках лимитов. 49 процентов компаний испытывают трудности с контролем затрат на облако, а 33 процента превышают бюджет на облако на 40 процентов.

Сложности при тестировании облачных приложений

В целом, чем больше у вас пользователей, тем сильнее нужно масштабировать приложение. Это означает нужду в дополнительных вычислительных ресурсах: более мощные виртуальные машины с большим количеством CPU и памяти, больше файлового хранилища и ёмкости баз данных для хранения данных приложения, больше очередей сообщений для обработки входящих событий и так далее.

Доход от новых пользователей, безусловно, помогает оправдать рост затрат, но всё равно стоит искать, как сэкономить на облачных расходах. Эти расходы включают и использование облака тестировщиками, которые проверяют, что приложение работает как ожидается. Само по себе тестирование не приносит дохода, а за использование облака при тестировании всё равно приходится платить. Поэтому вам нужна хорошая стратегия управления этими затратами. 28 процентов расходов на публичное облако ежегодно тратятся впустую, часто из-за избыточного выделения ресурсов и отсутствия надлежащего управления. Заблаговременное планирование поможет убедиться, что тестирование в вашей организации не станет частью этой проблемы.

Как снизить затраты на тестирование?

Чтобы избежать лишних затрат на использование облака, вы можете разработать стратегии, при которых проверяется, что приложение соответствует всем ожидаемым критериям качества, и при этом максимально снижается использование зависимых облачных сервисов в процессе тестирования.

Это может показаться серьёзной задачей, так как люди могут решить, что для обеспечения высокого качества нужно проводить обширное тестирование всех частей приложения. На самом деле тут просто требуется верная стратегия валидации приложения.

К счастью, существуют хорошие инструменты, помогающие реализовать такую стратегию. Читайте дальше, чтобы узнать больше.

Что такое LocalStack

LocalStack — это бесплатный инструмент с открытым исходным кодом, который эмулирует облачные сервисы на вашем компьютере. Это как если бы у вас было облако, запущенное локально, но без затрат на его использование.

С помощью LocalStack можно эмулировать облачные сервисы, от которых зависит ваше приложение. Это не будет стоить вашей организации ни копейки, и вы устраните риск необходимости поддерживать разные окружения-песочницы и аккаунты.

Важно отметить, что LocalStack работает именно с сервисами AWS. Инструмент достаточно зрелый, имеет большое сообщество и покрывает почти все существующие сервисы AWS. Однако если ваше приложение использует другие облачные провайдеры, такие как GCP или Azure, возможно, придётся рассмотреть другие похожие инструменты для достижения той же цели. Например:

  • Google Cloud CLI Emulators
  • Azurite

Некоторые из этих альтернатив пока не обладают той же зрелостью и покрытием, что и LocalStack, но они регулярно обновляются и развиваются.

Что такое Testcontainers?

Testcontainers — это бесплатная библиотека с открытым исходным кодом, которая предоставляет простые и лёгкие тестовые зависимости в виде реальных сервисов, упакованных в Docker-контейнеры. Любой Docker-контейнер можно поднять как часть окружения для выполнения тестов.

Также можно создавать необходимые для выполнения тестов конфигурации. Например, если вам нужно поднять базу данных, можно написать код, который создаст конкретные таблицы и заполнит их тестовыми данными.

После завершения выполнения тестов Testcontainers также выполнит все необходимые шаги по очистке. Это обеспечит чистое состояние для следующих тестов.


Источник: https://testcontainers.com/getting-started/

Ещё одна интересная возможность Testcontainers — поддержка модулей, представляющих собой заранее настроенные интеграции с зависимостями вашего приложения. Это делает процесс написания тестов ещё проще. Один из таких модулей — LocalStack, интеграция с которым рассматривается ниже. Полный список доступных модулей можно посмотреть здесь: Testcontainers modules.

Настоящая сила LocalStack раскрывается при использовании в связке с Testcontainers. В этом случае можно интегрировать зависимые облачные сервисы прямо в тестовый код без значительных усилий.

Testcontainers можно использовать с разными языками программирования и фреймворками, включая:

  • Java
  • Go
  • .NET
  • Node.js
  • Python
  • Ruby
  • Clojure
  • Haskell
  • Elixir
  • Rust

LocalStack и Testcontainers в действии

Чтобы показать, как использовать LocalStack и Testcontainers вместе на практике, приведу несколько примеров кода из личного проекта, над которым я работаю.

Это простое приложение для анализа отзывов и комментариев пользователей. Оно состоит из следующих компонентов: веб-сайт, два бэкенд-микросервиса, AWS SQS и AWS S3 Bucket.

Вот архитектура:

 

Сервис Review Collector вызывается для отправки нового отзыва в очередь SQS после того, как пользователь отправляет его через сайт. Затем сервис Review Analyzer обрабатывает новые сообщения из очереди SQS, анализирует отзыв и отправляет результаты в S3 Bucket.

Несмотря на простоту приложения, это хороший пример для демонстрации преимуществ использования Testcontainers и LocalStack в тестовой стратегии.

С помощью LocalStack и Testcontainers можно создавать два типа тестов: интеграционные и end-to-end (E2E). В этой статье основное внимание уделено E2E-тестам, но в моём репозитории также можно найти примеры интеграционных тестов для этого проекта: Review Analysis Cloud Microservices - Fernando Teixeira.

Настройка E2E-теста

  1. Сначала вам нужно настроить тестовое окружение, в котором все зависимости приложения и сами сервисы будут запускаться в Docker-контейнерах. В этом случае Testcontainers поднимет три контейнера:
    • контейнер LocalStack с доступными S3 и SQS
    • контейнер Review Collector Service
    • контейнер Review Analyzer Service
  2. E2E-тест моделирует полный поток обработки отзыва, описанный выше. Он начинается с запроса к сервису Review Collector и заканчивается сохранением отзыва в S3 bucket.
  3. Затем Testcontainers выполняет очистку: останавливает и удаляет все Docker-контейнеры, созданные для этого теста.

Диаграмма ниже иллюстрирует процесс выполнения E2E-теста:


Теперь давайте посмотрим, как выглядит код, реализующий эту идею.

Настройка теста

Настройка теста — самая важная часть кода, поскольку именно здесь хранится конфигурация Testcontainers и LocalStack.

Сначала нужен файл с базовой конфигурацией и шагами инициализации. Класс E2E-теста будет импортировать и использовать эту настройку.

Ниже пример базового конфигурационного класса:

package com.teixeirafernando.e2e.tests;
 
import org.junit.jupiter.api.BeforeAll;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.Network;
import org.testcontainers.containers.localstack.LocalStackContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.lifecycle.Startables;
import org.testcontainers.utility.DockerImageName;
import java.io.IOException;
 
@Testcontainers
public class TestContainersConfiguration {
 
private static final Network SHARED_NETWORK = Network.newNetwork();
protected static GenericContainer<?> ReviewCollectorService;
protected static GenericContainer<?> ReviewAnalyzerService;
 
static protected final String BUCKET_NAME = "review-analysis-bucket";
static protected final String QUEUE_NAME = "review-analysis-queue";
 
@Container
protected static LocalStackContainer localStack = new LocalStackContainer(
DockerImageName.parse("localstack/localstack:4.0.3")
).withNetwork(SHARED_NETWORK).withNetworkAliases("localstack");
 
@BeforeAll
static void beforeAll() throws IOException, InterruptedException {
localStack.execInContainer("awslocal", "s3", "mb", "s3://" + BUCKET_NAME);
localStack.execInContainer(
"awslocal",
"sqs",
"create-queue",
"--queue-name",
QUEUE_NAME
);
 
ReviewCollectorService = createReviewCollectorServiceContainer(8080);
ReviewAnalyzerService = createReviewAnalyzerServiceContainer(8081);
 
Startables.deepStart(ReviewCollectorService, ReviewAnalyzerService).join();
}
 
private static GenericContainer<?> createReviewCollectorServiceContainer(int port) {
return new GenericContainer<>("teixeirafernando/review-collector:latest")
.withEnv("AWS_ENDPOINT", "http://localstack:4566")
.withExposedPorts(port)
.withNetwork(SHARED_NETWORK);
}
 
private static GenericContainer<?> createReviewAnalyzerServiceContainer(int port) {
return new GenericContainer<>("teixeirafernando/review-analyzer:latest")
.withEnv("AWS_ENDPOINT", "http://localstack:4566")
.withExposedPorts(port)
.withNetwork(SHARED_NETWORK);
}
}

Разберём этот код по частям:

import org.testcontainers.containers.localstack.LocalStackContainer;
 
import org.testcontainers.containers.GenericContainer;

Нужно импортировать несколько классов:

  • класс LocalStackContainer уже содержит множество встроенных возможностей, упрощающих конфигурацию
  • GenericContainer используется для запуска контейнеров двух других сервисов приложения.
private static final Network SHARED_NETWORK = Network.newNetwork();

Общий сетевой объект нужен, чтобы все контейнеры находились в одной сети и могли взаимодействовать друг с другом.

@Container
protected static LocalStackContainer localStack = new LocalStackContainer(
DockerImageName.parse("localstack/localstack:4.0.3")
).withNetwork(SHARED_NETWORK).withNetworkAliases("localstack");

Здесь вы задаёте конкретный Docker-образ LocalStack, подключаете контейнер к общей сети и назначаете ему алиас внутри сети.

@BeforeAll
static void beforeAll() throws IOException, InterruptedException {
localStack.execInContainer("awslocal", "s3", "mb", "s3://" + BUCKET_NAME);
localStack.execInContainer(
"awslocal",
"sqs",
"create-queue",
"--queue-name",
QUEUE_NAME
);
 

   ReviewCollectorService = createReviewCollectorServiceContainer(8080);

   ReviewAnalyzerService = createReviewAnalyzerServiceContainer(8081);


   Startables.deepStart(ReviewCollectorService, ReviewAnalyzerService).join();

}

В блоке BeforeAll вы определяете все шаги, которые должны быть выполнены до запуска тестов.

  • Сначала выполняется конфигурация контейнера LocalStack — создаются S3 bucket и очередь SQS с заданными именами
  • Затем создаются и запускаются контейнеры сервисов ReviewCollectorService и ReviewAnalyzerService.

Тест E2E

Теперь можно создать E2E-тест, который будет использовать эту конфигурацию.

Чтобы упростить проверки в E2E-тестах, используется библиотека REST Assured — она позволяет отправлять REST API-запросы и проверять ответы сервисов с помощью ассертов.

Вот как выглядит тест целиком:

package com.teixeirafernando.e2e.tests;
 
import static io.restassured.RestAssured.given;
import static org.awaitility.Awaitility.await;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.notNullValue;
 
import org.apache.http.HttpStatus;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
 
import java.time.Duration;
import java.util.Optional;
 
public class ReviewAnalysisE2ETest extends TestContainersConfiguration {
 
@Test
@DisplayName("Create a new Review and make the sentiment analysis")
void analyzeReviewSuccessfully(){
 
String fullReviewCollectorURL = "http://"+ TestContainersConfiguration.ReviewCollectorService.getHost()+":8080";
String fullReviewAnalyzerURL = "http://"+ TestContainersConfiguration.ReviewAnalyzerService.getHost()+":8081";
 
String review = """
{
"productId": "da6037a6-a375-40e2-a8a6-1bb5f9448df0",
"customerName": "test",
"reviewContent": "test",
"rating": 5.0
}
""";
 
String id = given()
.contentType("application/json")
.body(review)
.when()
.post(fullReviewCollectorURL + "/api/review")
.then()
.assertThat()
.statusCode(HttpStatus.SC_OK)
.body("productId", equalTo("da6037a6-a375-40e2-a8a6-1bb5f9448df0"))
.extract()
.path("id");
 
System.out.println("Review Collector Service created a new Review and pushed it to SQS");
 
 
await()
.pollInterval(Duration.ofSeconds(5))
.atMost(Duration.ofSeconds(30))
.untilAsserted(() -> {
given()
.contentType("application/json")
.when()
.get(fullReviewAnalyzerURL + "/api/messages/" + id)
.then()
.assertThat()
.statusCode(HttpStatus.SC_OK)
.body("id", equalTo(id))
.body("reviewAnalysis", notNullValue());
});
 
System.out.println("Review Analyzed Service processed the Review and sent it to S3");
 
}
}

Разберём, что здесь происходит:

public class ReviewAnalysisE2ETest extends TestContainersConfiguration {

Сначала нужно унаследоваться от ранее созданного класса TestContainersConfiguration.

   String fullReviewCollectorURL = "http://"+      TestContainersConfiguration.ReviewCollectorService.getHost()+":8080";
String fullReviewAnalyzerURL = "http://"+ TestContainersConfiguration.ReviewAnalyzerService.getHost()+":8081";

Здесь вы получаете endpoint’ы двух сервисов, извлекая адрес хоста из контейнеров.

String review = """
{
"productId": "da6037a6-a375-40e2-a8a6-1bb5f9448df0",
"customerName": "my-customer-name",
"reviewContent": "This is my comment for why my rating for this product was 5",
"rating": 5.0
}
""";

Создаётся тестовый отзыв, который будет обработан сервисами приложения.

String id = given()
.contentType("application/json")
.body(review)
.when()
.post(fullReviewCollectorURL + "/api/review")
.then()
.assertThat()
.statusCode(HttpStatus.SC_OK)
.body("productId", equalTo("da6037a6-a375-40e2-a8a6-1bb5f9448df0"))
.extract()
.path("id");
 

   System.out.println("Review Collector Service created a new Review and pushed it to SQS");

В первой части теста вы отправляете API-запрос в сервис Review Collector, передаёте созданный отзыв и выполняете проверки, чтобы убедиться, что он был успешно создан и отправлен в очередь SQS.

await()
.pollInterval(Duration.ofSeconds(5))
.atMost(Duration.ofSeconds(30))
.untilAsserted(() -> {
given()
.contentType("application/json")
.when()
.get(fullReviewAnalyzerURL + "/api/messages/" + id)
.then()
.assertThat()
.statusCode(HttpStatus.SC_OK)
.body("id", equalTo(id))
.body("reviewAnalysis", notNullValue());
});
 

System.out.println("Review Analyzed Service processed the Review and sent it to S3");

В последней части теста выполняется запрос ко второму сервису Review Analyzer, чтобы убедиться, что он обработал отзыв и отправил результат в S3.

Поскольку обработка сообщений из SQS происходит асинхронно, на обработку отзыва приложением может потребоваться время. Чтобы избежать нестабильных тестов, используется функция await с механизмом повторных попыток.

Преимущества подхода

Используя Testcontainers и LocalStack для тестирования облачных сервисов, вы получаете:

  • Локальное тестовое окружение, близкое к продакшену: комбинация Testcontainers и LocalStack позволяет создать среду, максимально приближенную к реальному облачному окружению. Вы можете тестировать приложение в контролируемом, изолированном и воспроизводимом виде.
  • Снижение затрат: использование LocalStack избавляет от необходимости выделять и оплачивать реальные облачные ресурсы для тестов. Это дает значительную экономию на затратах, особенно для сложных сценариев, где задействовано много сервисов.
  • Быструю обратную связь: вы можете запускать тесты локально или в CI/CD-пайплайне без доступа к реальным облачным сервисам. Это ускоряет цикл обратной связи, позволяя быстро проверять изменения в локальном окружении, имитирующем облачные сервисы, от которых зависит ваше приложение.
  • Предопределённое состояние: Testcontainers обеспечивает чистое и одинаковое начальное состояние перед каждым запуском тестов, что снижает нестабильность, вызванную различием состояний.
  • Лучшее понимание зависимостей: хотя на начальном этапе потребуется время, чтобы описать все зависимости и конфигурации, в итоге вы получите более глубокое понимание того, как устроено ваше приложение и от чего оно зависит.

Заключение

Тестирование облачных приложений не обязательно должно быть дорогим. С помощью таких инструментов, как LocalStack и Testcontainers, можно создать локальную версию облачных сервисов и запускать все необходимые тесты без неожиданных счетов от провайдера.

Вы даже можете применять подход shift-left, поскольку вам не нужно разворачивать всё приложение для запуска тестов. Можно найти проблемы раньше, исправлять их и улучшать приложение без затрат на реальное использование облака во время тестирования. Это разумный и экономичный способ убедиться, что ваше облачное приложение работает как ожидается.

Примеры в этой статье были ориентированы на приложения, зависящие от сервисов AWS. Однако Testcontainers и LocalStack можно использовать и с другими облачными провайдерами.

Если коротко: использование LocalStack вместе с Testcontainers позволит вам выстроить эффективную тестовую стратегию, подходящую именно вам.

Полный проект и примеры из статьи вы можете найти в моём репозитории на Github.

Дополнительная информация