Привет! Меня зовут Артём, и большую часть своего рабочего времени я пишу сложные автотесты на 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’)

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


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



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


  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. Не бойтесь оставлять часть кода непокрытой! Если выбирать между хорошим тестированием кода и отсутствием тестов, то ответ очевиден: тестируйте хорошо. А вот при выборе между тавтологическим тестом и отсутствием теста всё не так очевидно. Надеюсь, я убедил вас, что тавтологические тесты – зло. Если отставить часть кода непокрытой, это станет для других разработчиков своеобразным индикатором текущего состояния дел – они смогут проявить осторожность при модификации этой части кода. Или, что предпочтительнее, воспользоваться вышеупомянутыми методиками для написания подходящих тестов.


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


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


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


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


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


  1. Reuniko
    23.08.2017 11:32
    +4

    Спасибо за шикарный материал.


  1. guai
    23.08.2017 12:47
    +7

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


    1. bbidox Автор
      23.08.2017 13:33
      +4

      Тесты не виноваты, что их написал председатель клуба тавтологий. У него, наверное, и комментарии в коде такие, что прям думаешь: "вот и зачем он из названия метода подчёркивания убрал и пробелы поставил?"


      1. guai
        23.08.2017 15:07
        +1

        Да вы экстрасенс!


        1. bbidox Автор
          23.08.2017 15:49
          +2

          Скорее, "I know this feel, bro" (с)


  1. maxim_0_o
    23.08.2017 15:25

    def test_random_real():
        assert 0 <= get_thing() < 10

    Я думаю это плохой тест. Предположим произошла ошибка и рандом стал возвращать числа до 10 включительно. О такой ошибке тест может сообщить очень не скоро.


    1. bbidox Автор
      23.08.2017 15:47

      Плохой, да.
      Например, вы ещё и закладку не найдёте, если на каждой итерации №№317-350 будут выдаваться нули.
      Но тут важно ведь не слепое следование примеру...


      1. maxim_0_o
        23.08.2017 17:10
        +1

        В данном примере, если не мокать получение рандомного числа — то, логически, мы уже начинаем тестировать метод получения рандомного числа. А это задача довольно нетривиальная, нужно проводить много испытаний и смотреть распределение. Не лучше ли реализовать тестирование получения рандомного числа в классе получения рандомного числа?
        А этот метод я бы вообще тестами не покрывал, ибо он не содержит логики. А если бы он содержал логику, завязанную на рандомные числа, то я бы предпочел замокать рандом, тем самым упростив тест и сделав его стабильным. Здесь, очевидно, нужен компромис.
        Что я хотел сказать: данный пример, на мой взгляд, не защищает точку зрения, изложенную в статье :)


        1. bbidox Автор
          23.08.2017 17:50

          Да, пример в статье не идеальный, согласен


    1. siziyman
      23.08.2017 17:50

      А какой тест вы можете предложить для того, чтоб такую ситуацию «выловить» надёжно и за предсказуемое время? Ну, такой, который при этом тестирует сколько-нибудь «общее» поведение метода/объекта, а не какую-то очень конкретную деталь имплементации.

      Если что, это не «спервадобейся», а вполне серьёзный вопрос — мне интересно узнать.


      1. maxim_0_o
        23.08.2017 18:09

        Как я уже написал раньше, если бы я тестировал получения ранодомного чесла — я бы вызвал метод большое количество раз (например 10 тысяч), затем проверил бы, что все числа находятся в заданных пределах и что распределение близко к равномерному. При этом, разумеется, тест может оказаться не очень стабилен (все таки это рандом). Но стабильность можно улучшить увеличив число вызовов.


      1. lxsmkv
        24.08.2017 01:15
        +1

        В некоторых ситуациях я пользуюсь принципом (как сам его называю) «слабого» или «достаточного» доказательства.
        У нас в приложении есть плеер, который имеет режим смешивания. Необходимо убедиться что функция смешивания работает. Сначала нужно определить, что для меня будет достаточным доказательством. Как бы я тестировал руками? Выбрал бы трек из середины списка, включил режим смешивания, нажал на кнопку «следующий трек» и проверил что новый трек не является следующим по списку, и не является треком который мы изначально выбрали. Т.е. он «любой другой, кроме». Мы не проверяем работу рандомизатора, мы проверяем функцию приложения, на соответствие заявленному поведению. Этот тест не дает нам абсолютной гарантии верного поведения, потому что рандомизация может перестать работать после второго нажатия по кнопке «след. трек». Но оно достаточное, чтобы двигаться дальше по покрытию. Усилить тест можно потом, в случае необходимости, если будут прецеденты.
        Чтобы не залезть в дебри и не начать тестировать совсем не то что нужно, в совершенно неоправданных объемах, а быстро двигаться вперед, порой помогает вслух спросить себя «что является предметом теста?».


        1. siziyman
          24.08.2017 10:50

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


  1. Hixon10
    23.08.2017 19:56

    В последнее время люди части пишу и рассказываю про мутационное тестирование. Как один из способов борьбы с такими тестами — должен подойти отлично.


  1. lxsmkv
    24.08.2017 16:51
    +1

    bbidox Артем, вот скажите, вы стали править тесты, чтобы избавить их от тавтологий. Результаты тестов изменились? К лучшему? К худшему? Вот о чем нам бы всем интересно было узнать. Может приведете интересный пример из ваших UI тестов? Потому что надуманный пример из статьи, действительно, не очень нагляден. А UI тесты как правило очень наглядны.


    1. bbidox Автор
      24.08.2017 18:36

      Ответ будет длинным.
      Совсем избавить UI-тесты от тавтологий, наверное, не получится. Причина как в раз в том, что же мы хотим проверить — мы хотим убедиться что поведения правильное или что поведение соответствует ожидаемому? Правильное поведение должно быть протестировано вручную или с помощью TDD, с привлечением документации или хотя бы с помощью здравого смысла и постановщика задачи. Протестировать то, что ожидаемое поведение не нарушено — это, по сути, регрессионное тестирование. В части задач (например, ежедневные выкладки) целенаправленно тесты пишутся именно таким образом.


      Но можно убрать тавтологию в компонентах, на которые мы делим тест или проверяем. Например, мы знаем, что определённая компонента подвергается постоянным изменениям. Так ли нам важно проверять, что элемент <div id="test">new text</div> является div-ом, не достаточно ли проверять элемент #test? Кажется очевидным, но это пример простой. В реальных тестах всё довольно сложнее.


      Ну, например, выпилил проверку того, всплывающее сообщение с каким текстом показывается после той или иной операции. Там нет текста, соответствующего ошибке? Значит, тест будет считать, что операция завершилась успешно и продолжать своё выполнение: ему нужно убедиться, что, например, покупка прошла и поменялись балансы. Поменялись? Отлично.
      А проверку того, какое облачко будет показываться после покупки (например) — мы будем делать в тепличных условиях и проверять будем только это.


      Короче. Порой надо проверять компоненты, а порой — сценарии. Если нужен сценарий, то выпиливайте подробную проверку компонент.
      По-моему, так.


      1. lxsmkv
        25.08.2017 08:19
        +1

        у меня как-то тимлид спрашивал в чем причины необходимости поддержки UI тестов. И одним из пунктов я тогда назвал то что тестируемое приложение является Runtime Environment для самих тестов. (и еще подумал тогда, что это довольно трудное для понимания заявление) Т.е. в тесте мы (слепо) полагаемся на правильную работу некоторых компонент, например, что кнопка которую нам нужно нажать активна. Кнопка в сценарии не является предметом теста, но ее правильная функцкия необходима для работы теста.
        Меня и по сей день регулярно посещает мысль, что компоненты должны быть тоже протестированы перед тем как гонять сценарии. Но наша задача проверить работу нашей части приложения, а не проверять фреймворк и пр. Поэтому так и живем.


        1. lxsmkv
          26.08.2017 04:17

          А вот еще нашел интересный примерчик
          исходник на гитхаб

              public void testCreateWithNoInitialText() throws Throwable {
                  View createMenu = view(id.m_apply);
                  assertFalse(createMenu.isEnabled());
                  EditText content = editText(id.et_gist_content);
                  focus(content);
                  send("gist content");
                  assertTrue(createMenu.isEnabled());
          }
          


          Закроем на минуту один из наших внимательных глаз на то, что впринципе несколько утверждений в одном тесте вобщем спорная, а в большинстве случаев довольно «прохладная тема».

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