Особливості тестування Spring-застосунків

💡 Усі статті, обговорення, новини про тестування — в одному місці. Приєднуйтесь до QA спільноти!

Всім привіт. Я Сергій Моренець, розробник, викладач, спікер і технічний письменник, хочу поділитися з вами своїм досвідом роботи з такою цікавою темою, як тестування коду в Spring/Spring Boot проєктах.

За різними опитуваннями останніх років Spring Boot проєкти займають до двох третин ринку Java-застосунків. Тому сучасні джавісти повинні добре знати не тільки використання Spring Framework при розробці, але і при тестуванні. Особливість автоматизованих тестів у тому, що тут використовуються трохи інші парадигми програмування і конфігурування, ніж для production коду.

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

Передісторія

Для розгляду візьмемо JDK 19, Spring 6, Spring Boot 3 та найпопулярнішу Java-бібліотеку для написання тестів — Junit останньої версії (5.9), тим більше, що вона чудово інтегрується з Spring, починаючи зі Spring 5.

Візьмемо найпростіший застосунок, де є в тому числі два компоненти:

  1. ProductService — цей сервіс реалізує певні бізнес-завдання щодо роботи з товарами;
  2. ProductAPI — це інтерфейс, чия реалізація може завантажувати/ зберігати товари із зовнішнього сховища (це може бути база даних або яка стороння система).

У цьому прикладі не розглядатиметься такий підхід як TDD, тобто код у нас уже є, а тестів ще немає. Якби ми не використовували Spring (або якийсь інший контейнер для DI), то написати скелет для тестового класу досить просто:

public class ProductServiceTest {
               
                ProductService productService;
               
                ProductAPI productAPI;
               
                @BeforeEach
                void setup() {
                                productAPI = mock(ProductAPI.class);
                                productService = new ProductService(productAPI);
                }

У цьому прикладі ми хочемо написати юніт-тест, тому мокуємо залежність ProductAPI. Але для Spring-застосунка так просто не вдасться зробити, тому що всі об’єкти, що розглядаються, швидше за все будуть бінами, пов’язаними з іншими бінами (або беруть налаштування з Spring оточення), і нам доведеться вручну створювати і пов’язувати всі ці об’єкти. Це досить-таки безглузда робота, тому що всі ці об’єкти і так вже знаходяться в application context (як Spring Біни) і потрібно просто їх звідти отримати.

Для цього є дві готові інструкції, які інтегрують JUnit та Spring:

  1. SpringJUnitConfig/SpringJUnitWebConfig — для Spring-застосунків
  2. SpringBootTest — для Spring Boot проектів

Юніт-тести

Візьмемо SpringBootTest, оскільки швидше за все наш застосунок базуватиметься саме на Spring Boot:

@SpringBootTest(classes = ProductApplication.class)
public class ProductServiceTest {
 
                @Autowired
                ProductService productService;
               
                @MockBean
                ProductAPI productAPI;

Тут ми вказали клас-конфігурацію ProductApplication, яка запускає наш Spring Boot проєкт і де оголошено наші біни та анотацію @MockBean для того, щоб Spring Boot за допомогою Mockito завантажив у application context фейкову реалізацію ProductAPI.

У цьому приклад є невеликий мінус. Якщо порівняти з анотацією @Value, ми так само оголошуємо поле в класі тільки для того, щоб вказати, що цей бін мокуватиметься. Тому якщо ви не використовуєте ProductAPI у цьому тестовому класі, тоді можна анотацію @MockBean поставити прямо на клас:

@SpringBootTest(classes = ProductApplication.class)
@MockBean(ProductAPI.class)
public class ProductServiceTest {

У будь-якому випадку обидва приклади є коректними і дозволяють написати юніт-тести для ProductService. Але працюючий код не завжди є найоптимальнішим. Коли ми стартуємо Spring-застосунок, то піднімаємо весь application context. І це звучить логічно, тому що він цілком знадобиться нам для роботи. Тут же в тесті нам потрібен лише один бін — ProductService, заради якого нам доводиться завантажувати всі Spring Boot біни (включаючи всі внутрішні). Це дуже сповільнить запуск тесту. І хоча Spring вміє кешувати application context (ми цього торкнемося трохи нижче), то все одно можна спробувати оптимізувати ініціалізацію тесту.

У Spring Boot вже є готові анотації, які завантажують лише певну частину контексту — @DataJpaTest, WebMvcTest або @RestClientTest. Тут вони нам, на жаль, не допоможуть, оскільки вони розроблені для певних Spring-проєктів, але можна застосувати інший принцип — виключити завантаження тієї частини контексту, яка відноситься до веб (за допомогою атрибуту webEnvironment):

@SpringBootTest(classes = ProductApplication.class,
                webEnvironment = WebEnvironment.NONE)
public class ProductServiceTest {

Але як бути з рештою бінiв? Тут можна застосувати ще один патерн, який називається lazy initialization. У Spring є анотація @Lazy, яка, якщо її додати для якогось біна, відкладає ініціалізацію цього біна до моменту першого використання. Але мало хто захоче додавати її кожному біну. Тому, починаючи з Spring Boot 2.2, є глобальна властивість, яка робить майже всі біни як lazy:

spring.main.lazy-initialization=true

Я написав «майже всі», тому що, наприклад, біни, які використовують @Scheduled анотацію, все одно будуть завантажені. Наскільки безпечно використовувати цю властивість? Єдиний її недолік — якщо раніше ви дізнавалися про проблеми в конфігурації відразу на старті застосунку, то тепер це може статися будь-коли. Але для тестів це особливо не важливо, тому що нас цікавлять лише потрібні нам біни.

Поки що залишимо тему оптимізації та перейдемо до керування залежностями. Уявімо, що нам потрібний не макований ProductAPI, а якась готова тестова stub-реалізація:

public class StubProductAPI implements ProductAPI {
 
