Тавтологические тесты

в 7:50, , рубрики: python, unit-тесты, Блог компании Badoo, отладка, Совершенный код, тавтология, Тестирование веб-сервисов

Тавтологические тесты - 1

Привет! Меня зовут Артём, и большую часть своего рабочего времени я пишу сложные автотесты на Selenium и Cucumber/Calabash. Честно говоря, довольно часто я оказываюсь перед непростым выбором: написать тест, который проверяет конкретную реализацию функциональности (потому что это проще) или тест, который проверяет функциональность (потому что это правильнее, но намного сложнее)? Недавно мне попалась неплохая статья о том, что тесты реализации – это «тавтологические» тесты. И, прочитав её, я уже почти неделю переписываю некоторые тесты в другом ключе. Надеюсь, вас она тоже подтолкнёт к размышлениям.

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

import hashlib
from typing import List
from unittest.mock import patch

def get_key(key: str, values: List[str]) -> str:
    md5_hash = hashlib.md5(key)
    for value in values:
        md5_hash.update(value)
    return f'{key}:{md5_hash.hexdigest()}'

@patch('hashlib.md5')
def test_hash_values(mock_md5):
    mock_md5.return_value.hexdigest.return_value = 'world'
    assert get_key('hello', ['world']) == 'hello:world'
    mock_md5.assert_called_once_with('hello')
    mock_md5.return_value.update.assert_called_once_with('world')
    mock_md5.return_value.hexdigest.assert_called()

Выглядит прекрасно! Четыре утверждения полностью протестированы, чтобы удостовериться, что код работает как ожидается. Тесты даже проходятся!

$ python3.6 -m pytest test_simple.py
========= test session starts =========
itemstest_simple.py .
======= 1 passed in 0.03 seconds ======

Конечно, проблема в том, что код ошибочен. md5 принимает только bytes, а не strэтом посте объясняется, как в Python 3 изменились bytes и str). Тестовый сценарий не играет большой роли; здесь протестировано лишь строковое форматирование, что даёт нам ложное ощущение безопасности: нам кажется, что код написано корректно, и мы это даже доказали с помощью тестовых сценариев!

К счастью, mypy вылавливает эти проблемы:

$ mypy test_simple.py
test_simple.py:6: error: Argument 1 to “md5” has incompatible type “str”; expected “Union[bytes, bytearray, memoryview]”
test_simple.py:8: error: Argument 1 to “update” of “_Hash” has incompatible type “str”; expected “Union[bytes, bytearray, memoryview]”

Замечательно, мы исправили наш код, чтобы сначала перекодировать строки в байты:

def get_key(key: str, values: List[str]) -> str:
    md5_hash = hashlib.md5(key.encode())
    for value in values:
        md5_hash.update(value.encode())
    return f'{key}:{md5_hash.hexdigest()}'

Теперь код работает, но проблемы остались. Допустим, кто-то прошёлся по нашему коду и упростил его всего до нескольких строк:

def get_key(key: str, values: List[str]) -> str:
    hash_value = hashlib.md5(f"{key}{''.join(values)}".encode()).hexdigest()
    return f'{key}:{hash_value}'

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

E AssertionError: Expected call: md5(b’hello’)
E Actual call: md5(b’helloworld’)

Очевидно, что с этим простым тестом есть какая-то проблема. Здесь одновременно присутствуют ошибка первого рода (тест падает, даже если код корректен) и ошибка второго рода (тест не падает, когда код некорректен). В идеальном мире тесты будут падать, если (и только если) код содержит ошибку. А в ещё более идеальном мире при прохождении тестов можно быть полностью уверенным в корректности кода. И хотя оба идеала недостижимы, к ним стоит стремиться.

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

Тавтологические тесты - 2

Я считаю, что тавтологические тесты – несомненный минус для вашего кода. По нескольким причинам:

  1. Тавтологические тесты дают инженерам ложное ощущение, что их код корректен. Они могут смотреть на высокое покрытие кода и радоваться за свои проекты. Другие люди, использующие ту же кодовую базу, будут уверенно пушить изменения, пока тесты проходят, хотя эти тесты на самом деле ничего не тестируют.
  2. Тавтологические тесты фактически «замораживают» реализацию, а не проверяют, чтобы код вёл себя так, как задумано. При изменении каких-либо аспектов реализации необходимо отражать это посредством изменения тестов, а не менять тесты, когда изменяются ожидаемые выходные данные. Это побуждает инженеров корректировать тесты в случае сбоев при их прогоне, а не выяснять, почему тесты сбоят. Если это происходит, то тесты становятся бременем, теряется их изначальный смысл как инструмента предотвращения попадания багов в продакшн.
  3. Инструменты статического анализа способны находить в вашем коде вопиющие ошибки вроде опечаток, которые и так были бы выловлены тавтологическими тестами. Инструменты статического анализа заметно усовершенствовались за последние пять лет, особенно в динамических языках. Например, Mypy в Python, Hack в PHP или TypeScript в JavaScript. Все они зачастую лучше подходят для вылавливания опечаток, при этом являясь более ценными для инженеров, поскольку делают код более понятным и удобным в навигации.

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

