Як писати тести простіше та веселіше
Я — Віталій Корж, 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. Приклади та детальна документація доступні на сайтах продуктів, або просто погугліть.
20 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів