Подход к тестированию кода в реальной жизни. Часть вторая

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

    Продолжаем тестировать запросы

    Не знаю, как у вас, но у меня написание сложных запросов к базе данных, неважно, SQL или HQL запросов, всегда вызывало некоторую опаску. Бывало, напишешь очень грамотный запрос, который стопроцентно должен делать то, что надо, запустишь код в production, и все, вроде бы, нормально, но через какое-то время выясняется, что находится такое сочетание данных, при котором чего-то вычисляется совершенно неправильно, или каких-то записей не хватает, или еще что-нибудь в этом роде. В особенности это относится к запросам, в которых приходится использовать много outer joins, да еще и группировать данные по каким-то полям, которые то ли есть, то ли нет. В-общем, понятно, надеюсь. Сложные запросы. В нашем проекте было несколько таких, которые для облегчения жизни Flex-программистов предоставляли сложные разветвленные данные в виде простой таблицы. Наш ответ на всю эту сложность? Конечно, тесты! При этом я тоже живой человек, и мне совершенно неинтересно писать 20 тестов с кучей повторяющегося (или незначительно отличающегося) кода такого типа:
    public void test() {
      Application app1 = ApplicationMother.makeApplication();
      Version version11 = VersionMother.makeVersion(app1);
      save(app1, version11);
      DeploymentMother.makeDeployment(app1);
      DeploymentMother.makeDeployment(app1);
      // повторить еще 4 раза
      List<ResultRow> result = myRepository.executeComplicatedQuery();
      assertEquals(app1.getId(), result.get(0).getAppId());
      assertEquals(version11.getId(), result.get(0).getVersionId());
      assertEquals(2, result.get(0).getDeploymentCount());
      // и так далее до посинения
    }
    // и еще 19 тестов такого типа с разными исходными данными
    
    Чтобы избежать этой муки, мы вспомним о том, что написание тестов — это тоже программирование, ничуть не менее интересное, чем написание самого продукта. И все подходы, которые мы применяем ко второму, вполне применимы и к первому. В частности, вспомним, что рефакторинг — наш друг. Все, что хоть отдаленно похоже на повторяющийся код, безжалостно убираем в методы-помощники и классы-помощники.В результате имеем:
    public void testDeletedDeploymentsDontCount() {
      TestData data = app("app 1")
          .withNonDeletedVersion().deployedTimes(2)
          .withDeletedVersions(1).deployedTimes(2)
        .app("app 2")
          .withNonDeletedVersion().deployedTimes(1);
    
      queryAndAssert(
        data.makeExpectedRow(1, 1, 2),
        data.makeExpectedRow(1, 2, 0),
        data.makeExpectedRow(2, 1, 0)
      );
    
    // где TestData.makeExpectedRow принимает параметры: 
    // номер приложения по порядку, номер версии внутри приложения, и ожидаемое число deployment-ов.
    
    Уже лучше, правда? Код стал несравненно более читаемым. При использовании method-chaining (по-моему, это называется ExpressionBuilder Pattern), у вас всегда есть возможность дописывать дополнительные методы, вызов которых будет создавать целые структуры, например:
    TestData data = times(5).deployedAppAndVersion()
      .times(2).deletedDeployedApp();
    
    Да, для этого пришлось написать некоторое количество служебного кода, но, во-первых, это было действительно интересно, а во-вторых, красиво. То есть, я получил удовольствие, научился новым трюкам, и принес пользу, так как теперь я в любой момент могу добавить любое количество тестов на любые дурацкие сочетания исходных данных, и займет у меня это две минуты от силы. Если интересно, примерная разбивка по времени:
    • Написание первого варианта запроса: 20 мин.
    • Написание первых двух тестов: 10 мин.
    • Написание третьего теста, ругань, что некрасиво и неудобно: 10 мин.
    • Рефакторинг номер 1: 30 мин.
    • Написание тестов 4-8, рефакторинг номер 2: 10 мин.
    • Переписывание запроса, ибо ни фига не работал, оказывается, и успешный запуск всех тестов: 20 мин.
    • Добавление еще тестов, легкий рефакторинг тестов, починка запроса: 4 раза по 10 мин.
    Сколько бы это заняло времени, если бы на каждую ошибку приходил баг-репорт, я снова открывал запрос, вспоминал, какой урод это напрограммировал и почему, чинил, отправлял код, и так далее, я даже боюсь себе представить.Надеюсь, понятно, что подобный подход применим не только к тестированию запросов. Поэтому переходим дальше:

    Тестируем взаимодействие со сторонними сервисами

    В нашем случае в качестве стороннего сервиса выступает виртуализационная инфраструктура, т.е. сервис, который запускает, гасит, и мониторит виртуальные машины. Сложностей тут — море. Начиная от того, что никто толком не знает, что она там у себя внутри делает («внутре у ней неонка», ага), и заканчивая тем, что ее API сделан ну совершенно через одно местов прошлом веке, и, например, вместо того, чтобы сказать ей (инфраструктуре): «Запусти 3 вот таких виртуалки, и постучи вот сюда, когда закончишь», приходиться отдавать команды на создание, потом все время запрашивать статус, и т.д. При этом напомню, что каждый программист запускает все тесты у себя на машине очень часто, и команда у нас географически разбросана, поэтому работать с реально инфраструктурой все время — нереально.Тут мы подходим к разбиению всех наших тестов на несколько групп. Обычно выделяют unit, integration, acceptance тесты. Но для меня группа integration тестов немного непонятна. Описанные в предыдущем разделе тесты запроса — это unit или integration? Поэтому у нас в группу номер один входят все тесты, которые можно запустить у себя на ноутбуке, а в группу номер 2 — все, которые нужно запускать внутри корпоративной сети, и которые имеют внешние зависимости (например, виртуализационную инфраструктуру). Есть еще группа номер 3, но о ней позже.Зададим себе вопрос — а что, собственно, нам нужно протестировать и в каких случаях? Мы не тестируем библиотеки и стронние сервисы как таковые, поэтому сделаем вид, что инфраструктура правильно выполнит то, что мы ей скажем. В нашем проекте есть отдельный модуль, который оборачивает HTTP API в код на Java. Этот модуль мы тестируем некоторым количеством простых тестов, проверяющих его собственную логику, которой там немного. Плюс буквально парочкой тестов во второй группе, которые запускаются только внутри корпоративной сети, и убеждаются, что этот виртуальный коннектор все еще может соединиться, запустить и убить виртуальную машину, получить ее статус. Гораздо интересней и полезней тестировать логику нашего приложения. Здесь нам на помощь приходят stubs. Фаулер, как всегда — наше все.
    //интерфейс VirtualInfrastructureManager - тот же, который использует реальный коннектор
    public class VirtualInfrastructureManagerStub implements VirtualInfrastructureManager {
    
      private List<VirtualMachineState> vms;
    
      public VirtualMachineState createVm(VirtualMachineDescription vmDescription) { 
        vms.add(makeVm(vmDescription));
      }
    
      public List<VirtualMachineState> pollForStatuses() {
        return vms;
      }
      //ну, и так далее. Код элементарный
    }
    
    Теперь, с помощью магии Spring-а, в нашем тестовом контексте мы подставляем этот класс везде, где требутся виртуальная инфраструктура. При этом во всех наших тестах мы не проверяем, действительно ли создана виртуальная машина, а как наше приложение себя ведет, когда она создана.
    public void testDeploymentStarted() {
      Deployment deployment = DeploymentMother.makeNewDeployment();
      deploymentManager.startDeployment(deployment);
      assertDeploymentGoesThroughStatuses(NEW, STARTING, CONFIGURING, RUNNING);
    }
    
    Чтобы было еще интересней, в наш стаб можно подселить Chaos Monkey.
    public class VirtualInfrastructureManagerStubWithChaosMonkey implements VirtualInfrastructureManager {
    
      private ChaosMonkey monkey;
    
      public VirtualMachineState createVm(VirtualMachineDescription vmDescription) {
        monkey.rollTheDice();
        vms.add(makeVm(vmDescription));
      }
      ...
    
    
      private class ChaosMonkey {
        public void rollTheDice() {
          if (iAmEvil()) throw new VirtualInfrastructureException("Ha-ha!");
          return;
        }
      }
    }
    
    Представляете, сколько интересных ситуаций можно создать, если обезьянке можно будет задавать вероятность ошибки. Или если она периодически будет «убивать» наши виртуальные машины по случайному закону?
    public void testRunningDeploymentRecovers() {
      Deployment deployment = startDeploymentAndAssertRunning();
      ((VirtualInfrastructureManagerStubWithChaosMonkey)virtualInfrastructureManager)
    .getChaosMonkey().killVms(1);
      assertDeploymentGoesThroughStatuses(BROKEN, RECOVERING, RUNNING);
    }
    
    И все это веселье мы получаем прямо на своей машине, без необходимости соединяться с корпоративной VPN, без ожидания по пять минут на запуск каждой виртуальной машины.

    Краткое отступление: Continuous Integration

    Буду краток — если у вас команде больше одного человека, то вам обязательно нужен сервер continuous integration. Нужен так же сильно, как и репозитарий кода. Какой смысл писать все эти умные и красивые тесты, если они не будут запускаться и ловить ошибки? В этом проекте у нас было три разных процесса сборки. Первый, запускается автоматически каждый раз, когда кто-то делает git push, гоняет тесты первой группы. В команде существовало правило — если ты сделал push, ты не уходишь домой, пока первая сборка не закончится успешно (примерно, 20 минут). В некоторых конторах на стол того, кто сломал сборку, клался плюшевый таракан, в некоторых — его обязывали носить безразмерную желтую майку с надписью «Kick me, I broke the build», а у нас было просто: сломал — почини. Если первая сборка проходила успешно, запускалась вторая, которая исполняла тесты из второй группы — которые работали с реальной инфраструктурой. Этот процесс был довольно длительным (к сожалению). Если и он заканчивался успешно, то включалась третья сборка, которая собранный код заливала на тестовый сервер, и прогоняла буквально пару тестов, чтобы убедиться, что апгрейд прошел успешно. Именно на этом этапе ловились проблемы с изменением структуры базы данных. После всех трех сборок у нас был сервер, на котором был гарантированно рабочий код, и мы могли его демонстрировать клиентам.

    Тестируем приложение целиком

    Все это, конечно, замечательно, но, чтобы спокойно спать, нам надо быть уверенными, что наше приложение работает полностью, а не только его отдельные куски. При этом, если каждый отдельный модуль и их сочетания хорошо покрыты тестами, нам не нужно проверять каждый аспект работы, чтобы не делать одну и ту же работу дважды. Тут полезно обратиться снова к техзаданию для вашего продукта. Например, одна из историй (user story) гласила: «Как пользователь, я хочу иметь возможность запускать приложение, требующее работы MySQL в режиме master-slave replication». У нас есть тесты, которые проверяют загрузку приложения, есть тесты, гарантирующие правильность той информации, которую мы отсылаем на каждую виртуальную машину для всех случаев. Но сам код, который будет конфигуриривать весь необходимый софт на виртуальной машине, во-первых, написан другими людьми, и, во-вторых, на другом языке. Что мы делаем в этом случае? Тупо следуем описанию истории. Сказано «мое приложение требует master-slave» — пожалуйста. Пишем примитивное веб-приложение, состоящее из двух JSP страниц. На одной странице мы создаем JDBCConnection к мастеру, и пишем в базу некую запись. На второй странице создаем JDBCConnection к слейву, и читаем. Затраты времени на создания этого тестового приложения — 10 минут.Наш тест загружает это приложение в наш продукт, просит запустить его в нужном режиме, а потом просто заходит по HTTP на первую страницу, и затем на вторую. Если нужный текст появился на второй странице — все в порядке. Если творчески подойти к списку требований, таких полных тестов потребуется совсем немного. Зато представьте, какое это громадное подспорье, когда нам было приказано в срочном порядке добавить поддержку парочки других операционных систем для запускаемых виртуальных машин!

    Тестирование веб-интерфейса

    Это совершенно отдельная тема, очень интересная, со своими подводными камнями. Я не стану сейчас ее раскрывать, потому что в описываемом проекте оно не делалось. Интерфейс был написан на Flex, им занималась другая группа со своими тараканами. Я протестовал, интриговал, делал, что мог, но ничего не изменилось. Сам я тестированием веб-интерфейсов занимался полтора года назад на совсем другом проекте, и сейчас мне по памяти было бы трудно привести хорошие примеры веб тестов. Скажу только, что Selenium — ваш очень большой друг. Вы спросите, как же мы тестировали наш нынешний проект полностью? Специально для этого нам пришлось написать набор REST веб-сервисов, и все тесты вызывали именно их. Нашей большой победой я считаю то, что мы заставили Flex-интефейс обращаться именно к этим сервисам, поэтому мы были достаточно уверенными, что продукт работает, а если что не работает — это проблема интерфейса. Кстати, поскольку интерфейс вечно отставал от нас по функциональности, мы написали клиент для коммандной строки, который обращался к веб-сервисам, и этот клиент настолько понравился потенциальным клиентам, что одно время начальство собиралось вообще веб-интерфейс выкинуть. Но это — совсем другая история.

    Заключение

    Я не ставил перед собой задачу написать учебник по тестированию, или описать все варианты тестов. Я не попытался в очередной раз доказать, что написание тестов — необходимость. Я просто хотел показать, что писать тесты — это совсем нетрудо и может доставить удовольствие (не говоря уже о пользе).Несколько выводов и наблюдений:
    • Тесты должны быть «говорящими», сам тест должен быть настолько примитивным, насколько это возможно. Вам не раз придется возвращаться к нему, и пытаться заново понять, что же он должен проверять. На это не должно уходить времени больше, чем необходимо, чтобы просто прочесть код
    • Лучший программист в вашей команде должен заниматься созданием и улучшением удобной инфраструктуры для тестирования сложных вещей. Например, метод assertDeploymentGoesThroughStatuses(DeploymentStatus...allowedStatuses) — совсем не прост. Домашнее задание — написать на псевдокоде такой метод, если учесть, что приложение многопоточное, и каждый шаг быть выполнен в отдельном потоке и занять неопределимое заранее время :)
    • Тестируйте только то, что нужно тестировать. За написание двадцати тестов, проверяющих getters и setters, надо отрывать руки
    • Где-то в комментариях встретил мнение, что надо продумать структуру ваших тестов, и вообще планировать. Категорически возражаю. Барьер для написания тестов должен быть минимальным. Просто начните писать тесты. Если завтра вам понадобиться изменить их структуру — изменяйте. Рефакторинг так же применим к тестам, как и ко всему остальному
    • Любой кусок теста, который встречается два раза — смело выносите в отдельный метод (например, в TestUtils). Это еще больше понижает барьер для написания следующего теста вашим сотрудником.
    • Любой кусок теста, который выглядит слишком общо (например, assertEquals(2, deployments.size()), выносите в TestUtils (assertSize(expectedSize, collection)). Код должен говорить, что он делает.
    • Если какой-то кусок кода трудно тестировать — скорее всего, он плохо написан. В принципе, положено сначала писать тест, а потом код, который этот тест проверяет. Попробуйте, вам понравится.
    • И самое главное, написание тестов — это не скучная обязанность, а просто правильный подход к написанию кода, который позволяет вам задумываться над по-настоящему интересными проблемами, а не над скучной рутиной.

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

      0
      А как бы вы порекомендовали тестировать работу с данными, точнее JDO?
        0
        In-memory database, не?
          +1
          Ага, она самая. Я много лет не имел дела ни с чем, кроме связки Spring+Hibernate, поэтому слегка не в курсе, какие проблемы существуют за ее пределами, и могу только сказать, что dependency injection очень помогает с тестированием, в числе прочего. Т.е. позволяет легко подменить боевую базу на тестовую in-memory для тестов. А дальше задача сводится к предыдущей :)
        0
        Ну хорошо, а давайте вот-так: есть мультимедийное приложение, которое рисует разную графику, видюшки, картинки, анимацию и прочее в каких-то своих целях. При этом, ясное дело, такие вещи как 3-д движки, DirectX\OpenGL, видеокодеки, драйвера видеокарт и прочее не пишется с нуля, а используется уже готовое (и большинство — без исходников). И вот в зависимости от глюков всего вышеперечисленного, в определенных программно-аппаратных конфигурациях могут возникать глюки вида «вот эта линия недостаточно плавная» или «изображение слишком резкое» или «цвета как-то поплыли» или «тень от объекта падает не по вектору освещения». А ну как представьте мне способ написания тестов, проверяющих такие вещи.
          0
          Извините, не специалист в этом, поэтому даже не представляю, как подступиться. Первое, что приходит в голову — двигаться в лоб, т.е. сравнивать результат с эталоном. Подозреваю, что это смешное предложение :)

          На самом деле, специалист именно в этой области должен спросить себя: что конкретно надо тестировать, какие данные подвергаются верификации? Какие части процесса находятся под моим контролем, и какие нет? То есть если упомянутые глюки заведомо являются глюками комбинации железа и драйвера, а мы пишем мультимедийное приложение, то можем ли мы что-нибудь с этим сделать, даже если найдем способ протестировать? Если же единственное, что мы можем сделать — это передать некоторые дополнительные параметры видео-драйверу, то и надо тестировать тот алгоритм, который передает эти параметры в зависимости от окружения, т.е., грубо говоря, assertNvidiaParametersSet(myAlgorithm(nvidia)), а не изобретать способы сравнения направления тени.
            0
            Сравнивать результат с эталоном — и вправду единственный хоть немного имеющий смысл вариант. Который по ряду причин часто не помогает:
            -эталонной конфигурации для данной программно-аппаратной конфигурации просто нет, так как таких конфигураций до чёрта и для всех эталоны не создашь
            -результат вообще никогда не совпадает с эталоном, поскольку на него влияет динамически-меняющиеся данные (рандом, время, действия юзера, картинка с камеры, информация из внешних источников и т.д.)

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

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

              0
              Про все, кроме последнего абзаца: А что вы реально можете сделать? Ваш код работает правильно? Правильно. Параметры переданы верно? Верно. Добавление «хака» для какой-то платформы — это не починка бага, а новая фича по-любому. Тесты и не могут ее поймать, по определению. А уже для хака пишется отдельный тест, который, опять же, проверяет не картинку, то, что хак задействован на нужной платформе, и незадействован на ненужной.

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

              А насчет последнего абзаца — это, вообще, о чем?
                0
                Ну, или если без поноса, то как создатель веб-приложения я в принципе не могу отследить тот факт, что на некой комбинации ОС, браузера, и настройки шрифтов, шрифт заголовка рендерится криво. Ну нет такой возможности, ни у кого. Чтобы иметь возможность это заметить, люди должны смотреть глазами, и сообщать мне. Или пользователи, или же я могу посмотреть видео-запись моих автоматических веб-тестов. Не факт, что я замечу, но, предположим, что я заметил. И что мне с этим делать? Найти шрифт, работающий хорошо для этой комбинации, и написать тест, который проверит, что везде используется стандартный шрифт, а на этой комбинации внешнего окружения — специальный. И все. Я не тестирую браузеры и операционные системы (кроме случаев, когда я пишу браузер или операционную систему). Я могу тестировать только свой код. Мой код не занимается рендерингом, поэтому я рендеринг тестировать и не буду.
            0
            Создать реальную сцену из фанеры и пенопласта, полностью соответствующую игровой, раскрасить ее, сфотографировать и сохранить в .png
            Затем пишите тест:
            1. Установить камеру в нужную позицию на игровой сцене
            2. Отрендерить и сохранить в .png
            3. Сравнить отрендеренное изображение с полученным с фотокамеры )))

            А вообще подобные проекты тестируется при помощи хомячков (ну или лемингов) )))
              0
              Ну, блин, фанера и пенопласт — я до такого не додумался :) Моя идея была проще — срендерить картинку на эталонном железе/драйвере, глазками посмотреть (а еще лучше, чтобы начальство глазками посмотрело и подписало), а потом сравнивать эту эталонную картинку с полученной в тесте. Подозреваю, что идиотская идея
                0
                Эталонное железо — это прекрасно. Я так думаю, из него сделана упряжка и подковы сферического коня в вакууме. Оно вечно, стабильно и надёжно. Вот только у конечного юзера его не будет, а будет какая-то хрень китайская по 5 баксов за пучок.
                  0
                  Еще раз: насчет эталонного сферического железа -это было в порядке бреда неспециалиста. Единственно правильный подход, как мне кажется, описан тут.
              0
              Что касается проблем в сторонних компонентах, то мы должны выловить все проблемы на компонентах, соответствующих некоторой спецификации. То, что конечный пользователь запустить на кривом железе и у него поплывет нас особо заботить не должно. Конечно, если таких пользователей много, то можно сделать work around, но опять же не стоит такие проблемы решить заранее.
              0
              >И самое главное, написание тестов — это не скучная обязанность, а просто правильный подход к написанию кода, который позволяет вам задумываться над по-настоящему интересными проблемами, а не над скучной рутиной.

              Золотые слова :)

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

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