Найти в Дзене
Prizrak Developer

Тестирование в Java: от JUnit 5 до современных интеграционных тестов

Тестирование в Java эволюционировало от простых unit-тестов до сложных систем, охватывающих всю архитектуру приложения. В эпоху микросервисов, облачных вычислений и распределенных систем качественное тестирование стало критически важным для обеспечения надежности, безопасности и скорости доставки изменений. Современный Java-разработчик должен владеть не только JUnit 5, но и целым арсеналом инструментов для интеграционного, контрактного и performance-тестирования. // Старый подход JUnit 4 public class OldTest { @Before public void setUp() { /* инициализация */ } @Test public void testSomething() { assertEquals("expected", actual); } @After public void tearDown() { /* очистка */ } } // Современный JUnit 5 @DisplayName("Сервис обработки заказов") class OrderServiceTest { private OrderService service; private OrderRepository repository; @BeforeEach void setUp() { repository = mock(OrderRepository.class); service = new OrderService(repository); } @Test @DisplayName("✅ Создание заказа с вали
Оглавление

Введение

Тестирование в Java эволюционировало от простых unit-тестов до сложных систем, охватывающих всю архитектуру приложения. В эпоху микросервисов, облачных вычислений и распределенных систем качественное тестирование стало критически важным для обеспечения надежности, безопасности и скорости доставки изменений. Современный Java-разработчик должен владеть не только JUnit 5, но и целым арсеналом инструментов для интеграционного, контрактного и performance-тестирования.

JUnit 5: современный подход к unit-тестированию

1.1. Архитектура JUnit Jupiter vs JUnit 4

// Старый подход JUnit 4 public class OldTest { @Before public void setUp() { /* инициализация */ } @Test public void testSomething() { assertEquals("expected", actual); } @After public void tearDown() { /* очистка */ } } // Современный JUnit 5 @DisplayName("Сервис обработки заказов") class OrderServiceTest { private OrderService service; private OrderRepository repository; @BeforeEach void setUp() { repository = mock(OrderRepository.class); service = new OrderService(repository); } @Test @DisplayName("✅ Создание заказа с валидными данными") void shouldCreateOrder_whenDataIsValid() { // given OrderRequest request = new OrderRequest(/*...*/); when(repository.save(any())).thenReturn(new Order(/*...*/)); // when Order result = service.createOrder(request); // then assertNotNull(result); assertEquals(request.getAmount(), result.getAmount()); verify(repository).save(any()); } @Test @DisplayName("❌ Бросить исключение при нулевой сумме") void shouldThrowException_whenAmountIsZero() { // given OrderRequest request = new OrderRequest(/* amount = 0 */); // when / then IllegalArgumentException exception = assertThrows( IllegalArgumentException.class, () -> service.createOrder(request) ); assertEquals("Amount must be positive", exception.getMessage()); } @AfterEach void tearDown() { reset(repository); } }

1.2. Параметризованные тесты и динамические тесты

