Pull to refresh

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

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

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

Не знаю, как у вас, но у меня написание сложных запросов к базе данных, неважно, 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)). Код должен говорить, что он делает.
  • Если какой-то кусок кода трудно тестировать — скорее всего, он плохо написан. В принципе, положено сначала писать тест, а потом код, который этот тест проверяет. Попробуйте, вам понравится.
  • И самое главное, написание тестов — это не скучная обязанность, а просто правильный подход к написанию кода, который позволяет вам задумываться над по-настоящему интересными проблемами, а не над скучной рутиной.
Tags:
Hubs:
Total votes 22: ↑21 and ↓1+20
Comments14

Articles