Java
March 5

Юнит-тесты: чек-лист методик проектирования

Меня зовут Василий Косарев, я Java‑разработчик в CDEK. Много раз мы слышали о необходимости писать модульные тесты, о том, что весь код должен быть ими покрыт. При этом мне не встречалось списка: какие именно методики лучше использовать при тестировании кода.

Я задумался: есть ли чек‑лист/ руководство, который облегчил бы генерацию тестовых сценариев и помог выявлять серьёзные ошибки? Чтобы вдумчиво подходить к тестированию и не тратить ресурсы впустую, сводя к минимуму количество необходимых тестов.

Сегодняшней статьёй попробую ответить на эти вопросы. Материал будет полезен разработчикам, которые уже пишут модульные тесты и которые только думают внедрять их в свой проект. Мы с вами применим методики проектирования тестов и разберёмся, насколько они эффективны.

Проектирование тестов — что за зверь

Проектирование тестов — этап тестирования ПО, позволяющий правильно составить список проверок для тестирования.

У вас может возникнуть вопрос: «Зачем вообще нужны методики? Тестировщики сами не могут справиться?»

  • Во‑первых, это упрощает написание тестов, так как мы используем конкретную методику.
  • Во‑вторых, можно смело рефакторить код и не бояться, что мы что‑то сломаем.
  • В‑третьих, применение методики помогает сократить количество тестов.
  • В‑четвёртых, мы быстрее находим ошибки.
  • В‑пятых, можно сконцентрироваться на наиболее уязвимых, важных участках функциональности.

Методики проектирования

Автор книги «A Practitioner»s Guide to Software Test Design» Ли Коупленд выделяет следующие методики:

  1. Тестирование на основе классов эквивалентности (equivalence partitioning). В его основе метод чёрного ящика. Это проверка поведения одного значения, приводящая к тому же результату, что и тестирование другого.
  2. Анализ граничных значений (boundary value testing). Это проверка поведения продукта при крайних (граничных) значениях входных данных.
  3. Попарное тестирование (pairwise testing). Формирование наборов тестовых данных из полного набора входных данных в системе. Позволяет существенно сократить количество тестовых сценариев.
  4. Тестирование на основе состояний и переходов (State‑Transition Testing). Применяется для фиксирования требований и описания приложения.
  5. Таблицы принятия решений (Decision Table Testing). Ещё одна методика на основе чёрного ящика. Применяется для систем со сложной логикой.
  6. Доменный анализ (Domain Analysis Testing). Методика на основе разбиения диапазона возможных значений переменной на поддиапазоны, с последующим выбором одного или нескольких значений из каждого поддиапазона для тестирования.
  7. Сценарии использования (Use Case Testing). Методика описывает сценарии взаимодействия двух и более участников (как правило, пользователя и системы).

Как видим, методик проектирования тестов довольно много. Ниже сосредоточимся на самых эффективных.

Уровни тестов

Все методики тестирования делятся на три уровня:

  1. Низкоуровневые — проверка элементов системы (поля ввода, чек‑боксы, кнопки и т. д.)
  2. Среднеуровневые — проверка логики работы системы при комбинации данных в элементах системы
  3. Высокоуровневые — проверка бизнес‑процесса системы и логики работы программы.

Рассмотрим три низкоуровневые методики:

  • Эквивалентное разделение
  • Анализ граничных значений
  • Доменный анализ.

И одну среднеуровневую: тестовую комбинаторику.

Эквивалентное разделение

Есть такое понятие «классы эквивалентности» — это наборы значений, внутри которых значения эквивалентны друг другу. Они помогают нам уменьшить количество тестов, не создавая сценарии для каждого возможного значения. То есть можно выбрать только одно значение, приняв за аксиому, что для всех значений в этой группе результат будет аналогичным.

