Предисловие: в университете было получено задание — собрать scrum команду, выбрать проект и работать над ним в течении семестра. Наша команда выбрала разработку веб-приложения (react + flask). В этой статье я постараюсь рассказать, какими тесты должны были быть, и проанализировать, что у нас получилось на бекенде.



Ожидания


Тесты необходимы, в первую очередь, для того, чтобы убедить всех (в том числе и себя) в том, что программа ведём себя так, как надо, в тестовых ситуациях. Во-вторых, они обеспечивают работоспособность покрытого тестами кода в будущем. Написание тестов — это полезный процесс, потому что в его процессе очень часто можно наткнуться на проблемные места, вспомнить про какие-то крайние случаи, увидеть проблемы с интерфейсами и т.д.


Разрабатывая любые системы надо помнить как минимум о трёх типах тестов:


  • Модульные тесты — тесты, которые проверяют, что функции делают то, что надо.
  • Интеграционные тесты — тесты, которые проверяют, что несколько функций вместе делают то, что надо.
  • Системные тесты — тесты, которые проверяют, что вся система делает то, что надо.

В одном из постов от google была опубликована таблица с характеристикой трёх типов тестов. "Small", "Medium" и "Large".



Модульные тесты


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


Интеграционные тесты


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


Системные тесты


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


Зачем следить за типами


Обычно, с ростом проекта будет расти и кодовая база. Длительность автоматических проверок будет увеличиваться, поддерживать большое количество интеграционных и системных тестов будет становиться сложнее и сложнее. Поэтому перед разработчиками стоит задача минимизации необходимых тестов. Для этого необходимо стараться использовать модульные тесты там, где это возможно и уменьшать интеграционные используя "моки" (mocks).


Реальность


Типичный тест API


def test_user_reg(client):
    return json.loads(
        client.post(url, json=data, content_type='application/json').data
    )

    response = client.post('api/user.reg', json={
        'email': 'name@mail.ru',
        'password': 'password1',
        'first_name': 'Name',
        'last_name': 'Last Name'
    })

    data = json.loads(response.data)

    assert data['code'] == 0

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


Почему интеграционный, а не модульный? Потому что в обработке запросов выполняется взаимодействие с flask, с ORM, с нашей бизнес логикой. Обработчики выступают в роли объединяющего звена других частей проекта, поэтому написать модульных тестов для них не слишком просто (надо заменить моками базу данных, внутреннюю логику) и не слишком целесообразно (интеграционные тесты будут проверять аналогичные аспекты — "были вызваны нужные функции?", "данные были корректно получены?" и т.д.).


Названия и группировка тестов


def test_not_empty_errors():
    assert validate_not_empty('email', '') == ('email is empty',)
    assert validate_not_empty('email', '  ') == ('email is empty',)
    assert validate_email_format('email', "") == ('email is empty',)
    assert validate_password_format('pass', "") == ('pass is empty',)
    assert validate_datetime('datetime', "") == ('datetime is empty',)

В данном тесте соблюдены все условия для "small" тестов — поведение функции без зависимостей проверяется на соответствие ожидаемому. Но оформление вызывает вопросы.


Хорошей практикой является написание тестов, которые фокусируются на определённом аспекте программы. В данном примере присутствуют разные функции — validate_password_format, validate_password_format, validate_datetime. Группировать проверки стоит не по результату, а по объектам тестирования.


Имя теста (test_not_empty_errors) не описывает объекта тестирования (какой метод проверяется), описывает только результат (ошибки не пусты). Этот метод стоило назвать test__validate_not_empty__error_on_empty. Такое название описывает, что проверяется, и какой результат ожидается. Это касается практически каждого названия теста в проекте из-за того, что не было уделено время обсуждению соглашений об именовании тестов.


Регрессионные тесты


