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

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

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


Я не теоретик, я практик («Я по натуре не Пушкин, я — Белинский» ©). Поэтому я буду использовать некоторые идеи из Test Driven Development, Data Driven Development, Behaviour Driven Development, но не всегда смогу обосновывать свой выбор. В-основном, моя аргументация будет звучать так: «Так легче», или «так удобнее». Прекрасно понимая, что мои рецепты подходят далеко не всем, прошу, во-первых, все равно задуматься над моей аргументацией, и, во-вторых, не рассматривать данный текст как учебник.

Итак, барабанная дробь:

Сложное приложение



Приложение это существует в реальности, но оно не публично, поэтому реальный код я показать не могу. Область применения — PaaS, платформа-как-сервис. Конкретно — автоматизация запуска клиентских приложений на некой виртуальной инфраструктуре. Работать будет внутри больших предприятий. Архитектура достаточно стандартна: реляционная база данных, сверху Hibernate, потом веб-интерфейс, все управляется Spring framework. Сбоку у нас две приблуды: одна разговаривает с API той самой виртуальной инфраструктуры, а вторая — соединяется с каждной созданной виртуальной машиной через SSH, и нечто там запускает, в результате чего виртуальная машина получает весь необходимый софт и нужные куски клиентского приложения, и эти куски запускает. Если честно, это самое сложное приложение, которое я написал за свою жизнь, думаю, пойдет в качестве примера. Язык — Java, с вкраплениями Scala.

Spring и тесты. Тестируем бизнес-логику с помощью mock-тестов



Приложения на Spring-е тестировать очень легко. Одна из причин использования Spring — это то, что с ним легко тестировать. «Кролик — это тот, кто живет в норе. Нора — это то, где живет кролик» ©

Интерфейс:
public interface DeploymentService {
  
  Deployment deploy(Application application, DeploymentDescription deploymentDescription);

}


Имплементация:
@Service
public class DeploymentServiceImpl implements DeploymentService {

  private DeploymentRepository deploymentRepository;
  private DeploymentMaker deploymentMaker;
  private VirtualInfrastructureService virtualInfrastructureService;

  @Autowired
  public DeploymentServiceImpl(DeploymentRepository deploymentRepository, DeploymentMaker deploymentMaker, VirtualInfrastructureService virtualInfrastructureService) {
    this.deploymentRepository = deploymentRepository;
    this.deploymentMaker = deploymentMaker;
    this.virtualInfrastructureService = virtualInfrastructureService;
  }

  public Deployment deploy(Application application, DeploymentDescription deploymentDescription) {
      //создаем новый объект Deployment
      Deployment deployment = deploymentMaker.makeDeployment(application, deploymentDescription);
      deploymentRepository.save(deployment);
      try {
	virtualInfrastructureService.launchVirtualMachines(deployment.getVirtualMachineDescriptors());
      } catch (VirtualInfrastructureException e) {
	throw new DeploymentUnsuccsessfullException(e);
      }
      return deployment;
  }

}


В принципе, понятно, что оно делает. Конечно реальный код чуть посложнее, но ненамного. Сначала в лоб тестируем метод DeploymentService.deploy() с использованием мок-библиотеки JMock

public class DeploymentServiceMockTest extends MockObjectTestCase {

  private DeploymentRepository deploymentRepository = mock(DeploymentRepository.class);
  private DeploymentMaker deploymentMaker = mock(DeploymentMaker.class);
  private VirtualInfrastructureService virtualInfrastructureService = mock(VirtualInfrastructureService.class);
  private DeploymentServiceImpl deploymentService;

  public void setUp() {
    deploymentService = new DeploymentServiceImpl(deploymentRepository, deploymentMaker, virtualInfrastructureService);
  }
   
  public void testSuccessfulDeploymentSavedAndVMsLaunched() {
    final Application app = makeValidApplication();
    final DeploymentDescription dd = makeValidDeploymentDescription();
    final Deployment deployment = helperMethodToCreateDeployment();
    checking(new Expectations(){{
      one(deploymentMaker).makeDeployment(app, dd);will(returnValue(deployment));
      one(deploymentRepository).save(deployment);
      one(virtualInfrastructureService).launchVirtualMachines(deployment.getVirtualMachineDescriptors());
    }});
    deploymentService.deploy(application, deploymentDescription);
  }

