Mocks vs TestContainers

Иван Пономарев

ivan

Иван Пономарев

  • Staff Engineer @ Synthesized.io

  • Преподаю Java в МФТИ и Mainor

Почему я решил сделать этот доклад?

Kafka streams testing: A deep dive (Joker 2020, online)

kafka testing deep dive

О чём пойдёт речь

  • Интеграционные тесты

  • Личный опыт последней пары лет

Современный интеграционный тест

diag 97bf3662b09b866a11bfe789b1673aca

Современный интеграционный тест

diag 61d58b0658ad29e2e66957742f56dee7

Современный интеграционный тест

diag a8047ddeeb9e73d1a2573a633f9d941f

Современный интеграционный тест

diag b3f8c92cc131cd5ecc16f4ce1870f6fa

Современный интеграционный тест

diag 58e24765f8ba78f530c6390b8b1ba18c

Современный интеграционный тест

diag 36be4cc346adbd16535d6239407b8581

Что у нас есть?

Моки

vs

Реальные системы

Использовать моки — это как учить химию по мультикам!

То ли дело настоящий эксперимент!

Что у нас есть для настоящих экспериментов?

testcontainers transparent

TestContainers in Action

GenericContainer redis = new GenericContainer(
        DockerImageName.parse("redis:5.0.3-alpine"))
        .withExposedPorts(6379);

String address = redis.getHost();
Integer port = redis.getFirstMappedPort();

underTest = new RedisBackedCache(address, port);

Набор стереотипов

  •  — Моки — это ненадёжно

  •  — Поднимем всё-всё в тестконтейнерах и затестируем!

  •  — Не надо больше использовать H2 для тестирования кода, работающего на базе данных!

  •  — А как внешние сервисы?.. их в тестконтейнерах не поднимешь…​

Моки внешних сервисов

diag 77b48cd4b10531356c721872683e4310

Моки внешних сервисов

arrange solntsev

Моки внешних сервисов

Моки

vs

Реальные системы

thumbs up Мы контролируем мок и легко можем имитировать все corner cases

thumbs down Мы не контролируем внешний сервис и его работоспособность

Пример из книги Humans vs. Computers

«Давайте отпустим из тюрем всех, кто не совершал тяжких преступлений»

try {
  murders = restClient.loadMurders();
}
catch (IOException e) {
  logger.error(failed to load, e);
  murders = emptyList();
}

WireMock может

имитировать ответ от сервиса на запрос

diag 974a98bd71ac659e45d30a66543e28a1

WireMock может

верифицировать произведённые вызовы

diag b72a92002f2ecf4ef2fa10be622ce59c

WireMock может

«шпионить», перехватывая вызовы к настоящему сервису

diag 30196fb645c588576fbafe195ae28284

Как быть с RDBMS/NoSQL/Message brokers и иными?

diag 0293b3a28f068481f2ec189471549e6f

Как быть с RDBMS/NoSQL/Message brokers и иными?

  •  — Это слишком сложно, чтобы мокировать!

  •  — Ура, контейнеры!

Mocks vs TestContainers: функциональность

Mocks

vs

Testcontainers

  • thumbs down Не гарантируют, что ведут себя так же, как настоящая система.

  • thumbs down Написать bug-to-bug compatible мок сложнее, чем настоящую систему, никто и никогда этого не сделает.

Mocks vs TestContainers: функциональность

Mocks

vs

Testcontainers

  • thumbs down Не гарантируют, что ведут себя так же, как настоящая система.

  • thumbs down Написать bug-to-bug compatible мок сложнее, чем настоящую систему, никто и никогда этого не сделает.

thumbs up Это настоящая система и есть!

Mocks vs TestContainers: простота и скорость запуска

Mocks

vs

Testcontainers

  • thumbs up Обычная зависимость

  • thumbs up Являются частью теста, стартуют моментально

Mocks vs TestContainers: простота и скорость запуска

bepatient

Mocks vs TestContainers: простота и скорость запуска

Mocks

vs

Testcontainers

  • thumbs up Обычная зависимость

  • thumbs up Являются частью теста, стартуют моментально

  • shrug Нужен Docker (OK, сейчас он есть везде)

  • shrug Нужно скачать образ (зависит от размера и скорости интернета)

  • shrug Нужно запустить контейнер (от нескольких секунд до пары минут)

TC Startup time

startup

Ничего не поделаешь, если

thumbs down «У меня Mac M1!»

tc cloud

Удобство

Mocks

vs

Testcontainers

thumbs up Быстрые и надёжные тесты хочется чаще запускать и больше их писать