@ParameterizedTest @ValueSource(strings = {"USD", "EUR", "GBP"}) @DisplayName("Поддерживаемые валюты") void shouldSupportCurrency(String currency) { assertTrue(CurrencyValidator.isSupported(currency)); } @ParameterizedTest @CsvSource({ "100, 20, 80", // original, discount, expected "50, 10, 40", "200, 50, 150" }) @DisplayName("Расчет цены со скидкой") void shouldCalculateDiscountedPrice( BigDecimal original, BigDecimal discount, BigDecimal expected ) { BigDecimal result = priceCalculator.applyDiscount(original, discount); assertEquals(expected, result); } @ParameterizedTest @MethodSource("provideTestData") void shouldProcessOrder(Order order, boolean expectedValid) { boolean isValid = orderValidator.validate(order); assertEquals(expectedValid, isValid); } private static Stream provideTestData() { return Stream.of( Arguments.of(new Order(/* valid */), true), Arguments.of(new Order(/* invalid */), false) ); } // Динамические тесты @TestFactory Stream dynamicPriceTests() { List prices = List.of( BigDecimal.valueOf(100), BigDecimal.valueOf(200), BigDecimal.valueOf(500) ); return prices.stream() .map(price -> DynamicTest.dynamicTest( "Price test for: " + price, () -> { BigDecimal taxed = taxCalculator.calculate(price); assertTrue(taxed.compareTo(price) > 0); } )); }

1.3. Расширения (Extensions) и кастомные аннотации

// Создание кастомного расширения public class DatabaseExtension implements BeforeAllCallback, AfterEachCallback, ParameterResolver { private Connection connection; private DataSource dataSource; @Override public void beforeAll(ExtensionContext context) throws Exception { // Инициализация тестовой БД dataSource = EmbeddedDatabaseBuilder() .setType(EmbeddedDatabaseType.H2) .addScript("schema.sql") .build(); connection = dataSource.getConnection(); } @Override public void afterEach(ExtensionContext context) throws Exception { // Очистка данных после каждого теста try (Statement stmt = connection.createStatement()) { stmt.execute("DELETE FROM orders"); stmt.execute("DELETE FROM customers"); } } @Override public boolean supportsParameter(ParameterContext paramContext, ExtensionContext extensionContext) { return paramContext.getParameter().getType() .equals(DataSource.class); } @Override public Object resolveParameter(ParameterContext paramContext, ExtensionContext extensionContext) { return dataSource; } } // Использование расширения @ExtendWith(DatabaseExtension.class) class RepositoryTest { @Test void shouldSaveOrder(@DataSource DataSource ds) { OrderRepository repo = new JdbcOrderRepository(ds); Order order = new Order(/*...*/); Order saved = repo.save(order); assertNotNull(saved.getId()); } } // Кастомная аннотация для комплексной настройки @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) @ExtendWith({DatabaseExtension.class, SecurityExtension.class}) @ActiveProfiles("test") @TestPropertySource(locations = "classpath:test.properties") @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) public @interface IntegrationTest { } @IntegrationTest class OrderServiceIntegrationTest { // Все настройки применены автоматически }

Mockito и современные подходы к мокированию

2.1. Mockito 4+ с поддержкой финальных классов

@ExtendWith(MockitoExtension.class) class PaymentServiceTest { @Mock private PaymentGateway gateway; @Mock private AuditLogger auditLogger; @Spy private FeeCalculator feeCalculator = new FeeCalculator(); @InjectMocks private PaymentService paymentService; @Test void shouldProcessPaymentSuccessfully() { // given PaymentRequest request = new PaymentRequest(/*...*/); PaymentResponse expectedResponse = new PaymentResponse(/*...*/); when(gateway.process(any(PaymentRequest.class))) .thenReturn(expectedResponse); doNothing().when(auditLogger).logPayment(any()); // when PaymentResponse result = paymentService.process(request); // then assertEquals(expectedResponse, result); verify(gateway).process(request); verify(auditLogger).logPayment(any()); verify(feeCalculator).calculate(any()); } @Test void shouldRetryOnGatewayFailure() { // given PaymentRequest request = new PaymentRequest(/*...*/); when(gateway.process(any())) .thenThrow(new GatewayException("Timeout")) .thenThrow(new GatewayException("Network error")) .thenReturn(new PaymentResponse(/* success */)); // when PaymentResponse result = paymentService.processWithRetry(request); // then assertNotNull(result); verify(gateway, times(3)).process(any()); } @Test void shouldVerifyInOrder() { // given PaymentRequest request = new PaymentRequest(/*...*/); // when paymentService.process(request); // then InOrder inOrder = inOrder(auditLogger, gateway, feeCalculator); inOrder.verify(auditLogger).logPaymentStart(any()); inOrder.verify(feeCalculator).calculate(any()); inOrder.verify(gateway).process(any()); inOrder.verify(auditLogger).logPaymentEnd(any()); } } // Мокирование финальных классов (требует opt-in) // В файле: src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker mock-maker-inline

2.2. BDD стиль с Mockito

@ExtendWith(MockitoExtension.class) @DisplayName("Payment Service BDD тесты") class PaymentServiceBDDTest { @Mock private PaymentGateway gateway; @InjectMocks private PaymentService paymentService; @Test @DisplayName("Успешная обработка платежа") void successfulPaymentProcessing() { // given PaymentRequest request = new PaymentRequest(100.0, "USD"); PaymentResponse expectedResponse = PaymentResponse.success(); given(gateway.process(request)) .willReturn(expectedResponse); // when PaymentResponse result = paymentService.process(request); // then then(gateway).should().process(request); assertThat(result).isSuccessful(); assertThat(result.getAmount()).isEqualTo(100.0); } @Test @DisplayName("Неудачный платеж из-за невалидных данных") void failedPaymentDueToInvalidData() { // given PaymentRequest request = new PaymentRequest(-50.0, "USD"); given(gateway.process(any())) .willThrow(new InvalidPaymentException("Invalid amount")); // when InvalidPaymentException exception = assertThrows(InvalidPaymentException.class, () -> paymentService.process(request)); // then then(gateway).should(never()).process(any()); assertThat(exception.getMessage()).contains("Invalid amount"); } }

Интеграционное тестирование с Spring Boot

3.1. @SpringBootTest с различными конфигурациями

// Полноценный контекст Spring @SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("test") @Transactional @Rollback class OrderControllerIntegrationTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; @Autowired private OrderRepository orderRepository; @Test @DisplayName("Создание заказа через REST API") void shouldCreateOrderViaApi() throws Exception { // given OrderRequest request = new OrderRequest(/*...*/); String jsonRequest = objectMapper.writeValueAsString(request); // when MvcResult result = mockMvc.perform(post("/api/orders") .contentType(MediaType.APPLICATION_JSON) .content(jsonRequest)) .andExpect(status().isCreated()) .andExpect(jsonPath("$.id").exists()) .andExpect(jsonPath("$.status").value("CREATED")) .andReturn(); // then OrderResponse response = objectMapper.readValue( result.getResponse().getContentAsString(), OrderResponse.class ); assertTrue(orderRepository.existsById(response.getId())); } @Test @DisplayName("Получение заказа по ID") void shouldGetOrderById() throws Exception { // given Order order = orderRepository.save(new Order(/*...*/)); // when / then mockMvc.perform(get("/api/orders/{id}", order.getId())) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(order.getId())) .andExpect(jsonPath("$.amount").value(order.getAmount())); } @Test @DisplayName("Валидация запроса") void shouldValidateRequest() throws Exception { // given OrderRequest invalidRequest = new OrderRequest(/* invalid data */); String jsonRequest = objectMapper.writeValueAsString(invalidRequest); // when / then mockMvc.perform(post("/api/orders") .contentType(MediaType.APPLICATION_JSON) .content(jsonRequest)) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.errors").exists()); } } // Слайсовые тесты (только web слой) @WebMvcTest(OrderController.class) @Import({SecurityConfig.class, ValidationConfig.class}) class OrderControllerWebTest { @Autowired private MockMvc mockMvc; @MockBean private OrderService orderService; @Test void shouldReturnOrder() throws Exception { // given OrderResponse response = new OrderResponse(/*...*/); when(orderService.getOrder(anyLong())) .thenReturn(response); // when / then mockMvc.perform(get("/api/orders/1")) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(response.getId())); } } // Тестирование только JPA репозиториев @DataJpaTest @AutoConfigureTestDatabase(replace = Replace.NONE) @Import(QuerydslConfig.class) class OrderRepositoryTest { @Autowired private OrderRepository repository; @Autowired private TestEntityManager entityManager; @Test void shouldFindByStatus() { // given Order order = new Order(/*...*/); entityManager.persist(order); entityManager.flush(); // when List orders = repository.findByStatus(OrderStatus.CREATED); // then assertThat(orders).hasSize(1); assertThat(orders.get(0).getId()).isEqualTo(order.getId()); } @Test void shouldUpdateStatus() { // given Order order = new Order(/*...*/); entityManager.persist(order); // when int updated = repository.updateStatus(order.getId(), OrderStatus.PROCESSED); // then assertThat(updated).isEqualTo(1); Order updatedOrder = entityManager.find(Order.class, order.getId()); assertThat(updatedOrder.getStatus()).isEqualTo(OrderStatus.PROCESSED); } }

3.2. Testcontainers для интеграции с реальными сервисами

@Testcontainers @SpringBootTest @AutoConfigureMockMvc @Transactional class OrderServiceWithContainersTest { @Container static PostgreSQLContainer> postgres = new PostgreSQLContainer( "postgres:15-alpine") .withDatabaseName("testdb") .withUsername("test") .withPassword("test"); @Container static GenericContainer> redis = new GenericContainer( "redis:7-alpine") .withExposedPorts(6379); @Container @ServiceConnection static KafkaContainer kafka = new KafkaContainer( DockerImageName.parse("confluentinc/cp-kafka:latest")); @DynamicPropertySource static void properties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgres::getJdbcUrl); registry.add("spring.datasource.username", postgres::getUsername); registry.add("spring.datasource.password", postgres::getPassword); registry.add("spring.data.redis.host", redis::getHost); registry.add("spring.data.redis.port", redis::getFirstMappedPort); } @Autowired private OrderRepository orderRepository; @Autowired private RedisTemplate redisTemplate; @Autowired private KafkaTemplate kafkaTemplate; @Test void shouldSaveOrderToDatabase() { // given Order order = new Order(/*...*/); // when Order saved = orderRepository.save(order); // then assertNotNull(saved.getId()); assertTrue(orderRepository.existsById(saved.getId())); } @Test void shouldCacheOrderInRedis() { // given String orderId = "order-123"; Order order = new Order(/*...*/); // when redisTemplate.opsForValue().set(orderId, objectMapper.writeValueAsString(order)); // then String cached = redisTemplate.opsForValue().get(orderId); assertNotNull(cached); Order cachedOrder = objectMapper.readValue(cached, Order.class); assertEquals(order.getAmount(), cachedOrder.getAmount()); } @Test void shouldPublishOrderEventToKafka() { // given OrderEvent event = new OrderEvent(/*...*/); // when kafkaTemplate.send("order-events", event.getOrderId(), event); // then await().atMost(10, TimeUnit.SECONDS) .untilAsserted(() -> { // Проверка, что событие обработано }); } } // Композитный контейнер для всего стека @Container static DockerComposeContainer> compose = new DockerComposeContainer(new File("docker-compose-test.yml")) .withExposedService("postgres_1", 5432) .withExposedService("redis_1", 6379) .withExposedService("kafka_1", 9092) .withLocalCompose(true);

Контрактное тестирование с Pact

4.1. Consumer-Driven Contracts

// Consumer side (клиент сервиса) @ExtendWith(PactConsumerTestExt.class) @PactTestFor(providerName = "OrderService") public class OrderServiceConsumerTest { @Pact(consumer = "PaymentService") public RequestResponsePact createOrderPact(PactDslWithProvider builder) { return builder .given("order service is available") .uponReceiving("a request to create an order") .path("/api/orders") .method("POST") .headers("Content-Type", "application/json") .body(new PactDslJsonBody() .numberType("amount", 100.50) .stringType("currency", "USD") .stringType("customerId", "cust-123")) .willRespondWith() .status(201) .headers(Map.of("Content-Type", "application/json")) .body(new PactDslJsonBody() .stringType("id", "order-123") .stringType("status", "CREATED") .numberType("amount", 100.50) .stringMatcher("createdAt", "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}")) .toPact(); } @Test @PactTestFor(pactMethod = "createOrderPact") void testCreateOrder(MockServer mockServer) { // given OrderClient client = new OrderClient(mockServer.getUrl()); OrderRequest request = new OrderRequest(100.50, "USD", "cust-123"); // when OrderResponse response = client.createOrder(request); // then assertNotNull(response.getId()); assertEquals("order-123", response.getId()); assertEquals("CREATED", response.getStatus()); assertEquals(100.50, response.getAmount()); } } // Provider side (сервис) @Provider("OrderService") @PactBroker(url = "http://pact-broker:9292") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class OrderServiceProviderTest { @LocalServerPort private int port; @TestTemplate @ExtendWith(PactVerificationInvocationContextProvider.class) void pactVerificationTestTemplate(PactVerificationContext context) { context.verifyInteraction(); } @BeforeEach void before(PactVerificationContext context) { context.setTarget(new HttpTestTarget("localhost", port)); } @State("order service is available") void serviceAvailable() { // Настройка состояния для теста // Например, подготовка БД } @State("order with id order-123 exists") void orderExists() { // Создание заказа в БД orderRepository.save(new Order("order-123", /*...*/)); } }

4.2. Pact для асинхронных сообщений

// Consumer для Kafka сообщений @Pact(consumer = "NotificationService") public MessagePact orderCreatedEventPact(MessagePactBuilder builder) { return builder .expectsToReceive("an order created event") .withMetadata(Map.of("contentType", "application/json")) .withContent(new PactDslJsonBody() .stringType("eventId") .stringType("eventType", "ORDER_CREATED") .stringType("orderId") .numberType("amount") .timestamp("timestamp")) .toPact(); } @Test @PactTestFor(pactMethod = "orderCreatedEventPact") void testOrderCreatedEvent(List messages) { // given Message message = messages.get(0); OrderEvent event = objectMapper.readValue( message.contentsAsString(), OrderEvent.class); // when notificationService.processOrderEvent(event); // then // Проверка, что нотификация отправлена }

Performance и нагрузочное тестирование

5.1. JMeter и интеграция с JUnit

@ExtendWith(SpringExtension.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @AutoConfigureMockMvc class PerformanceTest { @Autowired private MockMvc mockMvc; @Test @Timeout(value = 30, unit = TimeUnit.SECONDS) @RepeatedTest(1000) void shouldHandleConcurrentRequests() throws Exception { // given OrderRequest request = new OrderRequest(/*...*/); String json = objectMapper.writeValueAsString(request); // when long start = System.currentTimeMillis(); mockMvc.perform(post("/api/orders") .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isCreated()); long duration = System.currentTimeMillis() - start; // then assertThat(duration).isLessThan(500); // 500ms SLA } @Test void loadTestWithVirtualUsers() throws Exception { int virtualUsers = 100; ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); List > futures = new ArrayList(); for (int i = 0; i { try { for (int j = 0; j

5.2. Gatling с Java DSL

public class OrderSimulation extends Simulation { private HttpProtocolBuilder httpProtocol = http .baseUrl("http://localhost:8080") .acceptHeader("application/json") .userAgentHeader("Gatling/3.0"); private ChainBuilder createOrder = exec( http("Create Order") .post("/api/orders") .header("Content-Type", "application/json") .body(StringBody( "{\"amount\": 100, \"currency\": \"USD\"}")) .check(status().is(201)) .check(jsonPath("$.id").saveAs("orderId")) ); private ChainBuilder getOrder = exec( http("Get Order") .get("/api/orders/${orderId}") .check(status().is(200)) .check(jsonPath("$.status").is("CREATED")) ); private ScenarioBuilder scn = scenario("Order Flow") .exec(createOrder) .pause(1) .exec(getOrder); { setUp( scn.injectOpen( rampUsers(10).during(10), // 10 пользователей за 10 сек constantUsersPerSec(5).during(60) // 5 в сек в течение минуты ) ).protocols(httpProtocol) .assertions( global().responseTime().percentile3().lt(100), // p95 99.5% успеха ); } }

Современные практики и инструменты

6.1. Mutation testing с Pitest

// Пример кода для mutation testing public class DiscountCalculator { public BigDecimal calculate(BigDecimal price, BigDecimal discount) { if (price == null || discount == null) { throw new IllegalArgumentException("Arguments cannot be null"); } if (price.compareTo(BigDecimal.ZERO) 0) { throw new IllegalArgumentException( "Discount cannot be greater than price"); } return price.subtract(discount); } } // Тесты должны убить все мутанты class DiscountCalculatorTest { @Test void shouldCalculateDiscount() { BigDecimal result = calculator.calculate( BigDecimal.valueOf(100), BigDecimal.valueOf(20) ); assertEquals(BigDecimal.valueOf(80), result); } @Test void shouldThrowWhenPriceIsNull() { assertThrows(IllegalArgumentException.class, () -> calculator.calculate(null, BigDecimal.ONE)); } // ... остальные тесты для всех граничных случаев } // Конфигурация Pitest в pom.xml org.pitest pitest-maven 1.15.0 com.example.service.* com.example.service.*Test 90 85 ALL

6.2. Property-based testing с jqwik

@Property @Report(Reporting.GENERATED) @Label("Скидка не должна превышать цену") boolean discountCannotExceedPrice( @ForAll @Positive BigDecimal price, @ForAll @Positive BigDecimal discount ) { Assume.that(discount.compareTo(price) = 0 && result.compareTo(price) validOrders() { return Combinators.combine( Arbitraries.strings() .withCharRange('a', 'z') .ofMinLength(3).ofMaxLength(50), Arbitraries.bigDecimals() .between(BigDecimal.ONE, BigDecimal.valueOf(10000)), Arbitraries.of(Currency.values()) ).as((customer, amount, currency) -> new Order(customer, amount, currency)); } @Property @Label("Валидный заказ может быть сохранен") void validOrderCanBeSaved(@ForAll("validOrders") Order order) { Order saved = repository.save(order); assertNotNull(saved.getId()); assertEquals(order.getAmount(), saved.getAmount()); }

Заключение

Современное тестирование в Java вышло далеко за рамки простых unit-тестов.

Помните: хорошие тесты — это не тесты, которые проходят, а тесты, которые вовремя падают, когда что-то ломается. Инвестируйте в тестирование — это окупится снижением количества инцидентов и увеличением скорости разработки.