Привет, Хабр! Меня зовут Денис, я SDET-специалист в компании SimbirSoft. Работая на проектах, я приобрел опыт использования различных инструментов тестирования. Спустя тонны написанных автоматизированных тестов по тест-кейсам и техникам тест-дизайна, хочу рассказать вам о возможности тестирования не конкретных данных, а их свойств. Статья будет полезна всем, кто уже знаком с тестированием на основе примеров и позволит расширить кругозор в понимании подготовки данных.
В своей статье я описал методы гарантии качества ПО, такие как тестирование на основе примеров и тестирование на основе свойств, а также составил таблицу с описанием параметров их взаимодействия с тестовым оракулом. Рассказал об инструменте тестирования на основе свойств Jqwik для языка Java, привел примеры использования случайного набора данных на UI и API, раскрыл возможности инструмента и потенциал работы с ним в рамках генерации тестов.
Содержание:
Тестирование на основе свойств
• Аннотации произвольных данных
О преимуществах и недостатках использования Jqwik
Качество программного обеспечения — это степень удовлетворения системой заявленных и подразумеваемых потребностей различных заинтересованных сторон.
Для достижения высокого качества программного обеспечения разработчики должны выполнить все свои задачи, но для гарантии этого качества следует прибегнуть к тестированию. Инженеры по тестированию приступают к работе и разрабатывают артефакты тестирования, определяют тестовые оракулы.
Артефакты тестирования — набор документов и материалов, задействованных в течение жизненного цикла тестирования ПО.
Тестовый оракул — механизм определения статуса выполнения теста программой: прошел/не прошел.
К самым сильным тестовым оракулам относится тестирование на основе примеров, и зачастую выбор падает именно на этот метод, потому что он является самым простым в написании тест-кейсов: у нас есть один конкретный входной набор данных.
Но если обратиться к главенствующим принципам тестирования, то можно осознать, что такой способ является нарушением принципа «Парадокс пестицида».
Парадокс пестицида — это один из семи принципов тестирования, который указывает на риск снижения эффективности тестов при их многократном использовании. Если тестовые сценарии создаются и выполняются на одних и тех же данных и с использованием одинаковых подходов, то это может привести к тому, что новые дефекты, возникающие в аналогичных ситуациях, но не охваченных тестовым покрытием, останутся незамеченными.
Во многих проектах решают не сильно углубляться по этому поводу и обновляют входные данные вручную. Но для инженеров по автоматизации слово «вручную» означает тоже что и «рутина».
Решением для расширения области входных данных является возможность их случайно генерировать. Но в рамках многих задач невозможна генерация полностью случайных данных, как в фаззинг-тестировании. Нужна какая-то концепция, в рамках которой будут создаваться произвольные данные, чтобы не приходилось писать отдельные тесты для каждого случая. Данный метод уже существует и называется «Тестирование на основе свойств».
Тестирование на основе свойств
Тестирование на основе свойств (Property-Based Testing) — метод, при котором вместо написания отдельных тестовых примеров разработчик определяет свойства или инварианты системы, которые должны быть верны для широкого спектра входных данных. Инструмент автоматически генерирует множество разнообразных входных значений и проверяет, сохраняются ли заданные свойства.
Виды тестирования, описанные в данной статье, представлены в следующей таблице:
Входные данные | Тестовый оракул | Связь тестового оракула с входными данными | Повторное использование оракула | Степень автоматизации | |
Тестирование на основе примеров | Один конкретный набор данных | Сильный - проверяет соответствие результата входным данным | Использует | Нет | Низкая/Средняя |
Фаззинг | Много случайных данных | Слабый - проверяет падения | Не использует | Да | Высокая |
Тестирование на основе свойств | Много случайных данных | Средний - проверяет свойства результата | Использует | Да | Высокая |
Если степень автоматизации низкая, нужно каждый раз создавать новый тестовый оракул с новым конкретным набором данных. Если средняя, то здесь уместна смесь подходов, например, параметризация тестов. При высокой степени автоматизации тестовый оракул работает на множестве входных данных.
Преимущества тестирования на основе свойств
Покрытие большего числа сценариев. Автоматическая генерация данных позволяет проверить систему на большем количестве случаев, включая неожиданные и крайние значения.
Выявление скрытых дефектов. Тесты могут обнаружить ошибки, которые сложно предвидеть при ручном написании тестовых случаев.
Снижение трудозатрат. Вместо написания множества отдельных тестов, достаточно определить общие свойства, экономя время и ресурсы.
Разные языки программирования поддерживают свои инструменты, реализующие тестирование на основе свойств:
Язык | Инструмент |
Haskel | QuickCheck |
JS/TS | fast-check |
Java | junit-quickcheck jqwik |
Python | Hypothesis |
Kotlin | propCheck jqwik с модулем для Kotlin |
Golang | GOPter |
C# | CsCheck |
C++ | RapidCheck |
Rust | quickcheck |
C | theft |
В этой статье мы рассмотрим инструмент для Java — Jqwik.
Об инструменте Jqwik
Jqwik — это библиотека для генеративного тестирования на языке Java, интегрированная с JUnit 5. Она позволяет определять свойства системы и автоматически генерировать тестовые данные для проверки этих свойств.
Основные фишки
Интеграция с JUnit 5. Использование знакомых аннотаций и методов облегчает внедрение в существующие проекты.
Богатый набор генераторов данных. Предоставляет готовые генераторы для различных типов данных и возможность создания пользовательских генераторов.
Поддержка сокращения (shrinking). При обнаружении ошибки Jqwik автоматически пытается найти наименьший набор данных, вызывающий сбой, что упрощает отладку.
Поддержка граничных случаев (edge cases). Инструмент может генерировать данные и проверять их для граничных случаев.
Преимущества использования
Простота интеграции. Легко встраивается в текущий процесс разработки и существующую инфраструктуру тестирования.
Высокая производительность. Оптимизирован для быстрого выполнения большого количества тестовых случаев.
Подробные отчеты. Предоставляет детальную информацию о сбоях, включая значения входных данных, которые привели к ошибке.
Хорошая настройка и управляемость. Инструмент можно хорошо настроить под специфику проекта и написать свои генераторы.
Механизм работы Jqwik
Основным механизмом работы являются аннотации:
пометки тестов;
произвольных данных;
жизненного цикла.
Привычных аннотаций из инструментов JUnit и TestNG по типу @Test, @BeforeAll, @AfterMethod не существует. Для обозначения тестов используются аннотации @Property и @Example.
Аннотации
Аннотации пометки тестов
Аннотация @Property
Позволяет не только пометить тест, но и настроить работу с ним, а именно: количество запусков теста, выбор метода генерации данных, включение проверки граничных значений, перезапуск для предыдущей проваленной генерации.
Аннотация хорошо подходит для проверки свойств разными данными, чтобы покрыть все необходимые фрагменты кода.
У аннотации есть еще один аналог — @PropertyDefaults, который можно установить над классом, чтобы установить для аннотаций @Property конфигурацию по умолчанию:
@Property(tries = 100)
void testSum() {}
Аннотация @Example
Это та же самая аннотация @Property, только с гарантией запуска ровно 1 раз, не принимает никаких параметров совсем.
Аннотация называется Example, так как является отражением тестирования на основе примеров:
@Example
void testSum() {}
Аннотации произвольных данных
Аннотация @ForAll
Используется в аргументах метода и позволяет указать, откуда аргумент будет брать произвольные значения. Например, для базовых типов данных, он умеет это делать сам:
@Property
void testSum(@ForAll Integer a, @ForAll Integer b){
assertEquals(a + b, b + a);
}
Также есть базовая поддержка для типов данных: установка свойств для длины, диапазона значений, определение размера списка или уникальности значений и многие другие настройки воспроизводятся имеющимися аннотациями:
@Property
void testConcatStr(@ForAll @StringLength(min = 1) @CharRange(from = 'a', to = 'b') String a,
@ForAll @StringLength(min = 2) @CharRange(from = 'y', to = 'z') String b){
assertNotEquals(a + b, b + a);
}
Предусмотрена функция разработки пользовательских аннотаций как комбинации имеющихся:
@Target({ ElementType.ANNOTATION_TYPE, ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
@NumericChars
@AlphaChars
@CharRange(from = ’а’, to = ’я’)
@Chars({' ', '.', ',', ';', '?', '!'})
@StringLength(min = 10, max = 100)
public @interface RussianText { }
@Property(tries = 10)
@Reporting(Reporting.GENERATED)
void aRussianText(@ForAll @RussianText String aText) {}
Аннотация @Provide позволяет более гибко настраивать произвольные данные.
Для создания произвольных данных используются классы Arbitrary и Arbitraries:
@Property
boolean concatenatingStringWithInt(
@ForAll("shortStrings") String aShortString,
@ForAll("10 to 99") int aNumber
) {
String concatenated = aShortString + aNumber;
return concatenated.length() > 2 && concatenated.length() < 11;
}
@Provide
Arbitrary<String> shortStrings() {
return Arbitraries.strings().withCharRange('a', 'z')
.ofMinLength(1).ofMaxLength(8);
}
@Provide("10 to 99")
Arbitrary<Integer> numbers() {
return Arbitraries.integers().between(10, 99);
}
Аннотации жизненного цикла
Container — контейнер корневого движка, классы контейнеров и встроенные классы контейнеров (те, которые помечены @Group).
Properties — метод, который аннотирован @Property или @Example.
По порядку:
@BeforeContainer — статический метод
@BeforeProperty — перед свойством или примером. @BeforeExample – синоним
@BeforeTry — перед попыткой
@AfterTry — после попытки
@AfterProperty — после свойства или примера. @AfterExample – синоним
@AfterContainer — статический метод
Использование в UI-тестах
Описание бизнес-логики
Представим, что мы тестируем веб-приложение с формой регистрации пользователя. Форма содержит поля «Имя пользователя» и «Пароль». Необходимо проверить, что система корректно обрабатывает различные комбинации входных данных, включая валидные и невалидные значения.
Пример кода:
public class RegistrationTest {
@Property
void userRegistrationTest(@ForAll("usernames") String username, @ForAll("passwords") String password) {
open("https://example.com/register");
$("#username").setValue(username);
$("#password").setValue(password);
$("#registerButton").click();
// Проверка успешной регистрации или отображения ошибок
if (isValid(username, password)) {
$("#successMessage").shouldBe(visible);
} else {
$("#errorMessage").shouldBe(visible);
}
}
@Provide
Arbitrary<String> usernames() {
return Arbitraries.strings()
.withCharRange('a', 'z')
.ofLength(5, 15);
}
@Provide
Arbitrary<String> passwords() {
return Arbitraries.strings()
.ascii()
.ofMinLength(8)
.filter(this::containsSpecialChar);
}
// Вспомогательные методы для проверки валидности
boolean isValid(String username, String password) {
return username.length() >= 5 && username.length() <= 15 && password.length() >= 8 && containsSpecialChar(password);
}
boolean containsSpecialChar(String input) {
return input.matches(".*[!@#$%^&*()].*");
}
}
Объяснение примера:
Генераторы данных:
usernames()— генерирует строки длиной от 5 до 15 символов, состоящие из букв нижнего регистра.
passwords()— генерирует ASCII-строки длиной не менее 8 символов, содержащие специальные символы.
Метод userRegistrationTest:
Аннотация @Property указывает, что это свойство, которое будет проверяться с различными наборами данных.
Использует сгенерированные имена пользователей и пароли для заполнения формы регистрации.
В зависимости от валидности данных проверяет отображение сообщения об успехе или ошибке.
Валидация данных:
Метод isValid определяет бизнес-правила для имени пользователя и пароля.
Проверяет длину и наличие специальных символов в пароле.
Практическое применение
Используя Jqwik, мы можем автоматически проверить форму регистрации на множество комбинаций входных данных, что повышает надежность тестирования и снижает вероятность пропуска дефектов.
Использование в API-тестах
Описание бизнес-логики
Предположим, мы тестируем API для создания заказа. Эндпоинт принимает данные о заказе: количество товара, цену за единицу и скидку. Необходимо убедиться, что API корректно обрабатывает различные комбинации этих параметров.
Пример кода:
public class OrderApiTest {
@Property
void createOrderTest(@ForAll("quantities") int quantity, @ForAll("prices") double price, @ForAll("discounts") double discount) {
Order order = new Order(quantity, price, discount);
given()
.contentType("application/json")
.body(order)
.when()
.post("https://api.example.com/orders")
.then()
.statusCode(expectedStatusCode(quantity, price, discount))
.body("success", equalTo(expectedResult(quantity, price, discount)));
}
@Provide
Arbitrary<Integer> quantities() {
return Arbitraries.integers().between(1, 1000);
}
@Provide
Arbitrary<Double> prices() {
return Arbitraries.doubles().between(0.01, 10000.00);
}
@Provide
Arbitrary<Double> discounts() {
return Arbitraries.doubles().between(0.0, 0.5);
}
int expectedStatusCode(int quantity, double price, double discount) {
// Реализация логики определения ожидаемого статус-кода
if (quantity <= 0 || price <= 0) {
return 400; // Неверный запрос
} else {
return 200; // Успешный запрос
}
}
boolean expectedResult(int quantity, double price, double discount) {
// Логика определения ожидаемого результата
return quantity > 0 && price > 0;
}
}
class Order {
public int quantity;
public double price;
public double discount;
public Order(int quantity, double price, double discount) {
this.quantity = quantity;
this.price = price;
this.discount = discount;
}
}
Пример:
Моделирование данных:
Класс Order представляет структуру данных заказа, отправляемую в API.
Генераторы данных:
quantities()— генерирует целые числа от 1 до 1000 для количества товаров.
prices()— генерирует числа с плавающей точкой от 0.01 до 10,000.00 для цены.
discounts()— генерирует числа с плавающей точкой от 0.0 до 0.5 для скидки (0% до 50%).
Метод createOrderTest:
Отправляет POST-запрос на создание заказа со сгенерированными данными.
Проверяет статус-код ответа и поле success в теле ответа.
Определение ожидаемого результата:
Метод expectedStatusCode возвращает ожидаемый статус-код на основе бизнес-логики (например, отрицательное количество или цена — это ошибка).
Метод expectedResult определяет, должен ли запрос быть успешным.
Практическое применение
Данный подход позволяет автоматически проверить API на корректность обработки различных комбинаций входных данных, включая граничные и невалидные значения.
Еще о полезных функциях Jqwik
Combinators, Builders
Уникальный подход к сотворению свойств произвольных данных.
Combinators (комбинаторы) позволяют объединить несколько параметров в один.
Arbitrary<String> names = Arbitraries.strings().withCharRange('a', 'z').ofLength(5);
Arbitrary<Integer> ages = Arbitraries.integers().between(18, 60);
Arbitrary<Person> people = Combinators.combine(names, ages).as(Person::new);
// Person — класс с конструктором Person(String name, Integer age)
Также это работает, если есть зависимость между данными. Например, для данных потребуется создать каталог товаров в магазине и найти продукт в разделе, соответственно, если дать волю произвольной генерации, возможен исход, когда получится некорректная комбинация данных о продукте. Например, продукт «Мяснейший стейк» будет иметь категорию «Вегетарианский», что приведет к падению теста.
Комбинаторы же позволяют удобно отрегулировать такие данные:
public class ProductCatalog {
public Arbitrary<Product> generateProducts() {
Arbitrary<Category> categoryArb = Arbitraries.of(Category.values());
Arbitrary<Integer> quantityArb = Arbitraries.integers().between(5, 15);
return Combinators.combine(categoryArb, quantityArb).as((category, quantity) -> {
Arbitrary<String> name = getNameByCategory(category);
return Product.build()
.name(name)
.category(category)
.quantity(quantity)
.build();
});
}
private Arbitrary<String> getNameByCategory(Category category) {
switch (category) {
case DAIRY:
return Arbitraries.of(getMilks());
case MEAT:
return Arbitraries.of(getMeats());
case FRUIT:
return Arbitraries.of(getFruits());
default:
return Arbitraries.strings();
}
}
// Enum для категорий продуктов
enum Category {
DAIRY, MEAT, FRUIT;
}
// Предполагаем, что эти методы существуют и возвращают списки строк
List<String> getMilks();
List<String> getMeats();
List<String> getFruits();
// Product — класс с конструктором Product(Category category, String name, Integer quantity)
}
Builders позволяют удобно создать целый объект из перечня произвольных данных:
public Arbitrary<User> createUser() {
Arbitrary<String> nameArb = Arbitraries.strings().withCharRange('a', 'z').ofMinLength(5).ofMaxLength(10);
Arbitrary<Integer> ageArb = Arbitraries.integers().between(18, 60);
return Builders.withBuilder(User::new)
.use(nameArb).in(User::setName)
.use(ageArb).in(User::setAge)
.build();
}
Даже если у вас не создан реальный билдер (даже через Lombok), он может работать сеттерами:
@Provide
Arbitrary<Person> validPeopleWithPersonAsBuilder() {
Arbitrary<String> names =
Arbitraries.strings().withCharRange('a', 'z').ofMinLength(3).ofMaxLength(21);
Arbitrary<Integer> ages = Arbitraries.integers().between(0, 130);
return Builders.withBuilder(() -> new Person(null, -1))
.use(names).inSetter(Person::setName)
.use(ages).withProbability(0.5).inSetter(Person::setAge)
.build();
}
Hooks
Уникальная возможность добавить пользовательскую логику в жизненный цикл Jqwik. Например, мы хотим сохранять скриншоты экрана при падении:
public class AllureTryHook implements AroundTryHook {
@Override
public TryExecutionResult aroundTry(TryLifecycleContext tryLifecycleContext,
TryExecutor tryExecutor,
List<Object> list) {
TryExecutionResult result = tryExecutor.execute(list);
if (!result.isSatisfied()) {
AllureManager.saveScreenshotPng();
}
return result;
}
}
Domain Context
Облегчает структуру проекта, можно хранить большие провайдеры отдельно от класса с @Property и вызвать аннотацию @Domain.
Однако у такого подхода есть единственный минус – пока @Domain не умеет читать названия методов внутри доменного класса, он опирается только на типы.
Предположим следующую структуру:
class PersonProvider extends DomainContextBase {
@Provide
public Arbitrary<Person> persons(){
//Какая-то генерация пользователя
return personArb
}
@Provide
public Arbitrary<Person> personsWithAge23(){
//Какая-то генерация пользователя с гарантией возраста 23
return personArb
}
}
Ошибку вызовет любое уточнение в @ForAllили @From, так как домен не может искать по имени провайдера в таком случае:
class TestExample {
@Domain(PersonProvider.class)
@Example
void test(@ForAll Person person) {
Integer expected = 40;
person.setAge(expected);
assertEquals(expected, person.getAge());
}
}
Поэтому корректный вызов будет без указания конкретного имени провайдера. Он найдет по возвращаемому типу объекта. Так как в примере три метода, он выберет самый верхний.
О преимуществах и недостатках использования Jqwik
Преимущества
Гибкость генерации данных. Возможность создавать сложные и настраиваемые генераторы для специфических сценариев тестирования.
Интеграция с JUnit 5. Позволяет использовать знакомые инструменты и практики, облегчая процесс внедрения.
Диагностика и сокращение данных. При обнаружении ошибки Jqwik автоматически упрощает входные данные до минимального набора, вызывающего сбой, что ускоряет отладку.
Расширяемый. Поддержка расширений для работы с популярными фреймворками. Например, Spring (список актуальных расширений).
Недостатки:
Кривая обучения. Освоение концепций генеративного тестирования и особенностей Jqwik может потребовать времени и усилий. Инструмент имеет множество разных способов использования и настройки, которые могут полностью интегрироваться в вашу систему со всеми тонкостями.
Производительность. Генерация и выполнение большого количества тестовых случаев могут увеличить общее время тестирования, что следует учитывать при настройке тестового процесса. Например, для UI будет затратно пробегать по одному и тому же сценарию больше одного раза, но для этого можно сократить количество запусков, соответственно, до одного.
Заключение
Тестирование на основе свойств с использованием Jqwik предоставляет мощный инструмент для повышения качества и надежности программного обеспечения. Автоматическая генерация тестовых данных позволяет охватить широкий спектр сценариев, включая те, которые сложно предусмотреть вручную.
Интеграция Jqwik с JUnit 5 и возможность использования в сочетании с другими фреймворками, такими как Selenide и RestAssured, делает его практичным выбором для проектов на Java. Несмотря на первоначальные затраты времени на освоение, преимущества в виде обнаружения скрытых дефектов и повышения покрытия тестами делают этот инструмент ценным дополнением к арсеналу разработчика.
Рекомендации:
Начните с внедрения Jqwik в небольших модулях проекта.
Постепенно усложняйте генераторы и свойства по мере освоения инструмента.
Интегрируйте генеративные тесты в процесс непрерывной интеграции для регулярного обнаружения дефектов.
Полезные ресурсы для самостоятельного изучения:
Официальная документация Jqwik: jqwik.net
Примеры на GitHub: Jqwik Examples
Руководства: Jqwik User Guide
Спасибо за внимание!
Больше авторских материалов для SDET-специалистов от моих коллег читайте в соцсетях SimbirSoft – ВКонтакте и Telegram.