thumbs down «Страшные» тесты некогда ждать и отлаживать, хочется их скипнуть

Integration Mocks vs TestContainers

Mocks

vs

Testcontainers

  • thumbs up white box (верификация вызовов со стороны тестируемой системы)

  • thumbs up возможность имитировать любое состояние, включая сбои

  • thumbs up возможно синхронное выполнение (об этом речь впереди)

Integration Mocks vs TestContainers

Mocks

vs

Testcontainers

  • thumbs up white box (верификация вызовов со стороны тестируемой системы)

  • thumbs up возможность имитировать любое состояние, включая сбои

  • thumbs up возможно синхронное выполнение (об этом речь впереди)

  • thumbs down трудно «загнать» систему в нужное состояние

  • thumbs down трудно проверить, какие команды вызывались

Наличие

Mocks

vs

Testcontainers

shrug Иногда они есть, но чаще всего их нет.

thumbs up Можно использовать для всего, что можно запустить в контейнерах.

Пример № 1. JedisMock и верификация вызовов

  • переимплементация Redis на Java (работает на уровне сетевого протокола)

//This binds mock redis server to a random port
RedisServer server = RedisServer
        .newRedisServer()
        .start();

//Jedis connection:
Jedis jedis = new Jedis(server.getHost(), server.getBindPort());

JedisMock

  • Тестируется Comparison-тестами (прогон одинаковых сценариев на Jedis-Mock и на контейнеризованном Redis)

comparison

JedisMock

По состоянию на июнь 2022, поддерживает 103 из 225 команд (46%)

supported redis operations

JedisMock

Постоянно латаются ошибки (поведение, отличающиеся от настоящего Redis)

jedis mock bugs

Зачем нужен JedisMock

Обычная Maven зависимость

//This binds mock redis server to a random port
RedisServer server = RedisServer
        .newRedisServer()
        .start();

//Jedis connection:
Jedis jedis = new Jedis(server.getHost(), server.getBindPort());

RedisCommandInterceptor

RedisServer server = RedisServer.newRedisServer()
  .setOptions(ServiceOptions.withInterceptor((state, cmd, params) -> {
    if ("get".equalsIgnoreCase(cmd)) {
      //явно прописываем ответ
      return Response.bulkString(Slice.create("MOCK_VALUE"));
    } else {
      //делегируем в мок
      return MockExecutor.proceed(state, cmd, params);
    }
})).start();

RedisCommandInterceptor

RedisServer server = RedisServer.newRedisServer()
  .setOptions(ServiceOptions.withInterceptor((state, cmd, params) -> {
    if ("echo".equalsIgnoreCase(cmd)) {
      //проверяем запрос
      assertEquals("hello", params.get(0).toString());
    }
    //делегируем в мок
    return MockExecutor.proceed(state, cmd, params);
})).start();

RedisCommandInterceptor

RedisServer server = RedisServer.newRedisServer()
  .setOptions(ServiceOptions.withInterceptor((state, cmd, params) -> {
    if ("echo".equalsIgnoreCase(cmd)) {
      //имитируем сбой
      return MockExecutor.breakConnection(state);
    } else {
      //делегируем в мок
      return MockExecutor.proceed(state, cmd, params);
    }
})).start();

Работа как Test Proxy

diag 21cb369f868dd383ab2fe389c6e9d072

Выводы по Jedis-Mock

  • index up Для большинства задач тестирования Redis TestContainers работает.

  • index up Но если вы желаете верифицировать поведение собственной системы или изучать её в ситуации, когда сбоит сам Redis — JedisMock в помощь.

Пример №2. Kafka Streams TopologyTestDriver и ад асинхронного тестирования

diag f600bc2015f066a37c4b14dca10a4c15
vs
diag c6e5ac55d5193c56da4e25c0c8c01f97

Kafka streams testing: A deep dive (Joker 2020, online)

kafka testing deep dive

TopologyTestDriver

  • thumbs up Простой (обычная зависимость)

  • thumbs up Быстрый

  • thumbs up Удобный (хороший API для Arrange и Assert)

Главное отличие:

TopologyTestDriver

vs

Real Kafka

Работает синхронно (один поток и event loop, как в браузере)

Работает асинхронно во многих тредах на многих контейнерах

Мысленный эксперимент: ограниченность асинхронных тестов

  • Мы ввели "ping" и ожидаем, что система вернёт нам единственный ответ "pong".

  • yellow circle 2 секунды. Нет ответа.

  • yellow circle 3 секунды. Нет ответа.

  • checkmark 4 секунды. "pong". Расходимся?

  • checkmark 5 секунд. Тишина.

  • checkmark 6 секунд. Тишина.

  • cross mark 7 секунд. "boom!"

Проблема с поллингом

//5 seconds?? maybe 6? maybe 4?
while (!(records = consumer.poll(Duration.ofSeconds(5))).isEmpty()) {
    for (ConsumerRecord<String, String> rec : records) {
        values.add(rec.value());
    }
}

Фундаментальная проблема: это финальный результат или мы недостаточно долго ждали?

Awaitility: частичное решение проблемы с асинхронным тестом

Awaitility.await().atMost(10, SECONDS).until(() ->
                  { // returns true
                  });

yellow circle

step1

Awaitility: частичное решение проблемы с асинхронным тестом

Awaitility.await().atMost(10, SECONDS).until(() ->
                  { // returns true
                  });

yellow circle

step2

Awaitility: частичное решение проблемы с асинхронным тестом

Awaitility.await().atMost(10, SECONDS).until(() ->
                  { // returns true
                  });

checkmark

awaitility pass

Awaitility: падение теста

Awaitility.await().atMost(10, SECONDS).until(()->
                  { // returns false for more than 10 seconds
                  });
cross mark
awaitility fail

Возможности Awaitility DSL

  • atLeast (не должно произойти раньше)

  • atMost (не должно произойти позже)

  • during (должно происходить на протяжении интервала)

  • период опроса:

    • постоянный (1, 2, 3, 4…​)

    • Фибоначчи (1, 2, 3, 5…​)

    • экспоненциальный (1, 2, 4, 8…​)

  • Awaitility ускоряет асинхронные тесты, но не преодолевает фундаментальную проблему асинхронных тестов

Ничего не напоминает?

  • Selenium WebDriverWait

  • Selenide’s implicit wait

selenide logo

Проблемы Awaitility

  • Гарантий нет ⇒ Flakiness

  • Мы вступаем на скользкую тропку concurrent Java programming

Настоящий тест с Awaitility: часть 1

List<String> actual = new CopyOnWriteArrayList<>();
ExecutorService service = Executors.newSingleThreadExecutor();
Future<?> consumingTask = service.submit(() -> {
  while (!Thread.currentThread().isInterrupted()) {
    ConsumerRecords<String, String> records =
      consumer.poll(Duration.ofMillis(100));
    for (ConsumerRecord<String, String> rec : records) {
      actual.add(rec.value());
}}});

Настоящий тест с Awaitility: часть 2

try {
  Awaitility.await().atMost(5, SECONDS)
           .until(() -> List.of("A", "B").equals(actual));
} finally {
    consumingTask.cancel(true);
    service.awaitTermination(200, MILLISECONDS);
}

Тест с TopologyTestDriver

List<String> values = outputTopic.readValuesToList();
Assertions.assertEquals(List.of("A", "B"), values);

В чём же подвох??

  • синхронный характер и отсутствие кэширования ⇒ разное поведение

  • можно построить простые примеры кода, дающего «зелёный» тест на TTD, но работающего абсолютно некорректно на реальном кластере

Выводы по KafkaStreams:

  • index up Вам всё равно не обойтись без помощи TopologyTestDriver

  • index up Но надо понимать ограничения этой технологии, аспекты, в которых она не работает так же, как и реальный кластер

  • shrug Падение на TTD означает что код плох. Удачное выполнение на TTD не означает, что код хорош.

  • index up При необходимости, небольшое количество тестов производится на контейнеризованном кластере.

История №3. Apache Beam: мок как один из поддерживаемых бэкендов

beam logo
  • Apache Beam is a unified programming model to define and execute data processing pipelines, including ETL, batch and stream (continuous) processing.

  • SDKs: Java, Python, Go

Apache Beam Runners

  • Runners:

    • Apache Flink,

    • Apache Nemo,

    • Apache Samza,

    • Apache Spark,

    • Google Cloud Dataflow,

    • Hazelcast Jet,

    • Direct Runner

diag 7be795d2ca45b41f9ac498b608e3b0bc

--runner=DirectRunner

Паттерн: Mock Backend

diag 55a2c4c335e2924ac3e70be49fb5850b

Множества функциональных возможностей

beam backends

Матрица поддерживаемых возможностей (фрагмент)

beam backends
beam capability matrix

Direct Runner

beam direct

"Direct Runner performs additional checks to ensure that users do not rely on semantics that are not guaranteed by the model…​ Using the Direct Runner helps ensure that pipelines are robust across different Beam runners."

Apache Beam’s Direct Runner

  • index up Падение на Direct Runner означает что код плох.

  • shrug Удачное выполнение на Direct Runner не означает, что код хорош.

  • shrug Как затестировать Google Cloud Dataflow без Google Cloud — мне неведомо.