Например, мы тестируем метод, позволяющий рассчитывать стоимость доставки. Она будет зависеть от веса груза. У нас есть четыре группы:

  • до 10 кг — стоимость доставки 200 руб;
  • от 10 до 25 кг — стоимость доставки 400 руб;
  • от 25 и до 60 кг — стоимость доставки 600 руб;
  • от 60 кг — стоимость доставки 1000 ₽

При этом в поле для ввода веса помещается всего два символа, поэтому указать вес более 99 кг технически невозможно. В этом случае должна быть проверка, что более трёх символов вводить нельзя! Нам не нужно писать 99 тестов для каждой весовой категории, хватит пяти: по одному для каждой весовой группы (скажем, 5, 15, 35 и 75 кг) и один для случая, если вес превышает 99 кг.

Пример

@Test

void priceOfDelivery_beforeTen() {

    var price = priceOfDelivery.get(5);

    assertEquals(200, price.getResult());

}

@Test

void priceOfDelivery_fromTenBeforeTwentyFive() {

    var price = priceOfDelivery.get(15);

    assertEquals(400, price.getResult());

}

@Test

void priceOfDelivery_fromTwentyFiveBeforeSixty() {

    var price = priceOfDelivery.get(35);

    assertEquals(600, price.getResult());

}

@Test

void priceOfDelivery_fromSixty() {

    var price = priceOfDelivery.get(75);

    assertEquals(1000, price.getResult());

}

@Test

void priceOfDelivery_moreNinetyNine_Exception() {

    assertThrows(RuntimeException.class, () -> priceOfDelivery.get(100));

}

Анализ граничных значений

Эта методика основана на предположении, что большинство ошибок может возникнуть на границах эквивалентных классов. Она тесно связана с вышеописанной методикой эквивалентного разбиения, поэтому часто используется с ней в паре. Цель — найти ошибки, связанные с граничными значениями.

В примере из предыдущего пункта границами будут являться значения 0, 10, 25, 60 и 99. Граничными значениями будут:

  • -0,1, 0, 0,1
  • 9,9, 10, 10,1
  • 24,9, 25, 25,1
  • 59,9, 60, 60,1
  • 98,9, 99, 99,1.

Отдельно нужно проверять пустое значение.

Часто сложности возникают, если категории указаны «внахлёст», например, 0–12 и 12–25 лет, и т. д.

Пример

@ParameterizedTest

@CsvSource ({

            "0, 0",

            "0.1, 200",

    })

void priceOfDelivery_borderZero(String weight, Integer price) {

    var resultPrice = priceOfDelivery.get(weight);

    assertEquals(price, resultPrice.getResult());

}

@ParameterizedTest

@CsvSource ({

            "0, 0",

            "0.1, 200",

    })

void priceOfDelivery_borderZero(String weight, Integer price) {

    var resultPrice = priceOfDelivery.get(weight);

    assertEquals(price, resultPrice.getResult());

}

@ParameterizedTest

@CsvSource ({

            "24.9,, 400",

            "25, 400",

            "25.1, 400",

    })

void priceOfDelivery_borderTwentyFive(String weight, Integer price) {

    var resultPrice = priceOfDelivery.get(weight);

    assertEquals(price, resultPrice.getResult());

}

@ParameterizedTest

@CsvSource ({

            "59.9, 600",

            "60, 600",

            "60.1, 600",

    })

void priceOfDelivery_borderSixty(String weight, Integer price) {

    var resultPrice = priceOfDelivery.get(weight);

    assertEquals(price, resultPrice.getResult());

}

@ParameterizedTest

@CsvSource ({

            "98.9, 1000",

            "99, 1000",

            "99.1, 1000",

    })

void priceOfDelivery_borderNinetyNine(String weight, Integer price) {

    var resultPrice = priceOfDelivery.get(weight);

    assertEquals(price, resultPrice.getResult());

}

@Test

void priceOfDelivery_borderLessThanZero_Exception() {

	assertThrows(RuntimeException.class, () -> priceOfDelivery.get(-0.9));

}

Доменный анализ

Доменный анализ — методика создания эффективных и результативных тестовых сценариев в ситуациях, когда несколько переменных могут или должны быть протестированы одновременно — Святослав Куликов, «Тестирование программного обеспечения»