def test_datetime_errors():
    assert validate_datetime('datetime', '0123-24-31T;431') == ('datetime is invalid',)
    assert validate_datetime('datetime', '2018-10-18T20:21:21+-23:1') == ('datetime is invalid',)

    assert validate_datetime('datetime', '2015-13-20T20:20:20+20:20') == ('datetime is invalid',)
    assert validate_datetime('datetime', '2015-02-29T20:20:20+20:20') == ('datetime is invalid',)
    assert validate_datetime('datetime', '2015-12-20T25:20:20+20:20') == ('datetime is invalid',)
    assert validate_datetime('datetime', '2015-12-20T20:61:20+22:20') == ('datetime is invalid',)
    assert validate_datetime('datetime', '2015-12-20T20:20:61+20:20') == ('datetime is invalid',)
    assert validate_datetime('datetime', '2015-12-20T20:20:20+25:20') == ('datetime is invalid',)
    assert validate_datetime('datetime', '2015-12-20T20:20:20+20:61') == ('datetime is invalid',)
    assert validate_datetime('datetime', '2015-13-35T25:61:61+61:61') == ('datetime is invalid',)

Этот тест изначально состоял из первых двух assert. После этого был обнаружен "баг" — вместо проверки даты, проверялось лишь соответствие регулярному выражению, т.е. 9999-99-99 считалось нормальной датой. Разработчик это исправил. Естественно, после исправления бага надо добавить тесты, чтобы не допустить регрессии в будущем. Вместо того, чтобы добавить новый тест, в котором написать, почему этот тест существует, проверки добавились в этот тест.


Как следовало бы назвать новый тест, в который добавить проверку? Наверное, test__validate_datetime__error_on_bad_datetime.


Игнорирование инструментов


def test_get_providers():
    class Tmp:
        def __init__(self, id_external, token, username):
            self.id_external = id_external
            self.token = token
            self.username = username

    ...

Tmp? Это подмена объекта, который не используется в данном тесте. Разработчик, похоже, не знает про существование @patch и MagicMock из unittest.mock. Не нужно усложнять код, решая проблемы наивно, когда есть более адекватные инструменты.


Существует такой тест, который инициализирует сервисы (в базе данных), использует контекст приложения.


def test_get_posts(client):
    def fake_request(*args, **kwargs):
        return [one, two]

    handler = VKServiceHandler()
    handler.request = fake_request

    services_init()

    with app.app_context():
        posts = handler.get_posts(None)

    assert len(posts) == 2

Можно исключить из теста работу с базой данных и контекстом, просто добавив один @patch.


@patch("mobius.services.service_vk.Service")
def test_get_posts(mock):
    def fake_request(*args, **kwargs):
        return [one, two]

    handler = VKServiceHandler()
    handler.request = fake_request

    posts = handler.get_posts(None)

    assert len(posts) == 2

Итоги


  • Для разработки качественного ПО нужно писать тесты. Как минимум, чтобы убедиться, что вы написали то, что надо.
  • Для объёмных информационных систем тесты ещё важнее — они позволяют избежать нежелательного изменения интерфейсов или возвращения багов.
  • Чтобы написанные тесты со временем не превращались в массу странных методов, надо уделять внимание соглашению об именовании тестов, придерживаться хороших практик, минимизировать тесты.
  • Модульные тесты могут послужить отличным инструментом во время разработки. Их можно запускать после каждого небольшого изменения, чтобы убедиться, что ничего не сломалось.

Очень важным замечанием будет то, что тесты не гарантируют работоспособности или отсутствия багов. Тесты обеспечивают соответствие реального результата работы программы (или её части) ожидаемому. При этом проверка происходит только тех аспектов, для которых были тесты написаны. Поэтому при создании качественного продукта нельзя забывать и о других типах тестирования.

Комментарии (2)


  1. andreymal
    02.05.2019 00:11

    9999-04-05 считалось нормальной датой.

    Позвольте поинтересоваться, почему это не нормальная дата? Нормальная это до 2038 года что ли?)


    1. michaelkrukov Автор
      02.05.2019 00:17

      Да, пример плохой. Проблема была в том, что проверялось только то, что в нужных местах стояли цифры. Например, `9999-99-99` проходила проверку. Я обновил пример в статье.