Як писати тести простіше та веселіше

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Я — Віталій Корж, Lead test developer на Java в Luxoft. Інколи складається враження, що написання тестів — це нудне та невідповідне вашому рівню завдання, яке розробники виконують заради примарних та не завжди зрозумілих показників.

Цей матеріал може бути цікавим для тих, хто тільки починає свій шлях в тестуванні або для тих, хто витрачає купу часу на написання тестів. У статті розглянемо лише ті випадки, коли тести необхідно писати, а не про те, які та навіщо.

Мушу визнати, що для мене написання тестів — це здебільшого монотонний та нудний процес, часто схожий на нескінченний копі-паст з модифікацією змінних. Такий підхід не є оптимальним, але використовуючи певні інструменти та фреймворки ми можемо полегшити та урізноманітнити роботу.

Мінімалізм є новою метою

З чого складається завдання по тестуванню: робота з кодом, ресурсами, інфраструктурою. Написання коду — найпростіша складова тестів, але в той же час і найбільш нудна. Щоб не витрачати час на написання тестів, можна користуватись шаблонами, підтримка яких в тому чи іншому вигляді є в кожній IDE (надалі будуть приклади з Intellij).

За допомогою скорочень можна генерувати цілі класи.

Розвитком цього напрямку можуть слугувати різні інструменти по генерації коду. Вони не досконалі, але модифікувати написане набагато краще, ніж просто ігнорувати тести як сутність. Чудовим прикладом можуть слугувати спроби Github та інших по створенню генераторів коду. Код, можливо, не завжди варто генерувати, але генерувати шаблонні тести вони спроможні. В деяких своїх проєктах я користуюсь плагіном DiffBlue (Java11, SpringBoot 2), який генерує фреймворк-орієнтовний код. На сайті Jetbrains є список генераторів різної якості.

Для сервісу:

@Service
public class AmazonService {

  @Autowired
  private AmazonS3 s3client;

  public PutObjectResult uploadFileToBucket(String bucketName, String key, File file) {
    return s3client.putObject(bucketName, key, file);
  }

  public S3Object downloadFileFromBucket(String bucketName, String key) {
    return s3client.getObject(bucketName, key);
  }
}

Тести матимуть вигляд:

@SpringBootTest
public class AmazonServiceDiffblueTest {
  @MockBean
  private AmazonS3Client amazonS3Client;
  @Autowired
  private AmazonService amazonService;
  @Test
  public void testUploadFileToBucket() {
    // Arrange
    PutObjectResult putObjectResult = new PutObjectResult();
    putObjectResult.setContentMd5("file-hash");
    when(this.amazonS3Client.putObject(or(isA(String.class), isNull()), or(isA(String.class), isNull()),
        or(isA(File.class), isNull()))).thenReturn(putObjectResult);

    // Act and Assert
    assertSame(putObjectResult, this.amazonService.uploadFileToBucket("foo", "foo",
        Paths.get(System.getProperty("java.io.tmpdir"), "test.txt").toFile()));
  }
  @Test
  public void testDownloadFileFromBucket() throws UnsupportedEncodingException {
    // Arrange
    StringInputStream objectContent = new StringInputStream("file-name");
    S3Object s3Object = new S3Object();
    s3Object.setObjectContent(objectContent);
    when(this.amazonS3Client.getObject(or(isA(String.class), isNull()), or(isA(String.class), isNull())))
        .thenReturn(s3Object);

    // Act and Assert
    assertSame(s3Object, this.amazonService.downloadFileFromBucket("foo", "foo"));
  }
}

Часто перешкодою в створення тестів стає відсутність необхідних методів в моделях додатку і не менш захоплюючий мотив «не моє, то краще не чіпати». Використання ProjectLombok дозволить скоротити час на розробку, а також додасть весь необхідний функціонал в моделі. Звичайно, одними лише моделями його використання не обмежується, але свій потенціал lombok розкриває саме там.

Декілька прикладів для свідків сект «Дебажимо з System.out.println» та «Змінювати модель не буду, бо білдер треба переписувати» — нижче.

Приклад використання @Log подібних анотацій

import lombok.extern.java.Log;
import lombok.extern.slf4j.Slf4j;

@Log
public class LogExample {
  
  public static void main(String... args) {
    log.severe("Something's wrong here");
  }
}

@Slf4j
public class LogExampleOther {
  
  public static void main(String... args) {
    log.error("Something else is wrong here");
  }
}

Приклад @Data та @Builder

import lombok.AccessLevel;
import lombok.Setter;
import lombok.Data;
import lombok.ToString;

