В этой статье мы рассмотрим ту часть тестирования, которой не касаются специалисты по тестированию — модульные тесты. Почему же при Agile так необходимо иметь качественное покрытие модульными тестами? Раскроем их положение в цикле разработки и цели их создания. Рассмотрим различные варианты оценки качества покрытия тестами при разработке backend приложения на языке Java с использованием Spring-boot. С помощью Jacoco построим отчет и увидим недостатки численных оценок покрытия тестами. Сформулируем субъективные оценки модульного тестирования и советы по их разработке.
Терминология
Говоря о тестах, особенно о unit тестировании в разработке spring-boot приложений, есть расхождения в терминологии, которые возникают из-за того, что все перечисленные ниже виды тестов реализуются с помощью одной библиотеки - Junit, и с помощью одной и той же аннотации из нее @Test. Для Java все эти тесты — unit тесты. Поэтому, чтобы не возникло лишних вопросов, определим используемые в статье термины:
Модульные тесты — тесты приложения, не требующие дополнительного окружения для их запуска. Проверяют только одно приложение - то, в котором, они находятся. Как правило, такие тесты запускаются при каждой сборке приложения и делятся на два вида:
Unit тест — простейший модульный тест, который не взаимодействует с БД, не использует никакого окружения. Чаще всего это лёгкие и быстрые тесты.
Компонентный тест — модульный тест, который использует окружение (spring boot, база данных). Он проверяет интеграцию логического слоя приложения с другими слоями (представления, базы данных). Такие тесты всё ещё не требуют от вас дополнительной настройки окружения и могут запускаться, например, при сборке приложения. При старте такого теста автоматически создаётся окружение, которое уничтожается после его выполнения. В spring-boot приложении такие тесты будут помечены аннотацией @SpringBootTest.
Остальные термины не нуждаются в дополнительном переопределении.
Модульные тесты и их значение
Зачем нужны модульные тесты, если есть команда специалистов по тестированию, которая проводит smoke тесты, регресс тесты, функциональное тестирование, тестирование документации, pen тесты и другие тесты? Этим вопросом часто задаются менеджеры проектов (и не только) при согласовании времени на разработку модульных тестов.
Модульные тесты имеют очень большое значение при Agile разработке. Из-за частых релизов требуется тщательная проверка стабильности приложения.
"Но есть же регресс тестирование, которое тоже может быть автоматическим. Есть автоматическое тестирование UI, которое проверяет взаимодействие UI с backend. Существуют сквозные тесты!" — скажет вам менеджер проекта. Для программиста перечисленные тесты, конечно, имеют вес, но есть причины, по которым модульные тесты важнее:
Запуск вышеперечисленных тестов невозможен на локальной машине программиста. Для их работы необходим рабочий стенд.
Модульные тесты запускаются каждую сборку приложения, в отличие от остальных тестов.
Программист может не знать сценарии НЕ модульных тестов. Команда программистов, как правило, контролирует только модульные тесты. Соответственно, программист может быть не в состоянии проанализировать результат прохождения таких тестов и использовать их для проверки собственного кода.
Скорость выполнения НЕ модульных тестов, как правило, не позволяет ими пользоваться так часто, как хотелось бы.
Результат выполнения НЕ модульных тестов зависит не только от корректной работы твоего сервиса, но и от корректной работы других приложений, что затрудняет анализ результатов таких тестов.
Модульные тесты — это оплот стабильности приложения. Они используются, как инструмент разработки в целях проверки только что написанного кода. Программист изменил код и добавил (или изменил) тестовые сценарии для своих изменений, запустил тесты, убедился, что зафиксированные сценарии в модульных тестах корректно отработали. Модульные тесты хранятся вместе с приложением, являются частью проекта-приложения и выполняются при сборке этого приложения. Это быстрая проверка, которая позволяет не отправить дальше по процессу нерабочий код. Модульные тесты позволяют зафиксировать особенности работы компонентов приложения в самом коде приложения. Фактически, это своего рода документация для программиста, написанная в его терминах и его языком.
Если при разработке функционала перестали работать модульные тесты, падение которых не ожидалось, то стабильность программного продукта нарушена. Возможны даже ошибки документации. В этом случае требуется глубокий анализ кода программистом. Если программист выявил неточности требований, которые привели к некорректной работе тестов, то требуется ещё и анализ документации со стороны аналитиков. Модульные тесты — это наиболее часто запускаемые тесты. От их качества зависит стабильность работы кода, который отдают программисты команде тестирования.
Если модульное тестирование так важно, то нужно уметь оценивать его качество. Как и всегда - есть субъективные (неизмеряемые численно) характеристики и объективные оценки качества. Для объективной оценки существуют анализаторы кода Java, такие как Jacoco, Cobertura и другие. Мы будем рассматривать один из самых распространенных инструментов Jacoco. К сожалению, показателей анализатора кода недостаточно для проверки качества модульных тестов. Поэтому необходимы субъективные характеристики качества тестирования.
Субъективные характеристики качества модульного тестирования
Перечислим основные характеристики качества модульного тестирования:
Устойчивость к рефакторингу. Тесты должны быть устойчивы к рефакторингу кода. Если производится рефакторинг, тесты не должны изменяться. Тогда успешное завершения всех модульных тестов после этапа рефакторинга поможет вам убедиться, что рефакторинг не испортил функционал приложения.
Простота поддержки. Тесты должны быть просты в поддержке. Этот пункт выполняется, если выполняется первый пункт. Должно быть легко добавлять новые тестовые сценарии - поддержка тестов в актуальном состоянии не должна стать тяжелым грузом для команды разработки.
Защита от дефектов приложения. Тесты должны отражать реальное положение дел— если тест поломан, то часть функционала не работает. И наоборот — если часть функционала сломалась в ходе разработки, то должны сломаться и тесты.
Все три характеристики применимы к любым тестам, не только к модульным. Эти характеристики невозможно измерить анализаторами кода — их оценивает программист сам или его коллеги вовремя code review. Если первая и вторая характеристики достаточно субъективны (оценить можно исключительно “на глаз”), то третья - защита от дефектов - очень четкое и понятное правило.
Как говорилось ранее, модульные тесты разделяются на два вида:
Unit тесты.
Компонентные тесты.
Оценим и тот и другой вариант с помощью вышеуказанных характеристик.
Провокация: пишите компонентные тесты, а не unit тесты
Заголовок выше противоречит основной мысли из многих современных книг и учебников. Компонентные тесты гораздо дольше выполняются и при этом не показывают четкое место поломки кода. Несмотря на это, мы говорим о них как об “оплоте стабильности”. Дело в том, что для качественного покрытия unit тестами требуется гораздо больше времени, чем для написания хорошего компонентного теста. Хорошее покрытие unit тестами не гарантирует вам правильность взаимодействия классов между собой. И это крайне дорогое удовольствие. На их разработку и поддержку требуется очень много времени. В реальном проекте программисту, как правило, не выделяют время на написание unit тестов. Получается, что если на проекте выбрана именно политика unit тестов, то эти тесты не отражают реальные сценарии использования приложения - проверяется “сферический конь в вакууме”, причем не во всех возможных состояниях системы. В итоге такая политика разработки тестов рано или поздно приводит к тому, что тесты перестают защищать от дефектов приложения.
Рассмотрим пример. Классы логического слоя, которые последовательно обращаются к другим классам — это распространенное явление в Enterprise разработке.
@Service
@RequiredArgsConstructor
public class CService {
private final AService aService;
private final BService bService;
public C doSmth() {
A a = aService.doSmth();
B b = bService.doSmth(a);
return getCObjectFromBandA(a, b);
}
private C getCObjectFromBandA(A a, B b) {
return new C();
}
}
@Service
public class BService {
public B doSmth(A a) throws Exception {
return creatBFromA(a);
}
private B creatBFromA(A a) throws Exception {
return switch (a.getAnInt()) {
case 1: yield new B(false, B.Enum.One);
case 2: yield new B(true, B.Enum.Two);
case 3: throw new RuntimeException();
default: throw new Exception();
};
}
}
public class CServiceTest {
AService mockA = mock(AService.class);
BService mockB = mock(BService.class);
private final CService service = new CService(mockA, mockB);
@Test
public void testBFalseOne() throws Exception {
A a = A.builder().anInt(3).build();
B b = new B(false, B.Enum.One);
when(mockA.doSmth()).thenReturn(a);
when(mockB.doSmth(any())).thenReturn(b);
C c = service.doSmth();
assertNotNull(c);
assertEquals(1, c.getNum());
}
}
В данном примере наглядно изображено накопление ошибки при "книжном" подходе к unit тестированию. Рассмотрим unit тест для класса C: это обычный unit тест (не использует контекст). В примере используются заглушки для классов AService и BService. В функции BService.doSmth есть дефект, если А.getAnInt возвращает 3. В примере имитируется именно такое состояние системы. Тест проходит, несмотря на очевидную ошибку в коде. Но вам приходит от менеджера проекта дефект именно на это состояние. Вы смотрите на тест - он проходит. В итоге тест не отражает реальное состояние системы. Конечно, это некачественное покрытие тестами класса BService. Но при таком подходе добиться отражения реального поведения системы в unit тестах крайне сложно. Расхождения реального поведения и unit тестов будут накапливаться как снежный ком. В условиях ограниченного времени лучше отдать предпочтение компонентным тестам. Если бы в тесте класса C использовались не заглушки, а реальные реализации, то тест бы упал, и дефект был бы устранен еще на этапе разработки.
В случае использования компонентных тестов модульные тесты приобретают законченный вид: по ним можно прочитать пользовательские сценарии использования вашего приложения. Отдельно хочется выделить тесты контроллеров. Рассмотрим пример такого теста (чтение json и файлов ресурсов реализовано в классе TestUtil). В сценарии getEmployeeByIdExceptionTest имитируется сценарий, при котором не найдена сущность по id. В этом случае программа должна отдать exception NotFoundRecordException и http статус 404. Данный тест позволяет проверить не только логический слой приложения, но еще и слой обработки ошибок. Сценарий createEmployeeSuccessTest использует интерфейс, который принимает и отдает определенные данные. Соответственно, в этом сценарии будут проверятся слой валидации данных и маппинг.
@AutoConfigureMockMvc
@Sql(scripts = "/data/insert_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, config = @SqlConfig(encoding = "utf-8"))
@Sql(scripts = "/data/clear_data.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, config = @SqlConfig(encoding = "utf-8"))
@SpringBootTest(classes = {Application.class})
@ActiveProfiles("test")
public class EmployeeControllerTest {
private static final String BASE_REQUEST = "json/request/";
private static final String BASE_RESPONSE = "json/response/";
private static final String REQUEST_CREATE_SUCCESS = BASE_REQUEST + "employee_create_success.json";
private static final String RESPONSE_CREATE_SUCCESS = BASE_RESPONSE + "employee_create_success.json";
@Autowired
private MockMvc mockMvc;
@Test
void getEmployeeByIdExceptionTest() throws Exception {
final String expected = "В таблице: employee не найдена запись с идентификатором: 3";
this.mockMvc
.perform(get("/employees/{id}", 3L))
.andDo(print())
.andExpect(status().isNotFound())
.andExpect(result -> Assertions.assertTrue(result.getResolvedException() instanceof NotFoundRecordException))
.andExpect(content().string(expected));
}
@Test
void createEmployeeSuccessTest() throws Exception {
final byte[] requestBytes = TestUtil.readResource(REQUEST_CREATE_SUCCESS).readAllBytes();
InputStream inputStream = TestUtil.readResource(RESPONSE_CREATE_SUCCESS);
final String expected = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
this.mockMvc
.perform(post("/employees")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBytes)
)
.andDo(print())
.andExpect(status().isOk())
.andExpect(content().encoding(StandardCharsets.UTF_8))
.andExpect(json().ignoring("##IGNORE##").isEqualTo(expected));
}
}
Тесты контроллеров работают по принципу “черного ящика”, основаны на спецификации API и не затрагивают при этом внутреннюю структуру приложения. Спецификация API должна меняться редко (речь идёт не про добавление новых интерфейсов, а про изменение старых), следовательно, такие тесты устойчивы к рефакторингу. Если при изменении протоколов вы поддерживаете обратную совместимость, то тесты контроллеров помогут вам её проверить. Тесты контроллеров охватывают значительную часть кода. Следовательно, вероятность обнаружить дефект с помощью таких тестов больше, чем у других модульных тестов.
Не всегда удобно все возможные случаи входных данных перечислять в тестах контроллера, тогда можно перейти к более удобному формату — тестирование сервисов, но всё еще в рамках компонентных тестов.
Компонентные тесты проверяют не только функционал вашего приложения, но и другие аспекты. В компонентных тестах происходит полноценная имитация работы вашего приложения — поднимается Data Source, web container, Spring Context. Такими тестами вы одновременно контролируете:
Валидность liquibase скриптов для работы с БД.
Старт вашего приложения - поднимается spring context или нет.
Сценарии использования ваших api в тестах контроллеров.
Естественно, не стоит отказываться и от классических unit тестов — они применимы для тестирования сложного или обособленного функционала - например, статическая функция для форматирования телефонного номера. Когда реализуется сложный обособленный алгоритм, не требующий базы данных, который требует тщательного тестирования, лучше использовать unit тест. Или же код выполняет критичные для приложения функции, но при этом он максимально обособлен от окружения: класс получает данные из БД, вариантов этих данных очень много, происходит сложная обработка этих данных.
Объективные показатели покрытия тестами. Почему их недостаточно?
Перейдем к показателям покрытия тестами, которые могут посчитать за нас разнообразные анализаторы кода.
Один из распространенных инструментов для анализа качества модульного тестирования — это Jacoco. Результат отчета можно визуализировать различными способами (Gitlab, SonarQube). Мы же будем рассматривать оригинальный отчет Jacoco.
Давайте рассмотрим отчет Jacoco внимательнее.
В отчете есть несколько показателей, которые нас интересуют в первую очередь (в порядке приоритета):
Missed branches - показывает отношение неиспользованных веток кода при прохождении тестов к использованным. Ветками не считаются блоки try-catch. Cov.— процент использованных веток кода.
Missed Instructions - показывает отношение использованных строк кода к неиспользованным в процессе тестирования. Cov. - процент использованных строк.
Cyclomatic complexity(Cxty) - минимальное количество путей для прохождения по всему коду метода/класса. Для нас - это минимальное количество сценариев для класса/метода.
Полные описания вы можете прочитать в документации Jacoco https://www.eclemma.org/jacoco/trunk/doc/counters.html.
На примере рассмотрим недостатки объективных показателей, которые считает Jacoco.
Класс, который нужно покрыть тестами.
@Service
@RequiredArgsConstructor
public class CService {
private final AService aService;
private final BService bService;
public C doSmth() throws CException {
A a = aService.doSmth();
B b;
try {
b = bService.doSmth(a);
} catch (Exception e) {
throw new CException();
}
return getCObjectFromBandA(b);
}
private C getCObjectFromBandA(B b) {
if(b.isSmth() || b.getType() == null) {
return null;
}
return switch (b.getType()) {
case One: yield new C(1);
case Two: yield new C(2);
};
}
}
Для понимания кода CService еще необходимо предоставить код класса B. Остальное для нас на данный момент не важно.
@Setter
@Getter
@AllArgsConstructor
public class B {
public enum Enum {
One, Two
}
private boolean smth;
private Enum type;
public boolean isSmth() {
return smth;
}
}
Тесты класса CService. Тесты покрывают все случаи.
public class CServiceTest {
AService mockA = mock(AService.class);
BService mockB = mock(BService.class);
private final CService service = new CService(mockA, mockB);
@Test
public void testBFalseOne() throws Exception {
B b = new B(false, B.Enum.One);
when(mockB.doSmth(any())).thenReturn(b);
C c = service.doSmth();
assertNotNull(c);
assertEquals(1, c.getNum());
}
@Test
public void testBFalseTwo() throws Exception {
B b = new B(false, B.Enum.Two);
when(mockB.doSmth(any())).thenReturn(b);
C c = service.doSmth();
assertNotNull(c);
assertEquals(2, c.getNum());
}
@Test
public void testBFalseNull() throws Exception {
B b = new B(false, null);
when(mockB.doSmth(any())).thenReturn(b);
C c = service.doSmth();
assertNull(c);
}
@Test
public void testCException() throws Exception {
when(mockB.doSmth(any())).thenThrow(Exception.class);
assertThrowsExactly(CException.class, service::doSmth);
}
@Test
public void testBTrue() throws Exception {
B b = new B(true, B.Enum.One);
when(mockB.doSmth(any())).thenReturn(b);
C c = service.doSmth();
assertNull(c);
}
}
Рассмотрим отчет Jacoco.
Как видим из отчета, покрытие кода у функции getCObjectFromBandA не 100%, а 85%. Давайте разберемся, почему. Для этого посмотрим на код CService вместе с отчетом о покрытии:
Здесь мы видим следующую картину: строки case покрыты тестами, а switch нет. Желтый ромб говорит нам о том, что при прохождении тестов пропущена ветка.
В тестах мы видим все варианты значений b.getType: null, One, Two. Но Jacoco считает, что ветка потеряна. Это происходит из-за того, что Jacoco оценивает покрытие тестами по Byte-code. Чтобы добиться от Jacoco 100% “покрытия” необходимо добавить еще одно значение в B.Enum (допустим, Default), дописать default в switch и добавить тест, где b.getType вернет Default. С точки зрения разработчика это “костыль” для того, чтобы добиться 100% покрытия кода тестами.
На этом же примере рассмотрим еще один недостаток Jacoco: он не считает try/catch ветками. Давайте уберем тест testCException и посмотрим после этого отчет Jacoco:
В отчете видим, что процент покрытия функции doSmth упал до 73%. При этом Missed Branches не изменился — jacoco/catch не рассматривает как ветки кода. Давайте посмотрим на класс CService вместе с отчетом Jacoco:
Появились красные строки кода — строки, которые не использовались во время этапа модульного тестирования.
Безусловно показатели Jacoco надо учитывать при оценки качества модульного тестирования. Но не стоит пытаться достигнуть 100% ни по одному из показателей. Как вы увидели из примеров, для этого иногда приходится писать код, который вы никогда не будете использовать в production режиме.
Давайте рассмотрим компонентный тест под призмой Jacoco и ответим на вопрос: “Почему же при их использовании не надо ориентироваться только на числовые значения в отчете?”.
Sql скрипты для создания таблиц:
create table data (
id serial constraint data_pk primary key,
data varchar(200) not null
);
create table lazy_data (
id serial constraint lazy_data_pk primary key,
data varchar(200) not null,
data_id bigint not null constraint data_lazy_fk references data
);
Соответствующие Hibernate entity:
@Entity
public class Data {
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Id
@Column
private Long id;
@Column
@Setter
private String data;
@Column
@OneToMany(mappedBy= "dataLink", cascade = CascadeType.PERSIST, fetch = FetchType.LAZY)
@OrderBy("id")
@Getter
private List<LazyData> lazyDataList;
}
@Entity
public class LazyData {
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Id
@Column
private Long id;
@Column
@Getter
private String data;
@ManyToOne
@JoinColumn(name="data_id", nullable=false)
private Data dataLink;
}
Тесты:
@SpringBootTest(classes = {Application.class})
@ActiveProfiles("test")
@Sql(value = "/data/insert_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(value = "/data/remove_data.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
@Testcontainers
public class ServiceWithDbTest {
@Container
private static PostgreSQLContainer postgresqlContainer = new PostgreSQLContainer()
.withDatabaseName("foo")
.withUsername("foo")
.withPassword("secret");
@DynamicPropertySource
static void dataSourceProperty(DynamicPropertyRegistry registry) {
postgresqlContainer.start();
registry.add("spring.datasource.url", postgresqlContainer::getJdbcUrl);
registry.add("spring.datasource.username", postgresqlContainer::getUsername);
registry.add("spring.datasource.password", postgresqlContainer::getPassword);
}
@Autowired
private ServiceWithDbForTest service;
@Autowired
private ServiceWithDbLazy lazyService;
@Test
@Transactional
public void testTransactional() {
service.updateData(1L, null);
}
@Test
@Transactional
public void testLazy() {
List<String> strings = lazyService.doSmthWithLazy(1L);
assertArrayEquals(new String[] {"1", "2", "3", "4"}, strings.toArray());
}
}
/data/insert_data.sql
ALTER SEQUENCE data_id_seq RESTART WITH 1;
ALTER SEQUENCE lazy_data_id_seq RESTART WITH 1;
insert into data (data) VALUES ('not_null_data');
insert into lazy_data (data, data_id) VALUES ('1', 1);
insert into lazy_data (data, data_id) VALUES ('2', 1);
insert into lazy_data (data, data_id) VALUES ('3', 1);
insert into lazy_data (data, data_id) VALUES ('4', 1);
/data/remove_data.sql
delete from lazy_data;
delete from data;
Тестируемый класс вместе с отчетом Jacoco:
Из отчета видно, что мы не задействовали одну строку (помечена красным) — случай, когда данные по id не найдены. Действительно, если посмотреть на /data/insert_data.sql, то мы видим, что data с id = 1 всегда будет в БД.
Смущает другое — все остальные строки кода покрыты тестами, но это видимая иллюзия. Тесты, представленные в примере, пройдут, хотя по факту заявленные функции в классе ServiceWithDbForTest работать не будут. Всё из-за коварной аннотации @Transactional. В первом случае в реальной эксплуатации будет вызвана ошибка ConstraintViolationException - поле data помечено как not null в sql скриптах. Во втором случае в реальной эксплуатации будет вызвана ошибка LazyInitializationException. Если убрать @Transactional, то тесты станут показывать реальное поведение - они упадут.
Из приведенных выше примеров можно понять, что для оценки качества модульного тестирования недостаточно смотреть только на численные показатели, полученные с помощью анализатора кода Jacoco. Используйте числовые показатели в отчетах для анализа качества модульных тестов, но не стремитесь к идеалу - могут быть отклонения в расчетах. На этапе code review стоит оценивать код не только самого приложения, но и тестов.
Советы для оформления модульных тестов
Сформулируем некоторые советы для разработки модульных тестов, на которые можно опираться во время разработки и проведении code review:
Первый и самый важный совет — пишите модульные тесты.
Не стоит писать специальный код в функциональной части приложения(src/main), который используется только в тестах. Если вам требуется специальный код для реализации тестов, то это сигнализирует о:
А)Несовершенстве структуры классов и методов, используемых в тестах.
Б)Неправильности тестового сценария.Не используйте аннотацию @Transactional. Из указанного в статье примере, видно, что вы тем самым можете скрыть ошибки работы с БД.
Избегайте указания порядка выполнения тестов. Тест не должен влиять на выполнение другого теста. Для изоляции тестов друг от друга используйте аннотацию @Sql.
Используйте в модульных тестах ту же версию базу данных (например, PostgreSQL 14.6), что и в реальной эксплуатации. В противном случае вы усложняете себе жизнь анализом разного поведения тестовой БД и production БД.
Используйте один spring-context для всех компонентных тестов. В этом случаем приложение будет стартовать один раз,, что сильно ускоряет процесс тестирования.
Не смешивайте assert’ы c функциональной частью теста — например, с инициализацией данных. Это сильно усложняет анализ результата прохода по тестам.
Не тестируйте приватные методы - вы их протестируете с помощью тестов для публичных методов.
И последний и основной совет — думайте, когда пишете тесты. Анализаторы кода проверяют не всё, и на примерах мы убедились в этом. Объективные оценки, такие как покрытие кода или веток кода, не в полной мере отражает качество ваших тестов. Для достижения главной цели - защиты от дефектов приложения, необходимо оценивать сами тесты - их код и сценарии.
Автор статьи: @SashaVolushkova
Александра Волушкова
Java - разработчик