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

Введение

Тестирование в 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<Arguments> provideTestData() {
    return Stream.of(
        Arguments.of(new Order(/* valid */), true),
        Arguments.of(new Order(/* invalid */), false)
    );
}

// Динамические тесты
@TestFactory
Stream<DynamicTest> dynamicPriceTests() {
    List<BigDecimal> 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<Order> 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<String, String> redisTemplate;
    
    @Autowired
    private KafkaTemplate<String, OrderEvent> 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<Message> 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<CompletableFuture<Void>> futures = new ArrayList<>();
        
        for (int i = 0; i < virtualUsers; i++) {
            futures.add(CompletableFuture.runAsync(() -> {
                try {
                    for (int j = 0; j < 100; j++) {
                        testOrderCreation();
                    }
                } catch (Exception e) {
                    fail("Test failed: " + e.getMessage());
                }
            }, executor));
        }
        
        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
            .get(60, TimeUnit.SECONDS);
    }
}

// Интеграция с JMeter через JUnit
@RunWith(JUnitPerfTest.class)
@JUnitPerfTest(threads = 50, durationSec = 60, warmUpSec = 10)
@JUnitPerfTestRequirement(percentiles = "90:10,95:20,99:30")
public class OrderApiPerformanceTest {
    
    @Test
    @JUnitPerfTest(threads = 100, durationSec = 120)
    public void testOrderCreationPerformance() throws Exception {
        // Тест, который будет выполнен многократно
        // под нагрузкой
    }
}

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 < 100ms
             global().successfulRequests().percent().gt(99.5) // >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("Price must be positive");
        }
        
        if (discount.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Discount cannot be negative");
        }
        
        if (discount.compareTo(price) > 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
<plugin>
    <groupId>org.pitest</groupId>
    <artifactId>pitest-maven</artifactId>
    <version>1.15.0</version>
    <configuration>
        <targetClasses>
            <param>com.example.service.*</param>
        </targetClasses>
        <targetTests>
            <param>com.example.service.*Test</param>
        </targetTests>
        <mutationThreshold>90</mutationThreshold>
        <coverageThreshold>85</coverageThreshold>
        <mutators>
            <mutator>ALL</mutator>
        </mutators>
    </configuration>
</plugin>

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);
    
    BigDecimal result = calculator.calculate(price, discount);
    
    return result.compareTo(BigDecimal.ZERO) >= 0 &&
           result.compareTo(price) <= 0;
}

@Property
@Label("Коммутативность сложения скидок")
boolean discountAdditionIsCommutative(
    @ForAll @Positive BigDecimal price,
    @ForAll @Positive BigDecimal discount1,
    @ForAll @Positive BigDecimal discount2
) {
    Assume.that(discount1.add(discount2).compareTo(price) <= 0);
    
    BigDecimal result1 = calculator.calculate(
        calculator.calculate(price, discount1), discount2);
    BigDecimal result2 = calculator.calculate(
        calculator.calculate(price, discount2), discount1);
    
    return result1.equals(result2);
}

@Provide
Arbitrary<Order> 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-тестов.

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