Property-based тестирование в Java: когда один тест заменяет тысячу
Вы когда-нибудь писали юнит-тест, который проходил зелёным, а через неделю в проде падал с ошибкой на «каком-то странном значении»? Знакомо. Обычное (example-based) тестирование — это как проверять, что чайник закипает, только при 100°C. А что, если давление другое? А если высота над уровнем моря? Property-based тестирование в Java решает именно эту проблему: вместо того чтобы вручную перебирать примеры, вы описываете свойства, которым должен удовлетворять ваш код, а фреймворк генерирует сотни случайных входных данных и проверяет, всё ли в порядке.
Этот подход особенно полезен для Java разработчиков, которые пишут сложные алгоритмы, работают с коллекциями, парсерами или математическими функциями. В этой статье мы разберём, как внедрить property-based testing в ваш проект с помощью библиотеки jqwik, и покажем реальные примеры кода.
Что такое property-based тестирование и чем оно отличается от обычного?
В классическом подходе вы пишете:
@Test
void testAddition() {
assertEquals(4, Calculator.add(2, 2));
assertEquals(0, Calculator.add(-1, 1));
}
Всё хорошо, пока кто-то не передаст Integer.MAX_VALUE. Или null. Или очень большое отрицательное число. Property-based тестирование переворачивает логику: вы говорите не «ожидаю такой-то результат», а «для любых корректных входных данных результат удовлетворяет свойству X».
Например, свойство для сложения: add(a, b) == add(b, a) (коммутативность). Или add(a, 0) == a (нейтральный элемент). Фреймворк генерирует тысячи случайных пар a и b и проверяет, не нарушается ли свойство.
Основные преимущества property-based testing:
- Автоматическая генерация тестовых данных — вы находите баги, о которых даже не думали.
- Сокращение количества тестов — один property-тест заменяет десятки example-based.
- Проверка граничных случаев — генераторы умеют создавать «опасные» значения (0, null, пустые строки, большие числа).
- Shrinking — если тест упал, фреймворк минимизирует входные данные до самого простого примера, на котором воспроизводится ошибка.
Библиотеки для property-based тестирования в Java
На рынке есть несколько зрелых решений:
- jqwik — современная, легковесная, отлично интегрируется с JUnit 5. Рекомендую для старта.
- QuickTheories — быстрая, но менее популярная.
- JUnit QuickCheck — форк старого Scala-инструмента, но поддержка хромает.
Мы будем использовать jqwik, так как у него самый удобный API и отличная документация. Добавьте зависимость в pom.xml:
<dependency>
<groupId>net.jqwik</groupId>
<artifactId>jqwik</artifactId>
<version>1.8.4</version>
<scope>test</scope>
</dependency>
Или для Gradle:
testImplementation 'net.jqwik:jqwik:1.8.4'
Пишем первый property-based тест на Java
Допустим, у нас есть метод, который разворачивает строку:
public static String reverse(String s) {
if (s == null) return null;
return new StringBuilder(s).reverse().toString();
}
Какое свойство можно проверить? Если развернуть строку дважды, мы получим исходную строку. Это идеальное свойство для property-based testing.
import net.jqwik.api.*;
import static org.assertj.core.api.Assertions.assertThat;
class StringProperties {
@Property
void reverseTwiceReturnsOriginal(@ForAll String anyString) {
String reversed = StringUtils.reverse(anyString);
String doubleReversed = StringUtils.reverse(reversed);
assertThat(doubleReversed).isEqualTo(anyString);
}
}
Что здесь происходит? @ForAll String anyString — jqwik автоматически генерирует случайные строки: пустые, с пробелами, с Unicode-символами, null (если разрешено). Тест прогоняется 1000 раз (по умолчанию) с разными значениями. Если хотя бы один раз свойство нарушится — тест упадёт, и jqwik покажет минимальный пример, на котором ошибка воспроизводится.
Shrinking в действии
Представьте, что наша реализация reverse содержит баг: если строка содержит символ 'я', метод падает с исключением. jqwik найдёт это случайно, а затем начнёт «сжимать» входные данные, пока не получит самую короткую строку с этим символом — например, "я". Вы получите отчёт:
Sample: "я"
Shrunk to: "я" (1 steps)
Это невероятно экономит время при отладке.
Сложные свойства: работа с коллекциями
Допустим, мы пишем метод сортировки:
public static List<Integer> sort(List<Integer> list) {
List<Integer> sorted = new ArrayList<>(list);
Collections.sort(sorted);
return sorted;
}
Какие свойства можно проверить?
- Результат не длиннее исходного списка.
- Результат отсортирован (каждый следующий элемент >= предыдущего).
- Все элементы исходного списка присутствуют в отсортированном.
Реализуем:
@Property
void sortPreservesElements(@ForAll List<@IntRange(min = -1000, max = 1000) Integer> numbers) {
List<Integer> sorted = ListUtils.sort(numbers);
assertThat(sorted).containsExactlyInAnyOrderElementsOf(numbers);
}
@Property
void sortReturnsSortedList(@ForAll List<Integer> numbers) {
List<Integer> sorted = ListUtils.sort(numbers);
for (int i = 0; i < sorted.size() - 1; i++) {
assertThat(sorted.get(i)).isLessThanOrEqualTo(sorted.get(i + 1));
}
}
Обратите внимание на @IntRange — это ограничивает генерацию целых чисел заданным диапазоном. Вы можете тонко настраивать генераторы: @StringLength, @Size, @UniqueElements и т.д.
Property-based тестирование для алгоритмов и математики
Ещё одна классическая задача — тестирование математических функций. Например, функция sqrt:
public static double