Написание поддерживаемых модульных тестов

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

Тем не менее, при тестировании на основе свойств возникает большая проблема: как написать условие проверки?

Мы не можем ожидать, какие входные данные будут там, поэтому нам нужно переписать исходную тестовую цель по-другому, чтобы иметь возможность ее проверить. Например, если мы хотим проверить функцию add с помощью теста на основе свойств, то наше условие проверки будет следующим.

а + b == добавить(а, б)

Когда тестовая цель достаточно сложна, написать такое условие чрезвычайно сложно, не говоря уже о том, что если у меня есть способ написать условие проверки, почему я не написал тестовую цель таким образом?

В любом случае, тест на основе примеров по-прежнему имеет свою ценность.

Тогда как написать хороший тест на основе примеров? Я не уверен, что вы когда-либо сталкивались с проблемой, когда в модульном тесте слишком много примеров, что делает практически невозможным изменение модульного теста.

Когда мы не являемся автором модульного теста, трудно понять мысли автора в сложном модульном тесте, а также сложно полностью понять сложный тестовый пример.

Поэтому в этой статье представлены несколько способов упростить тестирование на основе примеров.

Табличное тестирование

Прежде всего, прежде чем упростить модульный тест, нам нужно понять структуру модульного теста, которую я называю принципом 3A.

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

Независимо от того, какой модульный тест используется, он в основном включает эти три шага. Есть несколько причин, по которым модульное тестирование становится сложным.

  1. Ожидаемые результаты трудно получить.
  2. Количество тест-кейсов со временем увеличивается.
  3. Взаимосвязь между договоренностью и оценкой не может быть установлена.
  4. Нагрузка по аранжировке огромная.

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

Второй причине посвящена данная статья. При написании модульных тестов мы часто добавляем соответствующий тест, когда сталкиваемся с новой ошибкой, в результате чего тестовый пример становится все больше и больше. Вот простой пример.

def test_calculator():
    # 1st
    expression = "2 + 3"
    ret = calculator(expression)
    assert ret == 5
    # 2nd
    expression = "2 + 3 * 4"
    ret = calculator(expression)
    assert ret == 14
    # 3rd
    expression = "(2 + 3) * 4"
    ret = calculator(expression)
    assert ret == 20
    # and so on

Такой кейс станет больше и найти множество повторяющихся кодов, отличающихся только параметрами, не составит труда. Итак, как мы должны упростить это?

Построение стола.

def test_calculator():
    table = [
        ("2 + 3", 5),
        ("2 + 3 * 4", 14),
        ("(2 + 3) * 4", 20),
        # and so on
    ]
    for expression, expected in table:
        ret = calculator(expression)
        assert ret == expected

При построении таблицы все, что нам нужно сделать для добавления новых случаев в будущем, — это добавить соответствующие записи в таблицу. В фреймворке PyTest для этого подхода есть более элегантное решение: parametrize. Вот пример PyTest.

import pytest

@pytest.mark.parametrize("expression,expected",
    [
        ("2 + 3", 5),
        ("2 + 3 * 4", 14),
        ("(2 + 3) * 4", 20),
        # and so on
    ]
)
def test_calculator(expression, expected):
    ret = calculator(expression)
    assert ret == expected

С parametrize он может напрямую обрабатывать циклы, поэтому код намного проще. Конечно, я считаю, что для достижения нашей цели достаточно построить таблицу.

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

А что за проблема 4? Давайте продолжим с примером калькулятора в качестве расширения. Допустим, наш калькулятор умеет запоминать результаты, как нам его протестировать?

def test_calculator():
    calculator = Calculator()
    expression1 = "x = 5 * 10"
    calculator.calculate(expression1)
    expression2 = "y = 2 + 3"
    calculator.calculate(expression2)
    expression3 = "x / y"
    ret = calculator.calculate(expression3)
    assert ret == 10

Из приведенного выше примера мы знаем, что состояние цели будет меняться со временем и входными данными, так как же построить таблицу?

def test_calculator():
    table = [
        ("x = 2", "y = 3", "x + y", 5),
        ("x = 2", "y = 3 * 4", "x + y", 14),
        # and so on
    ]
    for *expressions, expected in table:
        calculator = Calculator() # no init params
        for expression in expressions:
            ret = calculator.calculate(expression)
        
        assert ret == expected
        calculator.reset() # important

Вроде все еще работает, но этот простой пример уже немного усложняет логику теста. Если наша цель будет менять свое состояние не только на входе, но и на состоянии инициализации и многих внешних условиях, то в таблице будет все больше и больше столбцов и возникнет сложность.

Есть ли способ еще больше упростить его? Чтобы люди, которым нужно добавить новые дела в будущем, знали, что делать с первого взгляда?

Да, я называю это тестированием на основе конфигурации.

Тестирование на основе конфигурации

Продолжим рассмотрение на примере этого калькулятора.

scenario:
  - calculator_test1:
    expressions:
      - x = 2
      - y = 3
      - x + y
    execute: calculate
    expect:
      result: 5
  - calculator_test2:
    expressions:
      - x = 2
      - y = 3 * 4
      - x + y
    execute: calculate
    expect:
      result: 14

Написав удобочитаемый файл конфигурации, мы можем просто описать желаемый тестовый сценарий, и, кроме того, мы можем прочитать цель прошлых случаев. Я считаю, что поддержка конфигурации более расслабляющая, чем поддержка кода.

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

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

Заключение

Написать модульный тест несложно, но требуется много усилий, чтобы написать модульный тест, который легко поддерживать.

Когда мы пишем модульный тест, мы часто смотрим на тестовый пример с точки зрения автора, но на самом деле часто кто-то другой поддерживает кейс. Этот другой человек не имеет оригинальной идеи автора, что очень затрудняет нам поддержку модульного теста.

Хуже того, когда тестовые случаи становятся большими, это серьезно влияет на функциональную эволюцию. Вы когда-нибудь меняли немного кода, и модульный тест взрывался: много красных огней, и вы хотели это исправить, но обнаруживали, что не можете понять, что он тестирует?

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

Лучше пусть сам файл будет исполняемым объектом. Гораздо эффективнее писать документы в виде модульного теста, чем потом «исследовать» концепцию тестирования.

В этой статье используется интересная функция PyTest, но на самом деле PyTest очень глубок и глубок, поэтому, если у вас есть другие полезные применения PyTest, не стесняйтесь поделиться ими со мной.