Эта статья — не теоретическое руководство по написанию тестов и не how-to по использованию инструментария в определенном стеке, а ряд популярных вопросов, иногда даже у многих не сформировавшихся, на которые я постараюсь дать ответы. Источником этих вопросов служат коллеги, люди с обоих сторон в собеседованиях и знакомые, а ответы будут субъективными, краткими и не исчерпывающими, основанными на чужих материалах и своём опыте. Целевая аудитория статьи – разработчики, которые с определенным успехом пишут или хотя бы пытались писать тесты, но испытывают определенные сложности в их написании.


Я старался не привязываться к конкретному языку, чтобы увеличить охват читающих, однако оговорюсь сразу, что работаю в экосистеме PHP с использованием PHPUnit, и поэтому некоторые мои выводы могут быть неподходящими для других экосистем. При подборе вопросов и написанию я ориентировался на множество докладов и статей и пользовался ими как референсом.


Поводом для написания послужила недавняя статья «PHPUnit. Мокаем Doctrine Entity Manager» от trawl, некоторые проблемы из которой я тоже разберу.


Список вопросов:


  • Писать или не писать тесты?
  • А если на тесты не выделяют время?
  • Виды тестирования, как выбрать?
  • Почему мне сложно и долго писать тесты?
  • Как тестировать приватные методы?
  • Как писать интеграционные тесты? Как тестировать базу?
  • Как правильно: интеграционные или функциональные?
  • Как быть с внешними зависимостями?
  • Как упростить навигацию между тестами и предметом тестирования?
  • Должен ли я использовать TDD?
  • Что еще можно использовать для улучшения кода?

Писать или не писать тесты?


Я видел очень множество мнений на этот счёт, но сам пришел к своему собственному: надо ориентироваться на потребности бизнеса.


Для сделанного за пару часов на коленке куска кода тесты не нужны. С другой стороны, для крупного корпоративного проекта на сотни человеколет обязательны все популярные виды тестов. А все, что находится между этими полюсами, надо рассматривать как частный случай, оценивая стоимость тех или иных видов тестирования: с тестами она должна быть меньше, чем без них. Лично я пишу smoke тесты даже для крошечного CRUD-проекта длинной в пару недель, потому что уже на такой дистанции они приносят пользу и уменьшают стоимость разработки.


Плюсы тестирования, кратко и тезисно:


  • Значительное снижение стоимости исправления бага из-за раннего обнаружения.
  • Фиксирование контрактов.
  • Документация низкого уровня.
  • Обнаружение архитектурных проблем.

Так что если ваши проекты не являются одноразовыми скриптами из несколько файлов, то я однозначно рекомендую писать тесты. Поэтому вопрос из заголовка стоит переформулировать: «В каком объеме писать тесты, и какие?» Об этом далее.


А если на тесты не выделяют время?


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


Написание тестов – такая же часть работы над задачей, как её продумывание, кодирование или отладка. Тесты – такой же код, как бизнес-логика или контроллеры. Но про них у вас ведь не возникают вопросы? Вы же не ставите отдельную задачу на написание контроллера и не согласуете её с менеджерами?


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


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


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


Виды тестирования, как выбрать?


пирамида тестирования Майкла Коэна


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


Но всегда ли модульное тестирование применимо или целесообразно? Я считаю, что для примитивного CRUD такие тесты займут слишком много времени при малой отдаче и ничего не гарантируют. Попробуйте протестировать репозиторий (Repository в DataMapper) и потом ответьте на вопрос, что нам это дало. А вот для разнообразных калькуляторов такой подход будет идеальным.


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


Как протестировать совместную работу бекенда и фронтенда, если это разные проекты на разных стеках? Так же, как бекенд и мобильное приложение: это системное тестирование, и оно должно интересовать QA и DevOps инженеров, а не разработчиков (ну только если у вас не настоящий беспощадный скрам, где единственная доступная роль – фуллстек разработчик).


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


Почему мне сложно и долго писать тесты?


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


Не используйте реестры, синглтоны, локаторы, вы сильно усложняете себе этим жизнь. Это и было основной претензией к статье, на которую я ссылался выше. Используйте DI, и внедряйте в сервисы сразу необходимые репозитории. Соблюдайте закон Деметры, чтобы избежать цепочек моков. Попробуйте немного поработать с применением методологии TDD.


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


Как тестировать приватные методы?