@Data
@Builder
public class DataExample {
  private final String name;
  @Setter(AccessLevel.PACKAGE) private int age;
  private double score;
  @Singular private Set<String> tags;
}

Тим, кому набридло створювати однотипні кейси для різного набору даних і є стійке відчуття, що це потрібно змінити, є вихід — JUnit5 Parameterized tests.

@ParameterizedTest
@ValueSource(ints = { 1, 2, 3 })
void testWithValueSource(int argument) {
    assertTrue(argument > 0 && argument < 4);
}

@ParameterizedTest
@EnumSource(Month.class) // passing all 12 months
void testMonthIsAlwaysBetweenOneAndTwelve(Month month) {
    int monthNumber = month.getValue();
    assertTrue(monthNumber >= 1 && monthNumber <= 12);
}

@ParameterizedTest
@MethodSource("stringProvider")
void testWithExplicitLocalMethodSource(String argument) {
    assertNotNull(argument);
}

static Stream<String> stringProvider() {
    return Stream.of("apple", "banana");
}

Великі інтеграції маленьких програмістів

Достатньо захоплюючим прикладом оверінжинірингу, який часто заштовхує молоді уми в глуху оборону, є інтеграційні тести на кілька сотен рядків коду. Підключення та налаштування додаткових залежностей та перевірка асертами всіх можливих та неможливих значень.

Кожен, в тому чи іншому вигляді, зіштовхувався з тестами, які щось тестують, але єдине бажання, яке вони викликають — закрити та видалити редактор. Чи є такі тести інформативними? У жодному разі, головна ціль такого тесту, в зрозумілому програмісту вигляді документувати на прикладі свій код.

Для того щоб писати гарний код, вигадано купа методик, які забезпечують якість та допомагають в розробці складних систем. В той же час, коли справа доходить до тестування написаного, ми схильні про все забувати та виконувати роботу заради роботи, не вдаючись в деталі хто і як це буде підтримувати та наскільки корисні ті чи інші кейси. Приклад підміни суб’єктивної якості на об’єктивні відсотки покриття принижує цінність такої роботи.

Для досягнення потрібних показників можна вдало експлуатувати дизайн коду, спонукаючи розробника приймати потрібні рішення.

Divide and conquer

В будь-яких, навіть інтеграційних тестах, виділяються два типи маніпуляцій — поведінкові, аналіз взаємодії, та доменні, перевірка трансформацій. Треба чітко розуміти, коли і що перевіряти. Для подальшого спрощення, варто розділяти тести, які перевіряють логіку програми або дані.

Здавалося, навіщо взагалі таке розділення — ніколи такого не робив і все чудово працює. Такий розподіл стає доцільним в перспективі, коли на ваше місце прийдуть інші розробники і зіштовхнуться з мереживом коду з бізнеслогіки та трансформації даних в одному тестовому класі.

Чудовим прикладом такого розділення є винесення тестових даних в ресурси, хоча ніщо не заважає вказувати їх в самому класі. Така практика ні в кого не викликає питань, то чому стратегії тестування повинні?

SOLID

На відміну від попередньої стратегії, варто поглянути на те, що тестувати і як ретельно це потрібно робити. В кожному проекті є певні правила, але здебільшого тести відповідають один одному з класами нашого сервісу. Ініціалізація спільних даних мігрує до статичних класів і на цьому, здебільшого, оптимізація завершується.

Чим відрізняється тестування? Тим, що код є основним клієнтом. У випадку, коли ми перевіряємо контрлер і пишемо декілька позитивних та негативних кейсів на один ендпоінт, варто замислитись над тим, що варто виділити його в окремий клас. В залежності від потреб бізнесу, тестуючі класи варто розглядати як групи для окремого методу. Такий розподіл дозволить ізолювати необхідні частини проєкту та детальніше пропрацювати тесткейси.

Один у полі воїн

Ваш код нічого не вартий, допоки його ніхто не використовує. Те ж саме з тестами, ви можете дуже красиво та розгорнуто пропрацювати всі деталі, але при цьому використовувати лише моки та асерти.

Такий підхід не завжди є доцільним, іноді виникає необхідність перевірити не тільки внутрішньо програмну взаємодію, але й зовнішні ресурси. В залежності від того, який варіант ви оберете, буде залежати те, наскільки корисним для інших буде ваш код.

З чого взагалі починається таке тестування? З самого початку ми маємо окремо запущену та налаштовану базу, локально все чудово працює. При спробі запустити програму на іншій системі, спочатку треба налаштувати все необхідне оточення, що потребує детальної документації.