  public void testExceptionIsTranslated() {
    final Application app = makeValidApplication();
    final DeploymentDescription dd = makeValidDeploymentDescription();
    final Deployment deployment = helperMethodToCreateDeployment();
    checking(new Expectations(){{
      one(deploymentMaker).makeDeployment(app, dd);will(returnValue(deployment));
      one(deploymentRepository).save(deployment);
      one(virtualInfrastructureService).launchVirtualMachines(deployment.getVirtualMachineDescriptors());will(throwException(new VirtualInfrastructureException("error message")));
    }});
  ` try {
      deploymentService.deploy(application, deploymentDescription);
      fail("Expected DeploymentUnsuccsessfullException!");
    } catch (DeploymentUnsuccsessfullException e) {
      //expected
    }
    
}


Что тут важного и интересного? Во-первых, мок-тесты позволяют нам тестировать изолированные методы без оглядки на то, что эти методы вызывают. Во-вторых, написание тестов очень сильно влияет на структуру кода. Первый вариант метода deploy(), естественно, вызывал не DeploymentMaker.makeDeployment(), а тот же метод внутри DeploymentServiceImpl. Когда я начал писать тест, то обнаружил, что на данном этапе мне не интересно писать тесты на все варианты действий, которые выполняет makeDeployment. Они не имеют ни малейшего отношения к действиям в методе deploy(), который просто должен записать новый объект в базу, и запустить процесс создания виртуальных машин. Поэтому я вынес логику makeDeployment() в отдельный класс-помощник. Его я буду тестировать совсем другими способами, потому что для его работы имеют значения состояние объектов application и deploymentDescription. Кроме того, я обнаружил, что после того, как DeploymentMaker оттестирован, я его могу использовать в других тестах для создания тестовых данных. Кстати, в JMock есть возможность делать моки не только для интерфейсов, но и для экземпляров объектов. Для этого в setUp() добавьте setImpostorizer(ClassImpostorizer.INSTANCE). Уверен, что в других мок-библиотеках есть что-то подобное.

Чтобы закончить с этим сервисом, осталось:

Тестирование взаимодействия с базой данных



Как я уже писал выше, мы используем Hibernate, чтобы писать наши объекты в базу и читать их из нее. Одно из правил написания хороших тестов гласит — «Не надо тестировать библиотеки». В данном случае это означает, что мы можем доверять авторам Hibernate в том, что ими уже протестированы все возможные аспекты записи и чтения разнообразных графов объектов. Что нам надо подтвердить с помощью тестов — это правильность наших маппингов. Плюс неплохо написать небольшое количество integration тестов, т.е. запустить DeploymentService.deploy() на реальной базе и убедиться, что никаких проблем нет.

Насколько я знаю, рекоммендуется следующий способ тестирования взаимодействия с базами данных: каждый тестовый метод работает в транзакции, а в конце теста производится rollback. Честно говоря, мне это не нравится. Способ, который мы используем, позволяет тестировать более сложные операции с базой, выполняющие несколько транзакций. Для этого мы используем Hypersonic — SQL-совместимую базу данных, написанную на Java и умеющую работать в памяти. Все наши тесты с базой данных создают контекст Spring, который вместо реального PostgreSQL или MySQL использует Hypersonic. Конкретные детали выходят за рамки этого поста, желающие подробностей — пишите, расскажу.

Создается абстрактный класс как основа для всех наших тестов. На самом деле мы использовали ORMUnit, который тупо пересоздает всю структуру базы данных перед каждым тестом. Если использовать реальную базу данных, можно состариться, пока все тесты пройдут. Но при использовании Hypersonic все происходит очень быстро. Правда-правда!
public class DeploymentRepositoryTest extends HibernatePersistenceTest {

  @Autorwired
  private DeploymentRepository deploymentRepository;

  public void testSaveAndLoad() {
    Deployment deployment = DeploymentMother.makeSimpleDeployment();
    deploymentRepository.add(deployment);
    Deployment loadedDeployment = deploymentRepository.getById(deployment.getId());
    assertDeploymentEquals(deployment, loadedDeployment);
  }

  private void assertDeploymentEquals(Deployment expected, Deployment actual) {
    //тут можно как угодно поступить. Самое простое - написать Deployment.equals(...), который сравнит все поля
    //или использовать EqualsBuilder (кажется, он в Apache Commons-lang). Или просто сравнить id. 
  }
}


Обратите внимание на DeploymentMother. У нас принято такое обозначения для классов-помощников, которые создают сущности. Используются такие сущности для тестов. В нашем DeploymentMother есть такие методы: makeSimpleDeployment(), makeMutliVmDeploymentWithMasterSlaveReplication(), makeFailedDeployment(), makeStartedDeploymentWithFailedVM(), и так далее. В принципе, это реализация одного из вариантов DDD для бедняков. Лично я предпочитаю такой подход чтению данных из YAML или XML по той же причине, по которой я предпочитаю Scala, а не Groovy — проверка типов на этапе компиляции. Если я что-то меняю в моих классах (а при наличии достаточного количества тестов рефактроинг превращается из опасного извращения в приятнейшее занятие), то компилятор мне сразу показывает, какие проблемы возникнут в тестах и на что надо будет обратить внимание.

При работе с Hibernate самое интересное начинается при написании сложных запросов. Мы используем очень полезную библиотеку Arid pojos (того же автора, что и ORMUnit), которая позволяет не писать громадную кучу однотипного кода вызова запросов. Например, чтобы выбрать все деплойменты, которые готовы к запуску, достаточно а) написать запрос с именем findDeploymentsReadyToLaunch в маппинге Hibernate, и определить метод List<Deployment> findDeploymentsReadyToLaunch() в интерфейсе DeploymentRepository. И все, при запуске arid-pojos сгенерирует код, который будет запускать именно этот запрос. Опять же, мы не тестируем библиотеки, поэтому нам достаточно создать тестовые данные и убедиться, что из базы возвращается то, что мы ожидаем. Добавляем в DeploymentRepositoryTest:


  public void testRetrieveReadyToLaunch() {
    for (int i=0; i<5; i++) {
      deploymentRepository.add(DeploymentMother.makeReadyToLaunchDeployment());
    }
    deploymentRepository.add(DeploymentMother.makeNewDeployment());
    List<Deployment> result = deploymentRepository.findDeploymentsReadyToLaunch();
    assertEquals(5, result.size());
    for (Deployment d : result) {
      assertTrue(deployment.isReadyToLaunch());
    }
    //понятно, что неплохо бы сравнить, нужные ли записи выбраны, но это вы сами, ладно?
  }


Небольшое отступление: проблемы в предыдущих примерах


В принципе, приведенные выше примеры тестов прекрасно работают. В чем же проблема? В том, что их не очень легко читать. Мы пытаемся добиться того, что по тестам можно было бы легко восстановить требования к проекту и коду. Что можно сделать, чтобы даже такие простые тесты читать было легче? Использовать intention-revealing method names, т.е. названия методов, которые раскрывают намерения (это уже немного из BDD). Например, назвать тест не testSaveAndLoad, а testSavedDeploymentLoadedCorrectly. Не testRetrieveReadyToLaunch, а testOnlyReadyToLaunchDeploymentsRetrieved.

Далее, assertEquals(5, result.size()) — требует чуточку напрячься, чтобы понять, что программист хотел этим сказать. Вместо этого лучше в вашем TestUtils (у вас же есть TestUtils, правда?!) создать метод assertSize(int expected, Collection collection). Или еще лучше:

В TestUtils:

  public static void T assertCollection(int expectedSize, ElementAsserter<T> asserter, Collection<T> actual) {
    assertSize(expectedSize, actual.size());
    for (T element : actual) {
      asserter.assertElement(element);
    }
  }

  public static abstract class ElementAsserter<T> {

    public void assertElement(T element) {
      if (!checkElement(element)) fail(getFailureDescription(element));
    }

    protected abstract boolean checkElement(T element);

    //переопределите этот метод, чтобы он вам говорил что конкретно не так с объектом
    protected String getFailureDescription(T element) {
      return "Элемент не годится";
    }
  }


И тогда в нашем тесте мы можем сделать так:

  ElementAsserter readyToLaunch = new ElementAsserter<Deployment>() {
    protected boolean checkElement(Deployment d) {
      return d.isReadyToLaunch();
    }

    protected String getFailureDescription(Deployment d) {
      return String.format("Deployment with id %d and name %s is NOT ready to be launched!");
    }
  }
  
  private void assertAllReadyToLaunch(int expectedSize, List<Deployment> deployments) {
    TestUtils.assertCollection(expectedSize, a, deployments);
  }

  public void testOnlyReadyToLaunchDeploymentsRetrieved() {
    for (int i=0; i<5; i++) {
      deploymentRepository.add(DeploymentMother.makeReadyToLaunchDeployment());
    }
    deploymentRepository.add(DeploymentMother.makeNewDeployment());

    assertAllReadyToLaunch(5, deploymentRepository.findDeploymentsReadyToLaunch());
  }


Можно еще спрятать создание пяти нужных объектов в отдельный метод, чтоб совсем понятно стало. Нет пределов совершенству. Зачем все это? А затем, что у программиста не остается отмазок от написания новых тестов. Если все, что от него требуется — это две строчки кода (вызвать уже написанный метод для создания каких-то объектов и определить проверку некоего условия), то тесты становится писать очень легко. И процесс дарит ни с чем не сравнимое удовольствие осознания того, что ваш код можно напрямую запускать вживую — все будет работать.

А дальше?



На сегодня все. Если будет интерес у хабралюдей, через пару дней будет продолжение, в котором я планирую рассказать про (наш) подход к тестированию приложения целиком, общения с внешними сервисами, и отвечу на вопросы. Там же будет больше про «сложную инфраструктуру для простых тестов».
Теги:javaтестирование приложенийjunitspring framework
Хабы: Java
+31
9,3k 109
Комментарии 31

Похожие публикации

Java Developer (Team lead)
от 250 000 ₽Reliable systemsПензаМожно удаленно
JAVA-разработчик
от 200 000 до 350 000 ₽DATS.TEAMМоскваМожно удаленно
Java- разработчик
от 250 000 ₽А3Москва
Java Developer (удаленная работа)
от 200 000 ₽KRITМожно удаленно
Java developer
от 200 000 до 400 000 ₽EmphasoftСанкт-Петербург

Лучшие публикации за сутки