Я сомневался в юнит-тестах, но…

    Когда я пишу тест, то часто не уверен, что мой дизайн будет на 100% удачным. И хочу, чтобы он давал гибкость в рефакторинге кода — например, чтобы затем изменить класс, не меняя код теста.



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

    Всем привет! Это расшифровка подкаста “Между скобок” — моих интервью с интересными людьми из мира разработки на PHP.


    Запись, если вам удобнее слушать. В полной аудиоверсии мы также обсуждаем больше вопросов code coverage.

    С Владимиром vyants Янцем мы познакомились на февральском PHP-митапе в Ростове: я рассказывал про свой опыт с асинхронностью, он делал доклад про тесты. С того выступления у меня остались вопросы — и в период карантина мы созвонились, чтобы обсудить их.

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

    Владимир Янц, Badoo: Это очень хороший вопрос. Давай начнем с того, нужны ли они в принципе. Может, и правда, только функциональные?

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

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

    Чем хорош юнит-тест? Ты можешь протестировать всякие безумные кейсы.


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

    Поэтому, отвечая на главный вопрос. Нужны ли хорошие юнит-тесты? Да. Нужны ли плохие? Нет. Один из главных принципов тестирования: тест можно инвестировать в контракт, а не реализацию. Контракт — это договоренность, что ваш метод или ваша функция принимает на входе, и что она должна с этим сделать.

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

    Сергей Жук, Skyeng: Ок, смотри, у меня какое-то веб-приложение, и я начинаю в нем писать юнит-тесты. Скажем, я знаю, что у этого класса может быть 5 разных input, я меняю реализацию, и просто делаю юнит-тест провайдеру, чтобы не тестить каждый раз. И еще есть какая-то опенсорсная либа — тут без юнит-теста тоже никуда. А для каких еще кейсов их нужно и не стоит писать?

    Владимир Янц, Badoo: Я бы написал тесты на то, где есть какая-то бизнес-логика: не в базе данных, а именно в PHP-коде. Какие-то хелперы, которые считают что-то и выводят, какие-то бизнес-правила — отличные кандидаты для тестирования. Также видел, что пытались тестировать тонкие контроллеры в приложении.

    Основное, что должны делать юнит-тесты, — спасать чистую функцию.


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

    Напротив, если у вас какая-то админка с CRUD’ом и с большим количеством одинаковых форм, юнит-тесты не очень помогут. Логики мало, тестировать такой код в изоляции от базы данных и окружения — проблематично. Даже с точки зрения архитектуры. Здесь лучше взять какие-то тесты более высокого уровня.

    Сергей Жук, Skyeng: Когда я начинал, мне казалось, что круто юнит-тестировать максимально детально. Вот у меня есть объект, у него есть какой-то метод и несколько зависимостей (например, модель, которая ходит в базу). Я мокал все. Но со временем понял, что ценность таких тестов — нулевая. Они тестируют опечатки в коде и также еще больше связывают тесты с ним. Но я до сих пор общаюсь с теми, кто за такой вот «тру-подход». Твоя позиция какова: нужно ли так активно мокать? И в каких кейсах оно того точно стоит?

    Владимир Янц, Badoo: В целом, мокать полезно. Но одна из самых вредных конструкций, которых, мне кажется, есть в том же PHPUnit, это — ожидания.

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

    Мы пытаемся проверить тестом не контракт. Хороший тест этим не занимается.


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

    Возьмем функцию rand(). Она принимает на входе два числа от 0 и выше, а должна вернуть случайное число между этими двумя. Это контракт. В этом случае можно потестить, что она работает на граничных значениях и так далее.

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

    Сергей Жук, Skyeng: Вот ты начал про вред ожиданий. Моки — про них. Нужны ли тогда вообще моки, если есть фейки и стабы?

    Владимир Янц, Badoo: Проблема стабов в том, что они не всегда удобны: ты должен их заранее создать, описать. Это удобно, когда есть объект, который часто переиспользуется — ты один раз его написал, а затем во всех тестах, где нужно, используешь.

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


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

    Ну а фейки — мой любимый подход. Очень помогает тестировать многие вещи. Например, когда ты хочешь протестировать, что все правильно закэшировал. Для Memcached легко создать фейк-объект, он есть в большинстве стандартных поставок фреймворков. В качестве типизации вы используете интерфейс, а не конечный класс, и ваш фейк может быть безопасно передан в качестве аргумента в любую функцию. А внутри, вместо того, чтобы ходить в сам кэш, вы реализуете функционал кэша внутри объекта, делая массив. Это очень здорово облегчает тестирование, потому что вы можете проверить логику с кэшированием — иногда это бывает важно.

    Сергей Жук, Skyeng: Смотри, еще одна крайность. Я встречал людей, которые говорят: “Ок, а как мне протестировать приватный метод/класс?”

    Владимир Янц, Badoo: Бывает, что не очень правильно структурирован код, и правильный метод может быть отдельной чистой функцией, которая реализовывает функционал. По-хорошему, это должно быть вынесено в отдельный класс, но по каким-то причинам лежит в protected-элементе. В крайнем случае, можно протестировать такое юнит-тестом. Но лучше сделать отдельной сущностью, которая будет иметь свой тест. Что-то вроде своего явного контракта.

    Сергей Жук, Skyeng: Вот ты говоришь, есть контракт, а остальное — детали реализации. Если мы говорим о веб-приложении с точки зрения интерфейса: у него есть контракт, по которому оно общается с юзерами. В то же время мы формируем реквест, отправляем, инспектируем респонс, сайд-эффекты, БД, еще что-то. Если делать юнит-тесты, то можно вынести логику общения с БД в отдельный слой, завести интерфейс для репозитория, сделать отдельную in memory реализацию репозитория для тестов. Но стоит ли оно того?

    Владимир Янц, Badoo: У тебя есть приложение, которое должно правильно работать целиком. И юнит-тесты должны прикрыть какие-то важные вещи, у которых есть большая вариативность ответов, и которые легко выделены в чистую функцию. Вот там они должны быть в первую очередь.

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

    Умные люди не зря придумали пирамиду тестирования. Юнит-тесты быстрые, их можно написать много. В этом их смысл — задешево протестировать очень много участков твоего приложения, чтобы убедиться, что они работают правильно. Чем больше кусочков покрыты юнит-тестами, тем лучше, — но это не значит, что надо стремиться к 100-процентному покрытию. Есть много вещей, которые тесты не поймают, потому что не созданы для этого. Но если на их уровне что-то вылезло, нет смысла дальше идти и запускать тяжелые функциональные тесты.

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

    Сергей Жук, Skyeng: Давай напоследок поговорим о мутационных тестах. Нужны ли они?

    Владимир Янц, Badoo: Нужно внедрять мутационные тесты, как только вы задумались о юнит-тестах. Они поднимут из-под ковра все те проблемы, которые так часто обсуждают — бесполезное тестирование, coverage ради coverage.

    Там не нужно ничего писать дополнительно. Это просто библиотека: она берет coverage, который у вас где-то есть, идет в исходный код и начнет менять операторы кода на противоположные. После каждой такой мутации она прогоняет тесты, которые, согласно code coverage, эту строчку покрывают. Если ваши тесты не упали, они бесполезны. И, наоборот, можно находить строчки, для которых есть потенциальные мутации, но нет coverage.

    Внедрить это на ранних этапах ничего не стоит. А пользы много.
    Skyeng
    Крупнейшая онлайн-школа Европы. Удаленная работа

    Комментарии 8

      +2

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

        +1

        Спасибо за обратную связь!)

        0

        Все ж очень просто — тестировать надо какой то устоявшийся функционал. Будь то класс, который точно не будет меняться или будет, но незначительно, либо api сервера, либо рендеринг компонентов на фронте. Первый смысл тестов показать себе и другим что функционал работает корректно и отвечает заданным критериям задачи. Второй смысл — гарантия что система не упадет при внесении изменений. И все. Если смысл написания какого то класса — поддержка функционала некой программы то тестировать отдельно сам класс смысла нет никакого, достаточно текста конечных точек программы. Если же класс предполагается использовать как универсальное решение в прочих проектах, то тесты для класса необходимы. И очень важный момент — тесты должны писаться ДО написания функционала, после написания функционала они будут просто тестировать сами себя на соответствие программе, а не программу на соответствие тестам.

          +3
          На мой взгляд существует путаница в понятиях функциональный/приёмочный/юнит тест. Например в данной статье Владимир Янц как бы ставит знак равенства между функциональными и приёмочными тестами. В понимании фреймворка codeception — это разные виды тестов (https://codeception.com/docs/01-Introduction). Я считаю, разделение приёмочных и функцильнальных тестов полезно, поскольку начинаешь понимать, что между приёмочными и юнит-тестами существует разновидность тестов, не таких меддленных, как приёмочные, но при этом не таких чувствительных к изменениям архитектуры, как юнит-тесты. И вот такими «функциональными» (с точки зрения фреймворка codeception) тестами можно пользоваться с самого начала и не страдать при рефакторинге.
            +5
            Привет! Спасибо за комментарий! Действительно я несколько упрощал и говорил о не-юнит-тестах как об одном целом. На самом деле действительно не все так просто и есть немало «оттенков серого». Кажется, на пирамиде тестирования можно однозначно положить только UI-тесты наверх, и Unit-тесты вниз, а вот в серединке есть разные варианты и вариаций там много. Но смысл один — чем выше по пирамиде тем больше частей приложения тестируется вместе и тем медленее/дороже/более хрупкими эти тесты становятся.
            0
            Например есть такой кейс:
            есть набор классов, которые по раздельности, наверно, не особо имеет смысл тестить — они слишком маленькие.
            возможно намельчили в разбиении и в понимании «единичной ответственности», но что есть то есть.
            эти классы связываются через ди-конейнер, через него же туда прокидываются недостающие примитивы параметров — всякого рода костанты.
            в итоге, этот микро-граф классов образует уже что-то пригодное для blackbox-тестирования, без вдавания в подробности реализации классов.
            * получается, в тесте надо собирать эти классы вместе, для того чтобы начать их тестить, но если кто-то из разработчиков поменяет конфиг ди-контейнера, то тест станет неактуальным, хотя и продолжит отдавать зелёные результаты.
            * другой вариант — доставать эти классики прямо из ди-контейнера в тесте, но тогда возникает вопрос — как замокать репозитории и прочие внешние зависимости в ди-контейнере, без постоянной пересборки его на каждый тест(медленно)

            Не сталкивались с таким? Можете что-то посоветовать?
              0

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

                0
                В сборе они выполняют только часть верхнеуровневой задачи, это не целая ручка.
                Как пример: цепочка прасеров чего либо.
                Есть класс, который в конструктор получает регулярку. И таких классов N-штук. Классы одинаковые — регулярки разные.
                Все эти классы списком заинжекчены в ChainOfResponsibility, который будет идти по списку до тех пор, пока мы не выпарсим что-то внятное.
                Вся эта конструкция, и в том числе регулярки, описаны в контейнере.
                Тестить классы сами по себе бессмысленно, нужно доставать из контейнера чейн и тестить его, причём вместе с конфигами, которые прокидываются через контейнер.

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

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

            Самое читаемое