На перший погляд, виходом може слугувати вбудована база, таке рішення дозволяє перевірити необхідні інтеграції, але не гарантує повної сумісності з реальною системою і є певним компромісом. Рішенням вище озвучених компромісів будуть тест-контейнери. Вони взаємодіють з реальною базою та відкривають легку інтеграцію як з вбудованою. Прикладом такого рішень є TestContainers.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ContextConfiguration(initializers = AbstractIT.DockerMysqlDataSourceInitializer.class)
@Testcontainers
public abstract class AbstractIT {
    
    public static MySQLContainer<?> mysqlDBContainer = new MySQLContainer<>("mysql");
    
    static {
        mysqlDBContainer.start();
    }
    
    public static class DockerMysqlDataSourceInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
        
        @Override
        public void initialize(ConfigurableApplicationContext applicationContext) {
            
            TestPropertySourceUtils.addInlinedPropertiesToEnvironment(
                    applicationContext,
                    "spring.datasource.url=" + mysqlDBContainer.getJdbcUrl(),
                    "spring.datasource.username=" + mysqlDBContainer.getUsername(),
                    "spring.datasource.password=" + mysqlDBContainer.getPassword()
            );
        }
    }
}

Якщо тестування інтеграцій з підконтрольними ресурсами не має викликати проблем, то інтеграція з сторонніми сервісами може викликати певні труднощі.

Проблема перша: відсутність контролю над сервісом, з’єднання з яким ми перевіряємо, які призводять до розсинхронізації реальних інтерфейсів з тестовими. Здебільшого це вирішується впровадженням системного тестування, де ми перевіряємо всю екосистему. Такий підхід зміщує акценти в сторону від імплементації і позбавляє розробника контролю.

Проблема друга: байдужість сервера. Допоки команда повністю контролює розробку всіх своїх підсистем, проблема непомітна, всі необхідні зміни тиражуються по залежним сервісам всередині домену команди. Що робити, коли сервіс виходить за межі вашої відповідальності? Треба будувати тестове оточення — це дозволить вам налагодити діалог з усіма клієнтами, зовнішніми або внутрішніми командами.

Зрозуміло, що підготувати тестові дані не достатньо, необхідно якось доставити їх користувачам. Тут кожен приймає рішення для себе, чи буде то json артефакт чи docker image. В запуску допоможуть MockServer та подібні рішення. Вони значно полегшують життя всім користувачам вашого API та наладять необхідну комунікацію між різними командами, дозволяючи спіймати критичні зміни ще на етапі розробки.

Висновок

Існує безліч різних інструментів та технік, які дозволяють підвищити продуктивність або досягти бажаного результату. Можна використовувати все та отримати в результаті незрозумілу нікому систему, а можна не знати нічого з цього і писати достойний код.

Важливо пам’ятати, що ви самі ставите перед собою завдання і від складності тих чи інших компонентів залежить наше фінальне рішення. І коли ви не знаєте, як зробити красиво, то просто зробіть, щоб працювало, бо немає нічого гіршого, ніж незакінчене мистецтво.

P. S. Приклади та детальна документація доступні на сайтах продуктів, або просто погугліть.

👍НравитсяПонравилось4
В избранноеВ избранном3
LinkedIn
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

На жаль, в цій статті не написано, а як ви перевіряєте самі тести? Адже тести теж можуть бути неправильними, наприклад, приберіть з тестів assertions, і відсоток покриття залишиться таким же, а від тесту не буде ніякої користі.

Якщо ціль зламати систему, то існує багато варіантів як це зробити.
В статі не розглядаються системи аналізу коду та ревью практики.

public static class DockerMysqlDataSourceInitializer implements ApplicationContextInitializer {

@Override
public void initialize(ConfigurableApplicationContext applicationContext) {

TestPropertySourceUtils.addInlinedPropertiesToEnvironment(
applicationContext,
„spring.datasource.url=” + mysqlDBContainer.getJdbcUrl(),
„spring.datasource.username=” + mysqlDBContainer.getUsername(),
„spring.datasource.password=” + mysqlDBContainer.getPassword()
);
}
}

В останніх версіях Spring з’явилася анотація @DynamicPropertySource, яка дозволяє позбутися від цього громіздкого коду