Пример №4. Celesta: не спешим отказываться от H2

celesta duke
  • https://github.com/CourseOrchestra/celesta

  • Легковесный способ разработки приложений на основе реляционных баз данных

  • Альтернатива связке «ORM + мигратор»

Celesta

celesta duke
  • Database-first: пользователь определяет желаемую структуру базы данных, Celesta кодогенерирует API доступа к данным и занимается автоматической миграцией.

  • Database-agnostic: стуктура таблиц и представлений описывается на CelestaSQL, транспилируемый затем в один из поддерживаемых диалектов.

Celesta

diag 2592ae7e3ed3da94e192b35d2b7974f5

Celesta

celesta backends
  • 5 баз данных, несовместимых в деталях

Celesta

celesta sql
  • CelestaSQL транспилируется в конкретные диалекты

  • Поддерживается узкое подмножество возможностей

  • Сама Celesta тестируется Comparison-тестами (прогон одинаковых сценариев на всех типах баз данных)

Celesta Comparison Tests

comparison celesta

Celesta

  • Работает на H2 ⇒ будет работать на PostgreSQL, MS SQL, etc..

Celesta

  • Работает на H2 ⇒ будет работать на PostgreSQL, MS SQL, etc..

  • Mock Backend здорового человека!

diag 55a2c4c335e2924ac3e70be49fb5850b

Возможности in-memory H2

  • thumbs up Стартует с пустой базой моментально

  • thumbs up Мигрируется моментально

  • thumbs up Поступающие запросы трассируются элементарно
    (SET TRACE_LEVEL_SYSTEM_OUT 2)

  • thumbs up После теста состояние «забывается»

CelestaTest: Arrange

@CelestaTest
class OrderDaoTest {
  OrderDao orderDao = new OrderDao();
  CustomerCursor customer;
  ItemCursor item;

CelestaTest: Arrange

@CelestaTest
class OrderDaoTest {
  OrderDao orderDao = new OrderDao();
  CustomerCursor customer;
  ItemCursor item;

  @BeforeEach
  void setUp(CallContext ctx) {
    customer = new CustomerCursor(ctx);
    customer.setName("John Doe")
                 .setEmail("john@example.com").insert();

    item = new ItemCursor(ctx);
    item.setId("12345")
           .setName("cheese").setDefaultPrice(42).insert();
  }

CelestaTest: Local arrange

@Test
void orderedItemsMethodReturnsAggregatedValues(CallContext ctx)
    throws Exception {
  //ARRANGE
  ItemCursor item2 = new ItemCursor(ctx);
  item2.setId("2")
    .setName("item 2").insert();

  OrderCursor orderCursor = new OrderCursor(ctx);
  orderCursor.setId(null)
    .setItemId(item.getId())
    .setCustomerId(customer.getId())
    .setQuantity(1).insert();

  //и так далее

CelestaTest: Act & Assert

//ACT
List<ItemDto> result = orderDao.getItems(ctx);

//ASSERT
Approvals.verifyJson(new ObjectMapper()
  .writer().writeValueAsString(result));

CelestaTest

  • thumbs up Работает моментально

  • thumbs up Создаёт пустую базу данных нужной структуры под каждый тест

  • thumbs up Провоцирует на написание большого количества тестов на всю логику работы с базой данных

  • shrug Цена, которую мы платим — это ограничение функциональности в пределах того, что поддерживает Celesta.

Выводы о TestContainers

  • index up Могут создавать проблемы со скоростью запуска и конфигурацией машины разработчика.

  • index up Настоящие сервисы — «чёрные ящики», их трудно загонять в нужное состояние.

  • index up Интеграционные тесты с «настоящими» сервисами — асинхронные, с непреодолимыми трудностями. Эти трудности надо осознавать.

Выводы о моках

  • index up Специализированные моки подключаются проще, стартуют и выполняются быстрее.

  • index up Моки имеют специальную функциональность, облегчающую тестирование.

  • index up Моки работают не так же, как настоящая система. Этот факт надо понять и принять.

  • index up Для вашей системы их может попросту не существовать.

Общие выводы

  • index up При формировании стратегии тестирования надо ориентироваться не на стереотипы, а на глубокое понимание особенностей системы и доступного инструментария. Всякий раз стратегия будет разной!

  • index up Тестируемость системы в целом должна быть одним из критерием при выборе технологий.

Самый Главный Вывод

index up Надо использовать и моки, и контейнеры,
но прежде всего — собственную голову.


@inponomarev