Если говорить простыми словами — эта методика создана для случаев, когда необходимо протестировать несколько параметров одновременно.

Порядок действий:

  • проверить такой набор входных значений, который включает в себя одно «нестабильное» значение (граничное или предграничное) и остальные — «типичные» значения;
  • для каждого параметра выбирать одну корректную и одну некорректную точку.

Доменный анализ:

  • In (typical) — точки корректного диапазона
  • Out — точки некорректного диапазона
  • On:
    • всегда лежит на границе
    • может быть либо In, либо Out
  • Off:
    • не лежит на границе, но максимально близка к границе (к On)
    • может быть либо In, либо Out, в зависимости от «корректности» On
      - если On «корректна» — In, то Off «некорректна» — Out
      - если On «некорректна» — Out, то Off «корректна» — On.

Рассмотрим на примере. Допустим, у нас есть:

  • вес — не более двух знаков включительно;
  • название товара — более двух, но менее 50 знаков;
  • маркировка товара — 12 знаков.

По описанному выше алгоритму определим точки корректного и некорректного диапазона:

Выделим набор значений для тестовых сценариев:

Пример

@ParameterizedTest

@CsvSource ({

            "20, Генератор Bosch 986049460, 010290000223",

            "10, Вал, 010290000223",

            "20, Генератор Bosch 986049460011111111111111111111111111111, 010290000223",

    })void returnStatus200(Integer weight, String productName, String productLabeling) {	var request = new Order();	request.setWeight(weight);	request.setProductName(productName);	request.setProductLabeling(productLabeling);

	assertEquals(order.validation(request), 200)}

Тестовая комбинаторика

Методика среднего уровня. В неё входит:

  • полный перебор
  • метод минимальных проверок
  • атомарные проверки.

Наша задача — реализовать проверку наиболее важных значений. Используем для этого атомарные проверки. Допустим, есть такие параметры:

  • Номер заказа
  • Тип заказа. В поле значения: Выбрать всё, Доставка, Интернет‑магазин (ИМ)
  • Статус заказа. В поле значения: Выбрать всё, Создана, В обработке, Принята, Отклонена
  • Продавец.

У нас будет выбрано четыре параметра для поиска заказа. Для каждого параметра следует указать используемые значения.

Атомарные проверки выполняются так:

Количество тестов можем рассчитать как: количество всех значений – количество всех параметров + 1:

Пример

@ParameterizedTest

@CsvSource ({

            "1234, all, all, CDEK",

            "6745345, delivery, all, CDEK Seller",

            "123356808908, IM, all, C",

"12335680890846456465, all, created, CССССССССССССССССССССССССССССССССССС",

    })void returnStatus200(Integer orderNumber, String applicationType, String applicationStatus, String seller) {	var request = new OrderSearch();	request.setOrderNumber(orderNumber);	request.setApplicationType(applicationType);	request.setApplicationStatus(applicationStatus);	request.setSeller(seller);

	assertEquals(order.validation(request), 200)}

Вывод

Мы рассмотрели только малую часть методик проектирования, которые пригодятся при написании юнит-тестов. На первый взгляд может показаться, что они довольно сложны. Вы вправе сказать: «А зачем это всё? Я и так пишу тесты, без каких‑либо методик».

Основная идея заключается в том, чтобы не просто писать тесты, а максимально эффективно покрывать функциональность при минимуме написанных тестов. Главная помощь этих тестов будет заметна при рефакторинге приложения.

Кроме того, я написал эту статью, чтобы помочь быстро сформировать наиболее эффективный чек‑лист для модульных тестов.

Считаю, что создание тестов должно приносить удовольствие, ведь мы здесь пишем код и сразу проверяем, насколько корректно работает программа. Ну и самое главное: «Чем раньше найден баг, тем проще и дешевле его исправить».

Поделитесь в комментариях: какие методики модульного тестирования используете вы?

Источник