@Testcontainers
public abstract class AbstractIT {

public static MySQLContainer mysqlDBContainer = new MySQLContainer<>("mysql");

static {
mysqlDBContainer.start();
}

А не простіше використовувати анотацію @Container?
@Container
public static MySQLContainer mysqlDBContainer = new MySQLContainer<>("mysql");

Тоді не потрібно буде вручну запускати контейнер. І як ви його тоді зупиняєте?

В одному з коментів нижче неведені бібліотеки які значно спрощують процесс ініціалізації.
Тест контейнери, це концепт і якщо комусь вони підходять, то є набагото детальніші статті на цю тему.

Так, а для чого приклади у вашій статті? Мені здавалося, що приклади наводяться для того, щоб інші їх використовували. А ви просто показали кілька прикладів того, як НЕ ТРЕБА писати код. І навіть не розумієте цього.

Статя ознайомча, приклади — лише ілюстрація.
Кожен сам вирішує що йому робити з отриманою інформацією.

@Test
public void testUploadFileToBucket() {
// Arrange
PutObjectResult putObjectResult = new PutObjectResult();
putObjectResult.setContentMd5("file-hash");
when(this.amazonS3Client.putObject(or(isA(String.class), isNull()), or(isA(String.class), isNull()),
or(isA(File.class), isNull()))).thenReturn(putObjectResult);

// Act and Assert
assertSame(putObjectResult, this.amazonService.uploadFileToBucket("foo„, „foo”,
Paths.get(System.getProperty("java.io.tmpdir"), "test.txt").toFile()));
}

Знову, якщо ви говорите про best practices, то я наприклад, завжди додаю в кінці виклик Mockito.verify, якщо використовую mocks. Тому що інакше ви ніяк перевірите, що ваш mocked метод був викликаний.

Це лише приклад того що генерює DiffBlue. Звичайно такий код має недоліки але його наявність може комусь допомогти почати тестувати.

Тобто ви навіть не розумієте, що за код генерує цей DiffBlue?

У різних спеціалістів свої проблеми. Не всі навідь так писати хочуть.

Paths.get(System.getProperty("java.io.tmpdir")

В Junit 5 з’явилася дуже зручна анотація @TempDir, мені здається, це краще, ніж пам’ятати всі ці system properties

@SpringBootTest
public class AmazonServiceDiffblueTest {
@MockBean
private AmazonS3Client amazonS3Client;
@Autowired
private AmazonService amazonService;
@Test
public void testUploadFileToBucket() {

Якщо ви вже говорите про те, як правильно писати тести і використовуєте JUnit 5, то все-таки давайте використовувати best practices:

1) Модифікатори private, public більше не потрібні. Це ж тести.
2) Назва методу testUploadFileToBucket не говорить про те, що ми очікуємо від тесту — що upload буде успішний чи ні, який файл ми завантажуємо і т.д. Крім того, в JUnit 5 є дуже зручна анотація @DisplayName, яка дозволяє людською мовою прописати все це. А так доведеться лізти в код тесту і дивитися, для чого він потрібен.

Інколи складається враження, що написання тестів — це нудне та невідповідне вашому рівню завдання, яке розробники виконують заради примарних та не завжди зрозумілих показників.

Не зовсім згоден, що це дуже нудний процес.
Якщо ви написали код, його потрібно якось перевірити, що він працює і працює правильно. Для цього є два варіанти, мануальне або автоматизоване тестування.
Третього жаль не знаю, можливо ви знаєте?
Так ось, мануальное тестування — реально нудний процес, і я б точно вибрав замість нього написання тестів.

Тести можуть просто бути, а можуть щось тестувати. Доволі часто спостерігаю картину як тести перевіряють самі себе.
Тести то важливо але треба розуміти навіщо вони потрібні інакше то нудне полотно з асертів.

що це потрібно змінити, є вихід — JUnit5 Parameterized tests.

мабуть далі повинен бути приклад з використанням @ParameterizedTest та @ValueSource анотаціями?)

Спасибо, за внимательность, сейчас исправим.

Огромное спасибо за отличную статью!

Тестирование в Spring Boot — одно из самых удобных, что я встречал на практике.

Для тестирования интеграции со сторонними сервисами, над которыми нет контроля — есть решение — Test Containers. Эта библиотека позволяет максимально приблизить тестирование к реальным сервисам, базам данных, брокерам (в отличии от in-memory решений, таких как H2). В этом случае зависимости поднимаются локально в виде докер контейнеров. Эти зависимости можно довольно гибко настраивать и использовать в конпонентных тестах.
Есть даже отдельное дополнение для Spring Boot, которое добавлением одной зависимости в проект, позволяет получить на этапе старта контекста одну или больше зависимостей в докер контейнерах.

Дополнительно, в Spring Boot есть целый набор «заготовок» для тестирования слоев сервиса (@WebMvcTest, @DataJpaTest, etc). Они позволяет еще больше изолировать части сервиса и сконцентрироваться на конкретных элементах.

А дальше — мокирование взаимодействия с другими сервисами можно выполнять спомощью Wiremock.

Спасибо за качественное дополнение.

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

Главное уменьшить объем необходимого головняка и обеспечить приемлемое качество. А как это сделать каждый решит сам.

Подписаться на комментарии