Никак. Мы тестируем контракт – интерфейс, предоставляемый модулем другим модулям. Нам не нужно тестировать внутреннюю реализацию, она может меняться.


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


Как писать интеграционные тесты? Как тестировать базу?


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


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


Как правильно: интеграционные или функциональные?


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


Как быть с внешними зависимостями?


Внешние зависимости мы заменяем на моки. Не только для модульных, но и для интеграционных тестов.


Например, мы используем HTTPS клиент для обращения к какому-нибудь API через класс Guzzle. Если создавать экземпляр такого класса внутри тестируемого класса, то его будет сложно подменить, но решение будет очень простым: мы внедряем такой клиент в конструктор, а при тестировании заменим его моком.


Как упростить навигацию между тестами и предметом тестирования?


Современные инструменты разработки способны отследить расположение тестов или тестируемых классов, если вы используете стандарты наименования. Для простоты навигации можно использовать комбинацию клавиш Ctrl + Shift + T в продуктах JetBrains, к тому же если теста не существует, то вам предложат его создать и сделают каркас.


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


Должен ли я использовать TDD?


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


Что еще можно использовать для улучшения кода?


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


Это не значит, что их достаточно оставить только на IDE, я настоятельно рекомендую использовать их и на CI, чтобы видеть потенциальные ошибки и не вмержить такой код.
Также рекомендую добавить в IDE и CI поддержку инструмента для проверки code style. Ошибки часто прячутся среди небрежно написанного кода, а такие инструменты позволяют находить такие участки.


Материалы для дальнейшего изучения


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


  1. cross_join
    05.08.2019 17:41

    Все наоборот, модульные тесты самые дорогие. В системе несколько уровней API, при изменении реализации уровня и рефакторинге приходится перелопачивать все соответствующие модульные тесты. Тогда как тесты API (функциональные-интеграционные) меняются минимально.
    Соотношение кода тестов к тестируемому также максимально для модульных, минимум где-то 2:1. Для API уровня 1:10.


    1. OnYourLips Автор
      05.08.2019 18:48
      +1

      Прочитайте про пирамиду тестирования: почему каждый низший уровень больше уровня над ним. И посмотрите на эту статью https://habr.com/ru/post/358178/, в ней описываются популярные ошибки, в том числе относительно количества тестов определенных уровней.
      Обратите внимание на "Краткий вывод, зачем нужны юнит-тесты" абзац.


      Так же там есть пример, который иллюстрирует ваш опыт относительно частного случая, это Payment Gateway:
      image


      1. cross_join
        06.08.2019 00:30

        Начните чтение с классики, с Майерса «Надежность программного обеспечения».
        Далее переходим к SWEBOK (SoftWare Engineering Base Of Knowlege), где уровни тестирования и примерное число требований/тестов приводятся в виде матрицы. Для модульных речь идет о тысячах, вышестоящие уровни — сотни.
        Основное отличие интеграционных тестов от функциональных — whitebox vs blackbox.


        1. OnYourLips Автор
          06.08.2019 12:26

          Далее переходим к SWEBOK (SoftWare Engineering Base Of Knowlege)
          Основное отличие интеграционных тестов от функциональных — whitebox vs blackbox.

          У вас, наверное, SWEBOK образца 2001 года? В новой версии 2014 года (v3) эту матрицу убрали, как и терминологию с ящиками. Про разницу — это просто разный тип классификации, как я и написал.


          Для модульных речь идет о тысячах, вышестоящие уровни — сотни.
          Согласен в пропорциях.


          1. cross_join
            06.08.2019 14:29

            Значит в 2014 году все «черные ящики» стали прозрачными и заказчик теперь обязан разбираться в реализации подрядчика?
            Буду иметь в виду, спасибо :)


            1. OnYourLips Автор
              06.08.2019 14:38

              Переработали основополагающие факторы для классификации, убрав нечеткие признаки.


              Даже у меня в примере есть интеграционные тесты в режиме черного ящика (от запроса до ответа с применением фикстур), а есть в режиме белого (тестирование компонента с применением моков)


              1. cross_join
                06.08.2019 14:57

                Видимо после переработки «основополагающих факторов для классификации» модульные тесты стали самыми дешевыми. У экономистов такое часто бывает, кстати.
                Тестирование компонента с mock-ами — это типовой модульный тест.
                Итеграционный тест «черного яшика» — это что-то из области «сварю суп, бросив в кастрюлю не глядя все с верхней полки холодильника».
                Спасибо еще раз, но давайте все же завершим беседу :)