                @Override
                public List<Product> findAll() {
                                return List.of();
                }
}

Як вказати саме її у нашому тесті? Тут є кілька варіантів. Перший — це звичайно профілі. Ми додаємо анотацію @Profile, наприклад з профілем «prod» для звичайної реалізації:

                @Bean
                @Profile("prod")
                ProductAPI productAPI() {
                                return new DbProductAPI();
                }

І профілем «test» для тестової реалізації у нашому тестовому класі:

                @Configuration
                @Profile("test")
                static class ProductServiceConfiguration {
                               
                                @Bean
                                ProductAPI productAPI() {
                                                return new StubProductAPI();
                                }
                }

І тепер достатньо вказати активний профіль для тесту чи тестів:

@SpringBootTest(classes = ProductApplication.class, webEnvironment = WebEnvironment.NONE)
@ActiveProfiles("test")
public class ProductServiceTest {

Але тут можна потрапити у пастку та отримати помилку, якщо ми не додали новий клас-конфігурацію:

Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No bean named 'productAPI' available

Тому доведеться його явно додати до @SpringBootTest:

@SpringBootTest(classes = { ProductApplication.class, ProductServiceConfiguration.class},
webEnvironment = WebEnvironment.NONE)
@ActiveProfiles("test")

Чи обов’язково використовувати профілі? Що, якщо просто додати клас-конфігурацію і спробувати перевизначити бін? Це працювало до версії Spring Boot 2.1, а зараз ви просто отримаєте помилку:

The bean 'productAPI', defined in demo.service.ProductServiceTest$ProductServiceConfiguration, could not be registered. A bean with that name has already been defined in demo.ProductApplication and overriding is disabled.
Action:
Consider renaming one of the beans or enabling overriding by setting spring.main.allow-bean-definition-overriding=true

Можна додати зазначену властивість spring.main.allow-bean-definition-overriding, але це небезпечно, так як це глобальна властивість і можна випадково перевизначити не той бін.

Чи можна обійтись без профілів? Так, можна, якщо поставити анотацію @Primary на нове оголошення бина в тесті. Наші тести працюють коректно, але й тут на нас можуть чатувати непомітні проблеми. Наприклад, якщо ми використовуємо один і той же бін StubProductAPI для різних тестів, його стан може змінюватися цими тестами і це робить їх абсолютно непередбачуваними. Для того, щоб очистити стан біна, є два способи. Перший — це вказати його scope як prototype:

       @Configuration
       static class ProductServiceConfiguration {
             