Давайте перепишем тест, чтобы проверять выходные данные:

def test_hash_values(mock_md5):
    expected_value = 'hello:fc5e038d38a57032085441e7fe7010b0'
    assert get_key('hello', ['world']) == expected_value

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

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

Как выявить тавтологические тесты

  1. Тесты, которые при сбое обновляются гораздо чаще тестируемого кода. Мы всякий раз платим цену за покрытие кода. Если эта цена превышает получаемую от тестов выгоду, то велика вероятность того, что тесты слишком тесно связаны с реализацией. Сопутствующая проблема: небольшие изменения в тестируемом коде требуют обновления гораздо большего количества тестов.
  2. Код тестов невозможно редактировать без сверки с реализацией. В этом случае велик шанс, что вы получили тавтологический тест. В Testing on the Toilet: Don’t Overuse Mocks вы найдёте очень знакомый пример. Вы можете воссоздать саму реализацию на основании этого теста:

    public void testCreditCardIsCharged() {
        paymentProcessor = new PaymentProcessor(mockCreditCardServer);
        when(mockCreditCardServer.isServerAvailable()).thenReturn(true);
        when(mockCreditCardServer.beginTransaction()).thenReturn(mockTransactionManager);
        when(mockTransactionManager.getTransaction()).thenReturn(transaction);
        when(mockCreditCardServer.pay(transaction, creditCard, 500)).thenReturn(mockPayment);
        when(mockPayment.isOverMaxBalance()).thenReturn(false);
        paymentProcessor.processPayment(creditCard, Money.dollars(500));
        verify(mockCreditCardServer).pay(transaction, creditCard, 500);
    }

Как исправить тавтологические тесты

  1. Отделяйте ввод/ вывод от логики. Именно из-за ввода/ вывода инженеры чаще всего обращаются к заглушкам. Да, ввод /вывод крайне важен, без него мы могли бы лишь прокручивать циклы процессоров и греть воздух. Но лучше переносить ввод/ вывод на периферию своего кода, а не смешивать его с логикой. Рабочая группа Sans-I/O сообщества Python разработала превосходную документацию по этому вопросу, а Кори Бенфилд отлично рассказал о нём в своём выступлении Building Protocol Libraries The Right Way на PyCon 2016.
  2. Избегайте заглушек в находящихся в памяти объектах. Чтобы в качестве заглушек использовать зависимости, находящиеся целиком в памяти, нужны очень веские причины. Возможно, лежащая в основе функция является недетерминированной или исполняется слишком долго. Использование реальных объектов повышает ценность тестов за счёт проверки в рамках тестового сценария большего количества взаимодействий. Но даже в этом случае должны быть тесты, позволяющие удостовериться, что код правильно использует эти зависимости (вроде теста, проверяющего, что выходные данные находятся в ожидаемом диапазоне). Ниже приведён пример, в котором мы проверяем, что наш код работает в том случае, если randint возвращает определённое значение, и что мы правильно вызываем randint.

    import random
    from unittest.mock import patch
    
    def get_thing():
        return random.randint(0, 10)
    
    @patch('random.randint')
    def test_random_mock(mock_randint):
        mock_randint.return_value = 3
        assert get_thing() == 3
    
    def test_random_real():
        assert 0 <= get_thing() < 10

  3. Используйте вспомогательные данные. Если в качестве внешнего сервиса используется зависимость-заглушка, то создайте набор фальшивых данных или воспользуйтесь сервером-заглушкой для предоставления вспомогательных данных. Централизация реализации подделки позволяет осторожно эмулировать поведение настоящей реализации и минимизировать объём изменений тестов при изменениях реализации.
  4. Не бойтесь оставлять часть кода непокрытой! Если выбирать между хорошим тестированием кода и отсутствием тестов, то ответ очевиден: тестируйте хорошо. А вот при выборе между тавтологическим тестом и отсутствием теста всё не так очевидно. Надеюсь, я убедил вас, что тавтологические тесты – зло. Если отставить часть кода непокрытой, это станет для других разработчиков своеобразным индикатором текущего состояния дел – они смогут проявить осторожность при модификации этой части кода. Или, что предпочтительнее, воспользоваться вышеупомянутыми методиками для написания подходящих тестов.

Тавтологические тесты - 3

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

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

Помните, тавтологические тесты – плохие, потому что они не хорошие.

Что почитать по теме

Автор: bbidox

Источник

Поделиться

* - обязательные к заполнению поля