Думаю, почти каждый сталкивался с таким мнением: писать тесты сложно, все примеры написания тестов даны для простейших случаев, а в реальной жизни они не работают. У меня же за последние годы сложилось впечатление, что писать тесты — это очень просто, даже тривиально*. Автор упомянутого выше комментария далее говорит, что неплохо было бы сделать пример сложного приложения и показать, как его тестировать. Попробую именно этим и заняться.
*)Писать сами тесты — действительно элементарно. Создать инфраструктуру, позволяющую легко писать тесты — чуть сложнее.
Я не теоретик, я практик («Я по натуре не Пушкин, я — Белинский» ©). Поэтому я буду использовать некоторые идеи из Test Driven Development, Data Driven Development, Behaviour Driven Development, но не всегда смогу обосновывать свой выбор. В-основном, моя аргументация будет звучать так: «Так легче», или «так удобнее». Прекрасно понимая, что мои рецепты подходят далеко не всем, прошу, во-первых, все равно задуматься над моей аргументацией, и, во-вторых, не рассматривать данный текст как учебник.
Итак, барабанная дробь:
Приложение это существует в реальности, но оно не публично, поэтому реальный код я показать не могу. Область применения — PaaS, платформа-как-сервис. Конкретно — автоматизация запуска клиентских приложений на некой виртуальной инфраструктуре. Работать будет внутри больших предприятий. Архитектура достаточно стандартна: реляционная база данных, сверху Hibernate, потом веб-интерфейс, все управляется Spring framework. Сбоку у нас две приблуды: одна разговаривает с API той самой виртуальной инфраструктуры, а вторая — соединяется с каждной созданной виртуальной машиной через SSH, и нечто там запускает, в результате чего виртуальная машина получает весь необходимый софт и нужные куски клиентского приложения, и эти куски запускает. Если честно, это самое сложное приложение, которое я написал за свою жизнь, думаю, пойдет в качестве примера. Язык — Java, с вкраплениями Scala.
Приложения на Spring-е тестировать очень легко. Одна из причин использования Spring — это то, что с ним легко тестировать. «Кролик — это тот, кто живет в норе. Нора — это то, где живет кролик» ©
Интерфейс:
Имплементация:
В принципе, понятно, что оно делает. Конечно реальный код чуть посложнее, но ненамного. Сначала в лоб тестируем метод DeploymentService.deploy() с использованием мок-библиотеки JMock
Что тут важного и интересного? Во-первых, мок-тесты позволяют нам тестировать изолированные методы без оглядки на то, что эти методы вызывают. Во-вторых, написание тестов очень сильно влияет на структуру кода. Первый вариант метода 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 все происходит очень быстро. Правда-правда!
Обратите внимание на DeploymentMother. У нас принято такое обозначения для классов-помощников, которые создают сущности. Используются такие сущности для тестов. В нашем DeploymentMother есть такие методы: makeSimpleDeployment(), makeMutliVmDeploymentWithMasterSlaveReplication(), makeFailedDeployment(), makeStartedDeploymentWithFailedVM(), и так далее. В принципе, это реализация одного из вариантов DDD для бедняков. Лично я предпочитаю такой подход чтению данных из YAML или XML по той же причине, по которой я предпочитаю Scala, а не Groovy — проверка типов на этапе компиляции. Если я что-то меняю в моих классах (а при наличии достаточного количества тестов рефактроинг превращается из опасного извращения в приятнейшее занятие), то компилятор мне сразу показывает, какие проблемы возникнут в тестах и на что надо будет обратить внимание.
При работе с Hibernate самое интересное начинается при написании сложных запросов. Мы используем очень полезную библиотеку Arid pojos (того же автора, что и ORMUnit), которая позволяет не писать громадную кучу однотипного кода вызова запросов. Например, чтобы выбрать все деплойменты, которые готовы к запуску, достаточно а) написать запрос с именем findDeploymentsReadyToLaunch в маппинге Hibernate, и определить метод List<Deployment> findDeploymentsReadyToLaunch() в интерфейсе DeploymentRepository. И все, при запуске arid-pojos сгенерирует код, который будет запускать именно этот запрос. Опять же, мы не тестируем библиотеки, поэтому нам достаточно создать тестовые данные и убедиться, что из базы возвращается то, что мы ожидаем. Добавляем в DeploymentRepositoryTest:
В принципе, приведенные выше примеры тестов прекрасно работают. В чем же проблема? В том, что их не очень легко читать. Мы пытаемся добиться того, что по тестам можно было бы легко восстановить требования к проекту и коду. Что можно сделать, чтобы даже такие простые тесты читать было легче? Использовать intention-revealing method names, т.е. названия методов, которые раскрывают намерения (это уже немного из BDD). Например, назвать тест не testSaveAndLoad, а testSavedDeploymentLoadedCorrectly. Не testRetrieveReadyToLaunch, а testOnlyReadyToLaunchDeploymentsRetrieved.
Далее, assertEquals(5, result.size()) — требует чуточку напрячься, чтобы понять, что программист хотел этим сказать. Вместо этого лучше в вашем TestUtils (у вас же есть TestUtils, правда?!) создать метод assertSize(int expected, Collection collection). Или еще лучше:
В TestUtils:
И тогда в нашем тесте мы можем сделать так:
Можно еще спрятать создание пяти нужных объектов в отдельный метод, чтоб совсем понятно стало. Нет пределов совершенству. Зачем все это? А затем, что у программиста не остается отмазок от написания новых тестов. Если все, что от него требуется — это две строчки кода (вызвать уже написанный метод для создания каких-то объектов и определить проверку некоего условия), то тесты становится писать очень легко. И процесс дарит ни с чем не сравнимое удовольствие осознания того, что ваш код можно напрямую запускать вживую — все будет работать.
На сегодня все. Если будет интерес у хабралюдей, через пару дней будет продолжение, в котором я планирую рассказать про (наш) подход к тестированию приложения целиком, общения с внешними сервисами, и отвечу на вопросы. Там же будет больше про «сложную инфраструктуру для простых тестов».
*)Писать сами тесты — действительно элементарно. Создать инфраструктуру, позволяющую легко писать тесты — чуть сложнее.
Я не теоретик, я практик («Я по натуре не Пушкин, я — Белинский» ©). Поэтому я буду использовать некоторые идеи из 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());
}
Можно еще спрятать создание пяти нужных объектов в отдельный метод, чтоб совсем понятно стало. Нет пределов совершенству. Зачем все это? А затем, что у программиста не остается отмазок от написания новых тестов. Если все, что от него требуется — это две строчки кода (вызвать уже написанный метод для создания каких-то объектов и определить проверку некоего условия), то тесты становится писать очень легко. И процесс дарит ни с чем не сравнимое удовольствие осознания того, что ваш код можно напрямую запускать вживую — все будет работать.
А дальше?
На сегодня все. Если будет интерес у хабралюдей, через пару дней будет продолжение, в котором я планирую рассказать про (наш) подход к тестированию приложения целиком, общения с внешними сервисами, и отвечу на вопросы. Там же будет больше про «сложную инфраструктуру для простых тестов».
