Анатомия юнит тестирования

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

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

    Я определяю юнит тестирования как тестирование одного продакш юнита в полностью контролируемом окружении.

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

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

    О наследование


    Постарайтесь не применять наследование. Вместо него используйте композицию зависимостей. Часто наследование применяют для реализации принципа DRY (don’t repeat yourself), вынося общий код в родителя, но тем самым нарушая принцип KISS (keep it simple stupid), увеличивая сложность юнитов.

    AAA (Arrange, Act, Assert) паттерн


    Если посмотреть на юнит тест, то для большинства можно четко выделить 3 части кода:

    Arrange (настройка) — в этом блоке кода мы настраиваем тестовое окружение тестируемого юнита;
    Act — выполнение или вызов тестируемого сценария;
    Assert — проверка того, что тестируемый вызов ведет себя определенным образом.
    Этот паттерн улучшает структуру кода и его читабельность, однако начинать писать тест нужно всегда с элемента Act.

    Driven approach


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

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

    С чего мы начинаем разработку конкретного функционала? — с требований бизнеса, которые типично выглядят так: “Пользователь с любой ролью должен иметь возможность создать запись, таким образом, он выполнит такую то бизнес операцию”.

    Используя driven approach первое что мы должны сделать —

    • Это создать место в UI слое, где пользователь может создать запись, скажем, страницу в приложении, на которой будет кнопка “Создать запись”. Почему мы это сделали? — потому что это требует бизнес история.
    • Кнопка “Создать запись” будет требовать реализации обработчика click события.
    • Обработчик события будет требовать реализации создания записи в терминах слоя бизнес логики.
    • В случае клиент-серверной архитектуры, клиент будет обращаться к некоторому end point на стороне сервера для создания этой записи.
    • Сервер, в свою очередь, может работать с базой данных, где такая запись должна быть создана в отдельной таблице.

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

    Данный подход позволяет небольшими шагами реализовывать сложные бизнес истории, оставаясь все время сфокусированным только на нужном функционале, и избегать over engineering.

    AAS (Act, Assert, Setup) паттерн


    AAS — этот тот же AAA паттерн, но с измененным порядком частей, отсортированных с учетом Driven approach и переименованной Arrange частью в Setup, чтобы отличать их по названию.

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

    Второе — мы проверяем что Act действует ожидаемо. Мы пишем Assert часть, где выражаем требуемые последствия Act, в том числе с точки зрения бизнес истории.

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

    Смотрите, сам вид Act и его сигнатура продиктованы предыдущим шагом, тут нам нечего изобретать. Как предыдущий шаг хочет вызывать наш юнит, так он и будет его вызывать. Ожидаемые действия тоже продиктованы предыдущим шагом и самой бизнес историей.

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

    Принципы SOLID


    Из принципа SOLID, с точки зрения юнит тестирования очень важны 2 принципа:

    Single responsibility principle — позволяет снизить количество тест кейсов для юнита. В среднем на юнит должно приходиться от 1 до 9 тест кейсов. Это очень хороший индикатор качества юнита — если тест кейсов больше или хочется их сгруппировать, то вам точно нужно разделить его на два и больше независимых юнитов.

    Dependency inversion principle — позволяет легко создавать и управлять сложнейшими окружениями для тестирования через IoC контейнеры. В соответствии с данным принципом, юнит должен зависеть от абстракций, что позволяет передавать ему любые реализации его зависимостей. В том числе, и не продакшен реализации, созданные специально для его тестирования. Эти реализации не имеют в себе никакой бизнес логики и созданы не только под конкретный тестируемый юнит, но и под конкретный сценарий его тестирования. Обычно они создаются с помощью одной из библиотек для mock объектов, такой как moq.

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

    Качество кода


    Кстати, несколько слов о качестве кода тестов и продакшн. Самым качественным кодом должен быть код тестов. Причина этому одна — это его размер. На 1 строку продакшн кода в среднем приходиться 2-3 строки тестового кода, то есть его в 2-3 раза больше чем продакшн кода. В этих условиях он должен хорошо читаться, быть структурированным, иметь хорошую типизацию и быть очень дружелюбным к инструментам автоматического рефакторинга. Это цели, которые достойны отдельных мероприятий и усилий.

    Однотипность тестирования


    Много приложения реализовано в распределенной и модульной архитектуре, где разные части написаны на различных языках, скажем, клиент-серверные приложения, где клиент написан под веб на typescript и сервер написанный на c#. Важной целью для таких проектов будет приведение тестов для любой части, независимо от языка к единому подходу. Это значит, что все тесты на проекте используют AAA или AAS подход. Все тесты используют mock библиотеки с похожим API. Все тесты используют IoC. И все тесты используют одинаковые метафоры. Это позволяет повысить переносимость удачных практик на разные части проекта, упростить адаптацию новых коллег (выучил раз и применяй везде).

    Количество тестов для одного продакшн юнита


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

    Моя команда создает клиент-серверные приложения, где мы используем angular на клиенте и .net core для серверной части. В следующей статье я хочу показать на примерах, как мы пишем юнит тесты под angular и с#. Как мы делаем их похожими, как располагаем в проектах, какие библиотеки применяем.

    Similar posts

    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 21

      0

      Как вы пишите много много юнит тестов и не огребаете при рефакторинге? Удается ли вам ловить реальные баги с помощью юнитов? Расскажите как, потому что сколько я не пытался заставить юнит тесты работать всегда получается одно и то же:
      — Любой рефакторинг превращается в борьбу с тестами, время на рефакторинг увеличивается в разы.
      — Тесты ломаются от чего угодно, только не от багов. Это из-за моков, но как писать юниты без них непонятно.

        0

        Возможно, вам помогут идеи Джеймса Шора

          0

          Отличная ссылка, спасибо! Оказывается я использую Traffic Cop :) Например, есть утилиты, чтобы перехватить запрос к базе и вернуть ошибку или модифицировать результат. Аналогично для запросов к разным сервисам.


          С такой инфраструктурой нет смыла писать классические юнит тесты. Тесты высокоуровневых компонент, часто (но не всегда) протестируют низкоуровневые. Нет никакой нужды тестировать каждый сервис, если можно просто дернуть HTTP API, которое дёрнет тот же самый сервис и заодно проверить настройки сериализации, авторизацию и много чего ещё. Те же HTTP запросы проверят репозитории/DAO, генерацию SQL и т.п.


          Хочется узнать как у автора все устроено.

          0

          ИМХО


          1. Тесты — документируют ваш интерфейс с точки зрения использования. Вряд ли вам или кому-то ещё захочется писать подробную документацию к вашим интерфейсам в вики, где они скоро устареют или в комментариях. Вообще есть такой тренд, как "комментарии — зло". Штош, в таких условиях тесты это лучик света в этом царстве :)
          2. Тесты позволяют протестировать юнит без запуска приложения. Иначе вы вынуждены запускать весь проект, чтобы проверить работает ли ваш компонент правильно, а ещё нужно все условия воспроизвести, чтобы протестировать разные кейсы. На момент разработки это здорово снижает производительность.
          3. Тесты позволяют проводить этот самый рефакторинг, так как иногда вскрывают некоторые проблемы в дизайне и архитектуре. Обычное дело, если написание теста к юниту превращается в хардкор и мешанину моков, с юнитом явно что-то не так.
          4. Чем хуже архитектура, тем болезненней использования тестов. Проект, которые переживает этот самые рефакторинг частыми приливами, приводит к переписыванию тестов, что сводит на нет весь смысл.
          5. Не надо переживать, что юнит-тесты почти не ловят баги. Это же наоборот — хорошо :)
            +1

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


            Если тесты не ловят багов, а стоят в поддержке и написании как полноценная команда программистов, то наймите каждому программисту выделенного тестера, а разницу в зп положите в карман.

            +2
            Вообще это указывает на проблемы в архитектуре, если вы хотите «перепаять» внутренности не ломая контракты (рефакторинг), а при этом контракты ломаются. Или тесты зацеплены не за контракты, а за «внутряк». Или вы делаете не рефакторинг, а модифицируете приложение.
            Ну и юниты я бы не писал на все подряд, а только на контракты с внешними системами.
            Когда я сомневаюсь правильно ли я выбрал охват теста, я стараюсь ответить себе на вопрос: «что является предметом этого теста». Предмет теста нужно четко выделять.

            Ну и хорошие новости: если тест «ломается» значит он действительно что-то распознает. Другое дело дает ли он вам необходимую информацию. Ту которую вы бы хотели получать.

            Что такое мок? Это обьект который при обращении к нему отвечает как настоящая система, но при этом ей не является. Т.е. с его помощью вы заполняете пробел в коммуникационном контуре приложения для выполнения тестировочной задачи. Мок может влиять на тест в том случае, если контракт/протокол общения с этим объектом поменялся, и тест уже общается по новому, а мок еще по старому.

            А еще помогает, понять, что тесты это программный комплекс, и относиться к ним соответствующим образом. Это приложение. Прикладная программа, для проверки работоспособности различный частей основного приложения. Вы относитесь к тестам как к «гражданам второго сорта», а они совершенно равноправные и тоже являются приложением. Если применять к тестировочному ПО тот же уровень инженерной добросовестности как и к тестируемому приложению, то все должно начать работать на вас, а не против вас.

            Перестаньте бороться с тестами, примите их как «сигнализацию» для вашего кода.
              0
              Вообще это указывает на проблемы в архитектуре, если вы хотите «перепаять» внутренности не ломая контракты (рефакторинг), а при этом контракты ломаются.


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

              Перестаньте бороться с тестами, примите их как «сигнализацию» для вашего кода.


              Я с ними не борюсь, более того, команда их пишет, буквально тысячи их. Только это не юнит тесты ни разу. Несмотря на то, что я честно старался научиться их писать и упражнялся в этом где-то 3 или 4 года, от юнит тестов я получил только головную боль и потерянное время. А коллеги говорят, что оно у них работает. Вот мне и интересно как они решают те проблемы, которые заставили меня отказаться от юнит тестов.
                0
                Без тестов у нас было M мест, где нужно поменять код, в связи с изменением сигнатуры TryGetCachedValue. С тестами появилось ещё N мест. Если N много больше M, то будем говорить, что моки затрудняют рефакторинг. Кажется N всегда больше M.
                Мне не очень понятно как получается так, что в тесте метод претерпевший изменения используется «на каждом углу»? Обычно такие низкоуровневые вещи «плотно, в три слоя» обматывают абстракцией и уже в гораздо менее подверженом изменениям виде используют в тесте. Такие абстракции писать конечно тоже трудозатратно, но не приходится потом в миллионах мест в тесте адаптировать.

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

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

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

                Несмотря на это я совсем ни за что не агитирую. Каждый сам должен решать какие виды тестирования ему пользу приносят, а какие нет. Да, юнит тесты могут совсем быть нерентабельными, там кода нужно порой в полтора раза больше чем на само приложение. И я против оголтелого фанатизма и покрытия «квадратно-гнездовым», «потому что все так делают».
              0
              Добрый день, рефакторинг не представляет проблем — моки строго типизированы и в целом весь код очень дружественен к инструментам автоматического рефакторинга.

              Баги можно ловить юнит тестами. Есть два ярких случая:
              1. Код с неочевидным поведением — при прочтении кода, не понятно как код себя ведет. Примеры такого кода любят давать на собеседовании, на проверку знаний спецификации языка или среды.
              2. Код с вычислениями — в вычислениях могут существовать критические случаи, которые в повседневном использовании редко возникают, и как следствие их сложно найти. Юнит тесты сразу позволяют проверить как поведет себя код в этих условиях.

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

                Не понятно как получается так, что рефакторинг не представляет проблем.


                Возьмём кеш, который повсеместно в приложении используется и положим у него есть метод “bool TryGetCachedValue(string key, out T value);” Представим себе рефакторинг — сигнатура этого метода меняется на “ItemState TryGetCachedValue(string key, out T value);“ Положим кеш не простой, а write thru, то есть в нем, кроме самих данных есть ещё соединение с БД и TCP соединение с парой других микросервисов. Из-за этой особенности кеш замокан в каждом тесте.


                Без тестов у нас было M мест, где нужно поменять код, в связи с изменением сигнатуры TryGetCachedValue. С тестами появилось ещё N мест. Если N много больше M, то будем говорить, что моки затрудняют рефакторинг. Кажется N всегда больше M.


                Эти рассуждения находят подтверждение в моей практике. Часто видел в проектах «комбинаторный взрыв» количества изменений из-за моков. Для меня теория и практика сошлись и я от моков и unit тестов отказался (не от тестов вообще).


                Как вы решаете проблему с большим каскадом изменений в тестах, после изменений вроде описанного выше?

                  0

                  Тут вопрос, что вы называете моками и что юнит тестами.


                  Вот терминология у Фаулера моки


                  Моки
                  • Dummy objects are passed around but never actually used. Usually they are just used to fill parameter lists.
                  • Fake objects actually have working implementations, but usually take some shortcut which makes them not suitable for production (an in memory database is a good example).
                  • Stubs provide canned answers to calls made during the test, usually not responding at all to anything outside what's programmed in for the test.
                  • Spies are stubs that also record some information based on how they were called. One form of this might be an email service that records how many messages it was sent.
                  • Mocks are what we are talking about here: objects pre-programmed with expectations which form a specification of the calls they are expected to receive.

                  Юнит тесты


                  Если для вас приемлемы sociable юнит тесты с фейками, то: создаем единственный фейк на кеш. При его изменении его меняем. Надо поменять только тесты на кеш и фейк. Тесты тех, кто использует кеш остаются, примерно такими же, кроме новых требований (т.е. ситууации когда возвращаются ItemState отличный от того который раньше кодировался булеаном и это важно в конкретном требовании).


                  Так же см разные паттерны у Шора. Например, использование signature shielding.


                  Еще можно сохранить совместимость с boolean либо просто добавив новый метод вместо переделки существующего, либо сделав imlicit cast к булеан. Последним, правда, я не пользовался, если у кого-то есть какой-то опыт, было бы интересно узнать.

                    0
                    Тут вопрос, что вы называете моками и что юнит тестами.

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

                    Продакшн юнит — это обычно класс,…

                    Полностью контролируемое окружение — это окружение имитирующие среду,… Поведение окружения задается для конкретного тестового кейса через лаконичный API и любое поведение вне этого кейса для него не определено.


                    Исходя из этого, я делаю вывод, что а) unit test пишется для одного класа b) все вокруг него мокается с помощью чего-то, похожего на Moq. Это именно та среда, от корой я, в своем время, уходил. Но автор явно топит за этот подход, поэтому мне и интересно.

                    Если для вас приемлемы sociable юнит тесты с фейками

                    Я так и делаю, типа такого получается
                    //Юзер продает крипту боту, а он перепродает другой бирже, 
                    //полученные от юзера ордера бот помещает в архив. 
                    //Архив это просто очередь на запись в базу.
                    //Нужно проверить, что бот переходит в состояние "ошибка" если 
                    //он получил ордер, но архив перегружен и сохранить ордер не получится.
                    [Test]
                    public void GoesToRecoveryOnArchiveOverload()
                    {
                        //arrange
                        var order = SampleData.Order1;
                        OrderArchive.Overload = true;
                        
                        //act - notify bot about trade with a user
                        PrimaryAccount.AddOrder(order.MakeTrade(0.5m, out _));
                    
                        //assert - проверить, что бот перешел в нужное состояние.
                        InputEvents.WaitItem(_ => RiskManager.State.Should().Be(RiskManagerBotState.ErrorRecovery));
                    }
                    


                    В этом тесте, наверное 50% всей системы задействовано — очереди сообщений, коннекторы к бирже, всякие кеши и in-memory представление аккаунта и рыночных данных и т.п. Тестовыми является только коннектор к бирже, «PrimaryAccount.AddOrder» это обертка над ним, которая генерирует нужное событие.

                    Еще можно сохранить совместимость с boolean либо просто добавив новый метод вместо переделки существующего, либо сделав imlicit cast к булеан.

                    Не надо так делать, будет больно. Оно начнет в boolean превращаться там где не ожидаешь, а увидеть это глазами в коде невозможно.
                    0
                    Очень сложно ответить на вопрос, с данным примером. Формально вы правы, мест для изменения больше. Вот только, в практике я с тами мало встречался. Если это рефакторинг снижающий технический долг, то он направлен на упрощение юнита, однако переход от boolean к ItemState увеличивает complexity. Если у вас рефакторинг обусловлен бизнес задачей, то скорее всего решение будет сложнее, больше юнитов будет вовлечено, но каждый юнит сам по себе должен быть простым и укладываться в 1-9 тест кейсов.

                    Как вы решаете проблем у с большим каскадом изменений в тестах, после изменений вроде описанного выше?

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

                    Еще одна мысль. Тесты существуют не для снижения сложности рефакторинга, а для увеличения качества кода. Они в автоматическом режиме позволяют ответить на вопрос — работает ли ваш код, как ожидается.
                  0
                  как удалить?
                    +1

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

                      0
                      Любой рефакторинг превращается в борьбу с тестами, время на рефакторинг увеличивается в разы.
                      Вы страшный человек, как представлю рефакторинг без тестов… Если пишете без тестов, это подразумевает отсутствие рефакторинга, написал и забыл. А любой рефакторинг — это гарантированная ошибка в логике, которая может быть отловлена тестами, если они есть, ну или заказчиком, в итоге. Собственно перед рефакторингом легаси-кода (да-да, вы пишете легаси, вы тот самый «боже, что за идиот это писал», вам должно икаться периодически) необходимо обложить его тестами, чтобы ничего не поломать, в любой книжке по рефакторингу этот пунктик чётко прописан.
                        0

                        «Я не пишу юнит-тесты с моками» и «я не пишу тесты» это разные вещи.

                      0
                      Постарайтесь не применять наследование. Вместо него используйте композицию зависимостей. Часто наследование применяют для реализации принципа DRY (don’t repeat yourself), вынося общий код в родителя, но тем самым нарушая принцип KISS (keep it simple stupid), увеличивая сложность юнитов.

                      Не понятная идея — «не делайте так». А как делать?
                      По моим наблюдениям, наследование также часто применяют для реализации отношения «является» при построениии иерархии классов. Вы предлагаете отказаться от иерархии?
                        0
                        Использовать композицию из различных сервисов, которые объявляются как зависимости юнита.

                        Для примера: скажем есть контроллеры и часть из них используют проверку прав пользователя, можно сделать BaseController и наследники через protected функцию будут проверять права пользователя. Я так предлагаю не делать.

                        Вместо этого, общий код выноситься в сервис и каждый контроллер, которому этот код нужен объявляет от него зависимость.
                          0

                          А почему


                          вынося общий код в родителя, но тем самым нарушая принцип KISS (keep it simple stupid)

                          Мы нарушаем принцип KISS (keep it simple stupid)? Насколько я понимаю, если следовать Принципу подстановки Барбары Лисков, то все будет просто и наследование очень даже хорошо, или я ошибаюсь?

                            0
                            Вы не ошибаетесь, наследование рабочий инструмент и принцип очень важен. Я говорю о случае, когда наследование вводиться из-за желания переиспользовать код. Вариант с отдельным сервисом даст более «чистый» код.

                      Only users with full accounts can post comments. Log in, please.