Юнит-тесты. Быстрый старт – эффективный результат (с примерами на C++)



    Вместо вступления


    Всем привет! Сегодня хотелось бы поговорить о том, как просто и с удовольствием писать тестируемый код. Дело в том, что в нашей компании мы постоянно контролируем и очень ценим качество наших продуктов. Еще бы – ведь с ними ежедневно работают миллионы человек, и для нас просто недопустимо подвести наших пользователей. Только представьте, наступил срок сдачи отчетности, и вы тщательно и с удовольствием, используя заботливо разработанный нами пользовательский интерфейс СБИС, подготовили документы, еще раз перепроверили каждую циферку и вновь убедились, что встречи с вежливыми людьми из налоговой в ближайшее время не будет. И вот, легким нажатием мыши кликаете на заветную кнопку «Отправить» и тут БАХ! приложение вылетает, документы уничтожаются, жарким пламенем пылает монитор, и кажется, люди в погонах уже настойчиво стучат в двери, требуя сдачи отчетности. Вот как-то так все может и получиться:



    Фух… Ну, согласен, с монитором, наверное, все-таки погорячился ;) Но все же возникшая ситуация может оставить пользователя нашего продукта не в самом благостном состоянии духа.

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

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

    Изложенный ниже материал во многом был представлен на конференции C++ Russia, так что вы можете его почитать, послушать и даже посмотреть.

    Характеристики хороших юнит-тестов


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

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

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

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

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

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

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

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



    Юнит-тестирование. С чего все начинается


    Написание любого юнит-теста начинается с выбора его имени. Один из рекомендуемых подходов к наименованию юнит-теста – формировать его имя из трех частей:

    — имя тестируемой рабочей единицы
    — сценарий теста
    — ожидаемый результат

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

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

    — часть Arrange — здесь производится создание и инициализация требуемых для проведения теста объектов
    — часть Act — собственно проведение тестируемого действия
    — часть Assert — здесь производится сравнение полученного результата с эталонным

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

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

    — возвращаемый результат
    — изменение состояния системы
    — взаимодействие между объектами

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

    Чтобы писать тестируемый код, надо уметь реализовывать и применять по назначению поддельные объекты (fake objects).

    Существует несколько подходов к классификации поддельных объектов, Мы рассмотрим одну из базовых, которая соответствует задачам, решаемым в процессе создания тестируемого кода.

    Она выделяет два класса поддельных объектов: stub-объекты и mock-объекты. Они предназначены для решения разных задач: stub-объект – для решения задачи разделения, а mock-объект – для решения задачи распознавания. Наибольшая разница заключается в том, что при использовании stub-объекта assert (операция сравнения полученного результата с эталонным) производится между тестовым и тестируемым кодом, а использование mock-объекта предполагает его анализ, который и показывает пройден тест или нет.

    Если логику работы можно протестировать на основе анализа возвращаемого значения или изменения состояния системы, то так и сделайте. Как показывает практика, юнит-тесты, которые используют mock-объекты сложнее создавать и поддерживать, чем тесты, использующие stub-объекты.

    Рассмотрим приведенные принципы на примере работы с унаследованным (legacy) кодом. Пусть у нас есть класс EntryAnalyzer, представленный на рис. 1, и мы хотим покрыть юнит-тестами его публичный метод Analyze. Это связано с тем, что мы планируем изменять этот класс, или же хотим таким образом задокументировать его поведение.

    Для покрытия кода тестами определим его внешние зависимости. В нашем случае этих зависимостей две: работа с базой данных и работа с сетевым соединением, которая проводится в классах WebService и DatabaseManager соответственно.

    class EntryAnalyzer {
       public:
         bool Analyze( std::stringename ) {
            if( ename.size() < 2 ) {
               webService.LogError( "Error: "+ ename );
               return false;
            }
            if( false== dbManager.IsValid( ename ) )
               return false;
    
            return true;
         }
       private:
          DatabaseManager  dbManager;  
             WebService  webService;
    };

    Рис.1. Код тестируемого класса, не пригодный для покрытия юнит-тестами

    Таким образом, для класса EntryAnalyzer они и являются внешними зависимостями. Потенциально, между проверкой dbManager.IsValid и финальной инструкцией «return true» может присутствовать код, требующий тестирования. При написании тестов получить доступ к нему мы сможем только после избавления от существующих внешних зависимостей. Для упрощения дальнейшего изложения такой дополнительный код не приведен.

    Теперь рассмотрим способы разрыва внешних зависимостей. Структура данных классов приведена на рис. 2.

    class WebService {
       public:
          void LogError( std::string msg ) {
             /* логика, включающая 
             работу с сетевым соединением*/
          }
    };
    
    class DatabaseManager {
       public:
          bool IsValid( std::string ename ) {
             /* логика, включающая 
             операции чтения из базы данных*/
          }
    };

    Рис.2. Структура классов для работы с сетевым соединением и базой данных

    Для написания тестируемого кода очень важно уметь разрабатывать, опираясь на контракты, а не на конкретные реализации. В нашем случае контрактом исходного класса является определение, валидно или нет имя ячейки (entry).

    На языке С++ данный контракт может быть задокументирован в виде абстрактного класса, который содержит виртуальный метод IsValid, тело которого определять не требуется. Теперь можно создать два класса, реализующих этот контракт: первый будет взаимодействовать с базой данных и использоваться в «боевой» (production) версии нашей программы, а второй будет изолирован от неподконтрольных зависимостей и будет использоваться непосредственно для проведения тестирования. Описанная схема приведена на рис. 3.


    Рис.3. Введение интерфейса для разрыва зависимости от взаимодействия с базой данных

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


    Рис.4. Пример классов, позволяющих осуществить разрыв зависимости от базы данных

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

    Также следует обратить внимание на объявление деструктора абстрактного класса виртуальным. Если это выглядит удивительно и неожиданно, то можно сгонять за книгой С. Майерса “Эффективное использование С++” и читать ее взахлеб, причем особое внимание уделить приведенному там правилу №7;).

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

    Разрыв зависимости с использованием stub-объектов


    Рассмотрим шаги, которые нужны для тестирования нашего класса EntryAnalyzer. Как было сказано выше, реализация тестов с использованием stub-объектов несколько проще, чем с использование mock-объектов. Поэтому сначала рассмотрим способы разрыва зависимости от базы данных.

    Способ 1. Параметризация конструктора

    Вначале избавимся от жестко заданного использования класса DatabaseManager. Для этого перейдем к работе с указателем, типа IDatabaseManager. Для сохранения работоспособности класса нам также нужно определить конструктор «по умолчанию», в котором мы укажем необходимость использования «боевой» реализации. Внесенные изменения и полученный видоизмененный класс представлены на рис. 5.


    Рис.5. Класс после рефакторинга, который позволяет осуществить разрыв зависимости от базы данных

    Для внедрения зависимости следует добавить еще один конструктор класса, но теперь уже с аргументом. Этот аргумент как раз и будет определять, какую реализацию интерфейса следует использовать. Конструктор, который будет использоваться для тестирования класса, представлен на рис. 6.


    Рис.6. Конструктор, используемый для внедрения зависимости

    Теперь наш класс выглядит следующим образом (зеленой рамкой обведен конструктор, используемый для тестирования класса):


    Рис.7. Рефакторинг класса, позволяющий осуществить разрыв зависимости от базы данных

    Теперь мы можем написать следующий тест, демонстрирующий результат обработки валидного имени ячейки (см. рис. 8):

    TEST_F( EntryAnalyzerTest, Analyze_ValidEntryName_ReturnsTrue )
    {
       EntryAnalyzer ea( std::make_unique<FakeDatabaseManager>( true ) );
    
       bool result = ea.Analyze( "valid_entry_name" );
       
       ASSERT_EQ( result, true );
    }
    
    class FakeDatabaseManager : public IDatabaseManager {
       public:
          bool WillBeValid;
    
          FakeDatabaseManager( bool will_be_valid ) : 
             WillBeValid( will_be_valid ) {
          }
    
          bool IsValid( std::string ename ) override {
             return WillBeValid;
          }
    };

    Рис.8. Пример теста, не взаимодействующего с реальной базой данных

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

    Вначале проделаем все те же шаги по замене жестко заданного использования класса DatabaseManager – перейдем к использованию указателя на объект, реализующий требуемый интерфейс. Но теперь в конструкторе «по умолчанию» возложим обязанности по созданию требуемых объектов на фабрику.

    Получившаяся реализация приведена на рис. 9.


    Рис. 9. Рефакторинг класса с целью использования фабрик для создания объекта, взаимодействующего с базой данных

    С учетом введенного фабричного класса, сам тест теперь можно написать следующим образом:

    TEST_F( EntryAnalyzerTest, 
       Analyze_ValidEntryName_ReturnsTrue )
    {
       DbMngFactory::SetManager( 
          std::make_unique<FakeDatabaseManager>( true ) );
       EntryAnalyzer ea;
    
       bool result = ea.Analyze( "valid_entry_name" );
    
       ASSERT_EQ( result, true );
    }
    
    class DbMngFactory {
       public:
          static std::unique_ptr<IDatabaseManager> Create() {
             if( nullptr == pDbMng )
                return std::make_unique<DatabaseManager>();
             return std::move( pDbMng );
          }
          static void SetManager( 
    	std::unique_ptr<IDatabaseManager> &&p_mng ) {
               pDbMng = std::move( p_mng );
          }
       private:
          static std::unique_ptr<IDatabaseManager> pDbMng;
    };

    Рис.10. Еще один пример теста, не взаимодействующего с реальной базой данных

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

    Способ 2. «Выделить и переопределить»

    Рассмотрим еще один поход к разрыву зависимости от базы данных — «Выделить и переопределить» (Extract and override). Возможно, его применение покажется более простым и таких вот эмоций не вызовет:



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

    Начнем с локализации зависимости. В нашем случае зависимость заключается в обращении к методу IsValid класса DatabaseManager. Мы можем выделить эту зависимость в отдельную функцию. Обратите внимание, что изменения следует вносить максимально осторожно. Причина – в отсутствии тестов, с помощью которых можно удостовериться, что эти изменения не сломают существующую логику работы. Для того чтобы вносимые нами изменения были наиболее безопасными, необходимо стараться максимально сохранять сигнатуры функций. Таким образом, вынесем код, содержащий внешнюю зависимость, в отдельный метод (см. рис. 11).


    Рис.11. Вынесение кода, содержащего внешнюю зависимость в отдельный метод

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


    Рис.12. Реализация метода «Выделить и переопределить» для разрыва зависимости

    Сам тест теперь можно написать следующим образом:

    TEST_F( EntryAnalyzerTest, Analyze_ValidEntryName_ReturnsTrue)
    {
       TestingEntryAnalyzer ea;
       ea.WillBeValid = true;
    
       bool result = ea.Analyze( "valid_entry_name" );
    
       ASSERT_EQ( result, true );
    }
    
    class TestingEntryAnalyzer : public EntryAnalyzer {
       public:
          bool  WillBeValid;
    
       private:
          bool IsValid( std::string ename ) override {
             return WillBeValid;
          }
    };

    Рис.13. И еще один пример теста, не взаимодействующего с реальной базой данных

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

    Разрыв зависимости с использованием mock-объектов


    Теперь мы умеем разрывать зависимости от базы данных с использованием stub-объектов. Но у нас еще осталась необработанной зависимость от удаленного web-сервера. С помощью mock-объекта мы можем разорвать эту зависимость.

    Что же надо для этого сделать? Здесь нам пригодится комбинация из уже рассмотренных методов. Вначале локализуем нашу зависимость в одной из функций, которую затем объявим виртуальной. Не забываем при этом сохранять сигнатуры функций! Теперь выделим интерфейс, определяющий контракт класса WebService и вместо явного использования класса будем использовать указатель unique_ptr требуемого типа. И создадим класс-наследник, в котором эта виртуальная функция будет переопределена. Полученный после рефакторинга класс представлен на рис. 14.


    Рис.14. Класс после рефакторинга, подготовленный для разрыва зависимости от сетевого взаимодействия

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


    Рис.15. Тестируемый класс, позволяющий осуществить разрыв зависимости от сетевого взаимодействия

    И теперь мы можем написать следующий тест:

    TEST_F( EntryAnalyzerTest, Analyze_TooShortEntryName_LogsErrorToWebServer )
    {	
       std::shared_ptr<FakeWebService> p_web_service =   
          std::make_shared<FakeWebService>();
       TestingEntryAnalyzer ea( p_web_service );
    
       bool result = ea.Analyze( "e" );
    
       ASSERT_EQ( p_web_service->lastError, "Error: e" );
    }
    
    class TestingEntryAnalyzer : public EntryAnalyzer {
       public:
          TestingEntryAnalyzer(
            std::shared_ptr<IWebService> p_service ) :
             pWebService( p_service ) {
          }
    
       private:
           void LogError( std::string err ) override {
             pWebService->LogError( err );
           }
           std::shared_ptr<IWebService>   pWebService;
    };
    
    class FakeWebService : public IWebService {
       public:
          void LogError( std::string error ) override {
             lastError = error;
          }
          std::string lastError;
    };

    Рис.16. Пример теста, не взаимодействующего с сетевым соединением

    Таким образом, внедрив зависимость с помощью параметризации конструктора, на основе анализа состояния mock-объекта мы можем узнать, какие сообщения будет получать удаленный web-сервис.

    Рекомендации для создания тестов, легких для поддержки и модернизации


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



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

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

    Однако порой тесты не проходят, и приходится разбираться, что же пошло не так. При этом довольно неприятная ситуация может возникнуть, если ошибка содержится в самом тесте. Как правило, в первую очередь причины непрохождения мы начинаем искать именно в логике работы тестируемого «боевого» кода, а не самого теста. В этом случае на поиск причины непрохождения может быть потрачена куча времени. Для того чтобы этого избежать, надо стремиться к тому, чтобы сам тестовый код был максимально простым – избегайте использования в тесте каких-либо операторов ветвления (switch, if, for, while и пр.). Если же необходимо протестировать ветвление в «боевом» коде, то лучше написать два отдельных теста для каждой из веток. Таким образом, типовой юнит-тест можно представить как последовательность вызовов методов с дальнейшим assert.

    Рассмотрим теперь следующую ситуацию: есть класс, для которого написано большое количество тестов, например, 100. Внутри каждого из них требуется создание тестируемого объекта, конструктору которого требуется один аргумент. Однако с ходом развития проекта, ситуация изменилась — и теперь одного аргумента недостаточно, и нужно два. Изменение количества параметров конструктора приведет к тому, что все 100 тестов не будут успешно компилироваться, и для того чтобы привести их в порядок придется внести изменения во все 100 мест.

    Чтобы избежать такой ситуации, давайте следовать хорошо известному нам всем правилу: «Избегать дублирования кода». Этого можно добиться за счет использования в тестах фабричных методов для создания тестируемых объектов. В этом случае при изменении сигнатуры конструктора тестируемого объекта достаточно будет внести соответствующую правку только в одном месте тестового проекта.

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



    Стало интересно? Можно погрузиться глубже.


    Для дальнейшего и более подробного погружения в тему юнит-тестирования советую книгу Roy Osherove «The art of unit testing». Кроме того, довольно часто также возникает ситуация, когда требуется внести изменения в уже существующий код, который не покрыт тестами. Один из наиболее безопасных подходов заключается в том, чтобы вначале создать своеобразную «сетку безопасности» — покрыть его тестами, а затем уже внести требуемые изменения. Такой подход очень хорошо описан в книге М. Физерса «Эффективная работа с унаследованным кодом». Так что освоение описанных авторами подходов может принести нам, как разработчикам, в арсенал очень важные и полезные навыки.

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

    Автор: Виктор Ястребов vyastrebov
    Тензор
    54.19
    Разработчик системы СБИС
    Share post

    Comments 14

      0

      Отличная статья, показывающая что и как надо рефакторить в старом проекте для внедрения UnitTest-ов.


      А главное теперь есть хороший пример, чтобы пресечь возражения такого рода: "Ну тут чтобы протестировать надо пол-проекта переписать :("

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

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


        1. фабрика:


          DbMngFactory::SetManager( 
            std::make_unique<FakeDatabaseManager>( true ) );
          EntryAnalyzer ea;

          EntryAnalyzer ea;
          DbMngFactory::SetManager( 
            std::make_unique<FakeDatabaseManager>( true ) );

          В двух приведенных примерах анализатор находится в разных состояниях, что крайне не очевидно.


        2. перегрузка метода:
          Вы не вызываете реальные методы БД, но сам менеджер инициализируется. Если запустить параллельно несколько тестов, то может быть достигнут лимит количества подключений к БД.
          Таким образом тест корректной проверки упадет, если несколько разработчиков запустят тесты одновременно. Или не упадет.
          0
          Спасибо за комментарии к приведенным примерам. Насчет указанных Вами замечаний хотел бы сказать следующее.
          1. Вы правы, что крайне желательно писать тесты, которые понятны для чтения и прозрачны для понимания.
          Однако при написании тестируемого кода мы, по сути, создаем его для еще одного пользователя — наших тестов. В указанном вами примере всю ответственность за создание необходимых для выполнения программы объектов мы возлагаем на фабрику. Это позволяет разграничить нам зоны ответственности классов. Но в этом случае для написания теста нам следует привести тестируемые объекты в требуемое состояния, что и является целью выполнения этапа Arrange.
          2. В зависимости от особенностей архитектуры тестируемого класса, описанная вами ситуация потенциально, конечно, может возникнуть. Но если вы знаете такие архитектурные особенности, то целиком в вашей власти разорвать в тестируемом классе зависимость от БД полностью. В противном случае, как вы справедливо заметили, написанный тест будет взаимодействовать с реальной БД, что не даст ему права являться хорошим юнит-тестом.
          0
          Статья в целом весьма приятная.

          Но вот пример для mock какой-то странный. Если уж мы там выделили в тестируемом коде работу с веб-сервисом в отдельную функцию (LogError), то никакие IWebService нам уже не требуются, т.к. мы можем банально завести переменную lastError и писать в неё прямо в классе TestingEntryAnalyzer.

          Понятно, что это всё демонстрационные примеры для статьи. Но они всё же в них должен быть логический смысл — если используем mock, то должна быть видна потребность в таком решение.
            0
            Спасибо за отклик на статью. Вы правы, можно сделать проще. Но при написании тестов и соответствующего тестируемого кода нельзя выделить один путь, который является единственно верным. Как правильно, на мой взгляд, в своей книге отметил Roy Osherove, написание юнит-тестов — это есть искусство. Следует развивать умение чувствовать в каком случае и как лучше поступить. Возвращаясь к статье, мне хотелось показать основные принципы работы с fake-объектами, поэтому и был приведен указанный вами тест.
            0
            Во-первых, непонятно почему используя GoogleTest, вы не используете GoogleMock, а изобретаете очередной велосипед. Причем новый под каждый новый тест.
            А во-вторых, в синтаксисе GoogleTest правильней использовать конструкции EXPECT_XXX, а не ASSERT_XXX. Т.к. макросы ASSERT прерывают выполнение тестов и вы увидите только первый фейл из всех возможных и не факт что это будет самый «правильный» фейл с точки зрения анализа root cause.
              0
              Спасибо за комментарии. Вы правы, можно применять GoogleMock для автоматизированного создания Mock-объектов. Но, как было сказано ранее, к числу важнейших характеристик хорошего юнит-теста следует отнести его читабельность, а также легкость его дальнейшей модификации и поддержки. Возможно, с помощью GoogleMock Mock-объекты можно быстрее создавать, но в дальнейшем, по моему опыту, тесты, их использующие, сложнее читать и тем более поддерживать.
              Таким образом, в примерах, представленных в статье, GoogleMock намеренно не использовался, чтобы не усложнять рассказ о принципах создания тестируемого кода.

              Касательно применения EXPECT_XXX и ASSERT_XXX. Как было сказано в статье, одна из рекомендаций заключается в том, чтобы использовать в каждом тесте только один assert. В этом случае, если сработал assert, то можно однозначно определить root case — найти причину его непрохождения.
                0
                Что-то мне подсказывает что научиться один раз читать конструкции GoogleMock все-же проще и перспективней, чем каждый раз разбираться как-же в этом месте написан тот или иной mock или stub…

                Насчет единственного ASSERT, тоже требование так себе… Предположим у нас есть функция которая возвращает два значения, можно конечно сделать что-нибудь типа
                ASSERT_TRUE(val1==xxx && val2==yyy);

                но мне кажется правильней все-же писать
                EXPECT_EQ(val1, xxx);
                EXPECT_EQ(val2, yyy);
                

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

                  Говоря же в статье об использовании в тесте единственного assert, я хотел отметить, что не стоит в одном тесте подвергать проверке несколько несвязанных друг с другом задач. В указанном вами примере, мне кажется, может быть оправдано использование приведенного вами подхода – поскольку, по сути, тестируется один результат, возвращаемый функцией.

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

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

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

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

                  Также спасибо вам за аргументированный комментарий.
              0
              Да. Забыл еще вот что спросить. Зачем делать два конструктора, один дефолтный и один с параметром, если вместо этого можно сделать один конструктор с дефолтным параметром. Таким образом мы избегаем дублирования кода.
                0
                В принципе, в приведенном примере так, конечно, сделать можно. Но в этом случае, при работе над реальным проектом в перспективе может возникнуть ряд сложностей, связанных с поддержкой тестов, а также читабельностью кода боевого класса.
                Немножко поутрирую. Например, пусть у нас есть класс, обладающий внешними зависимостями в количестве 100 штук, а для создания боевой версии объекта используется конструктор с 5 параметрами, часть из которых также задается по умолчанию. В таком случае, человеку, который разбирается с нашим кодом, придется потратить определенное количество времени, чтобы понять, какие из 105 параметров нужны для боевой версии, а какие — для тестирования. А если сроки горят, то все совсем нехорошо складывается. Так вот, чтобы избежать потенциальной агрессии с его стороны, все-таки рекомендуется разграничивать предназначения конструкторов, что и было сделано в указанном примере)
                0
                юнит тесты зло без функциональных, вроде всё потестено, а как оно всё вместе работает неизвестно :)
                  0
                  Спасибо за комментарий. Конечно, юнит-тесты не являются серебряной пулей, способной излечить все проблемы проекта. Но их использование в процессе разработки способствует созданию прочного фундамента, позволяющего поддерживать и развивать долгосрочные проекты. А тестирование совместной работы модулей, как вы справедливо заметили, это уже задача следующего уровня — интеграционных, нагрузочных, функциональных и прочих-прочих-прочих тестов :)

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