              @Bean
              @Scope(BeanDefinition.SCOPE_PROTOTYPE)
              ProductAPI productAPI() {
                     return new StubProductAPI();
              }
       }

І тоді цей бін перетворюватиметься для кожного тесту. Або, якщо таких бінів дуже багато, можна використовувати інструкцію @DirtiesContext:

@SpringBootTest(classes = { ProductApplication.class, ProductServiceConfiguration.class},
webEnvironment = WebEnvironment.NONE)
@DirtiesContext
@ActiveProfiles("test")
public class ProductServiceTest {

Тепер application context буде видалятися з кеша (і знову завантажуватися) після того, як відпрацювали всі тести в поточному класі. Якщо нам потрібно, щоб це відбувалося після кожного тесту, для цього є атрибут classMode:

@DirtiesContext(classMode = ClassMode.AFTER_EACH_TEST_METHOD)

Я вже кілька разів ужив разом Spring і кеш. Зрозуміло, що створення application context — досить затратна процедура і немає сенсу завантажувати його наново для кожного тесту. Тому Spring кешує application context і завантажує його заново тільки якщо змінилася конфігурація тесту. Тому якщо у нас буде два тестові класи, в одному з яких атрибут classes дорівнює ProductApplication.class, а в другому — ProductApplication.class/ProductServiceConfiguration.class, то Spring доведеться двічі завантажувати контекст.

Це не дуже добре, тому що, по суті, будь-яка мінімальна зміна classes одразу ж обнуляє кеш. Але з цим можна боротися, тому що у Spring є така фітча як context hierarchy:

public abstract class AbstractApplicationContext extends DefaultResourceLoader
@Nullable
private ApplicationContext parent;

Вся річ у тому, що у кожному класі, який є контекстом, є посилання на батька та можна легко створити ієрархію таких класів. Якщо спробувати отримати бін з дочірнього контексту, то він спочатку перевірить такий бін у себе, а потім у батьків. Але як це використовувати у тестах? Немає нічого простішого, якщо ви використовуєте Spring. Потрібно лише створити ієрархію тестових класів, розпочавши з базового класу, де ми вкажемо загальний всім клас-конфігурацію ProductAp-plication:

@SpringBootTest(webEnvironment = WebEnvironment.NONE)
@ContextConfiguration(classes = ProductApplication.class)
public abstract class BaseSpringTest {
}

І потім, додавши спадкоємців:

@ContextConfiguration(classes = ProductServiceConfiguration.class)
public class ProductServiceTest extends BaseSpringTest {

Анотація @ContextConfiguration дозволяє розділити класи-конфігурації та створити ієрархію контекстів. Причому базовий контекст (з класу BaseSpringTest) тепер буде закешований і не завантажуватиметься повторно.

Інтеграційні тести

Тепер уявімо, що вам хочеться додати інтеграційні тести, щоб перевірити роботу ваших компонентів з базою даних. При цьому у вас буде абсолютно ідентична конфігурація, крім JDBC URL (та аутентифікації). Зараз ключова властивість spring.datasource.url зберігається у конфігураційному файлі application.prooperties:

spring.datasource.url=jdbc:mysql://prod/shop

Відповідно її потрібно перевизначити і для цього є три порівняно прості способи. Перший дозволяє це зробити глобально, вказавши нове значення в application.properties для тестів:

spring.datasource.url=jdbc:mysql://localhost/shop

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

@TestPropertySource(properties = "spring.datasource.url=jdbc:mysql://localhost/dummy")
public class ProductServiceTest {

Щоправда, вона працює лише на рівні класу, її не можна встановити для окремого тесту. Але тут є вихід завдяки тому, що JUnit 5 підтримує вкладені тести. Можна виділити тест(и) в окремий вкладений клас і додати анотацію туди:

                @Nested
                @TestPropertySource(properties = "spring.datasource.url=jdbc:mysql://localhost/main")
                class LoadTests {
                                @Test
                                void findAll_success() {

Іноді те значення, яке потрібно замінити, не є статичним, його доводиться рахувати вже під час запусків тестів. І в такому випадку допоможе анотація @DynamicPropertySource, яка з’явилася вже у Spring 5:

                @DynamicPropertySource
                static void setup(DynamicPropertyRegistry registry) {
                                registry.add("spring.datasource.url", () -> "jdbc:mysql://localhost/main");
                }

Її потрібно додати на статичний метод і впровадити бін DynamicPropertyRegistry, який дозволяє змінювати Spring properties в run-time.

Application events

Події (events) — це один з цікавих прикладів, коли внутрішній механізм Spring Framework щодо обміну повідомленнями між його компонентами став популярним і для звичайних розробників. У нього є свої недоліки (він синхронний/ блокуючий), але через простоту реалізації його зручно використовувати як локальну event bus.

Щоб відловлювати події є механізм application event listeners, але для тестів він максимально спрощений. Якщо додати на тест анотацію @RecordApplicationEvents (вона з’явилася нещодавно, в Spring 5.3) і спеціальний бін ApplicationEvents, то можна отримати всі події, які були згенеровані під час запуску тесту:

@ContextConfiguration(classes = ProductServiceConfiguration.class)
@RecordApplicationEvents
public class ProductServiceTest extends BaseSpringTest {
               
                @Autowired
                ApplicationEvents events;

Даний контейнер є mutable, тобто ви можете отримати всі події (у тому числі певного типу), але й очистити список подій:

                @Test
                void findAll_success() {
                                assertTrue(events.stream(ProductEvent.class).findAny().isPresent());                               
                                events.clear();

Висновки

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

У своїх прикладах я використав JUnit 5, але Spring/Spring Boot легко інтегрується з іншими бібліотеками для тестування — TestNG/Spock.

👍ПодобаєтьсяСподобалось3
До обраногоВ обраному5
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
Але для Spring-застосунка так просто не вдасться зробити, тому що всі об’єкти, що розглядаються, швидше за все будуть бінами, пов’язаними з іншими бінами (або беруть налаштування з Spring оточення), і нам доведеться вручну створювати і пов’язувати всі ці об’єкти. Це досить-таки безглузда робота, тому що всі ці об’єкти і так вже знаходяться в application context (як Spring Біни) і потрібно просто їх звідти отримати

Перш ніж «просто їх звідти отримати», необхідно той контекст, для тестового середовища, сконфігурувати (привіт додаткова робота) та підняти (привіт збільшення часу виконання тестів). В той же час mockito/mockk/powermock із коробки мають аннотацію/метод щоб заінжектити мок об`єкти до SUT (System Under Test). Що дозволяє тестувати spring bean-и як звичайні POJO. Саме тому не має сенсу в unit тестах використовувати функціонал spring-a, якщо в цьому немає реальної потреби.

PS

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

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