Как стать автором
Обновить
120.3
SimbirSoft
Лидер в разработке современных ИТ-решений на заказ

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

Уровень сложностиСредний
Время на прочтение13 мин
Количество просмотров659

Привет, Хабр! Меня зовут Денис, я 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.

Теги:
Хабы:
+2
Комментарии0

Публикации

Информация

Сайт
www.simbirsoft.com
Дата регистрации
Дата основания
Численность
1 001–5 000 человек
Местоположение
Россия