Привет, Хабр! Меня зовут Денис, я 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.
ссылка на оригинал статьи https://habr.com/ru/articles/903686/
Добавить комментарий