Jqwik: обзор тестирования на основе свойств в UI и API

от автора

Привет, Хабр! Меня зовут Денис, я SDET-специалист в компании SimbirSoft. Работая на проектах, я приобрел опыт использования различных инструментов тестирования. Спустя тонны написанных автоматизированных тестов по тест-кейсам и техникам тест-дизайна, хочу рассказать вам о возможности тестирования не конкретных данных, а их свойств. Статья будет полезна всем, кто уже знаком с тестированием на основе примеров и позволит расширить кругозор в понимании подготовки данных.

В своей статье я описал методы гарантии качества ПО, такие как тестирование на основе примеров и тестирование на основе свойств, а также составил таблицу с описанием параметров их взаимодействия с тестовым оракулом. Рассказал об инструменте тестирования на основе свойств Jqwik для языка Java, привел примеры использования случайного набора данных на UI и API, раскрыл возможности инструмента и потенциал работы с ним в рамках генерации тестов.

Содержание:

Введение

Тестирование на основе свойств

Об инструменте Jqwik

Аннотации

• Аннотации пометки тестов

• Аннотации произвольных данных

• Аннотации жизненного цикла

Использование в UI-тестах

Использование в API-тестах

Еще о полезных функциях Jqwik

О преимуществах и недостатках использования 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 в небольших модулях проекта.

  • Постепенно усложняйте генераторы и свойства по мере освоения инструмента.

  • Интегрируйте генеративные тесты в процесс непрерывной интеграции для регулярного обнаружения дефектов.

Полезные ресурсы для самостоятельного изучения:

Спасибо за внимание!

Больше авторских материалов для SDET-специалистов от моих коллег читайте в соцсетях SimbirSoft – ВКонтакте и Telegram.


ссылка на оригинал статьи https://habr.com/ru/articles/903686/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *