Pull to refresh

Принцип слоеного теста

Reading time12 min
Views5.6K
Всем неустрашимым на пути от отрицания до убеждения посвящается…

image

Среди разработчиков бытует справедливое мнение, что если программист не покрывает код тестами, то попросту не понимает зачем они нужны и как их готовить. С этим трудно не согласиться, когда уже понимаешь о чем речь. Но как достичь этого драгоценного понимания?

Не судьба...


Так получается, что зачастую самые очевидные вещи не имеют внятного описания среди тонн полезной информации в глобальной сети. Этакий очередной соискатель решает разобраться с актуальным вопросом “что такое unit тесты” и натыкается на уйму подобных примеров, которые словно калька копируются из статьи в статью:

“у нас есть метод, который подсчитывает сумму чисел”

public Integer sum(Integer a, Integer b) {
return a+b
}

“на данный метод можно написать тест”

Test
public void testGoodOne() {
assertThat(sum(2,2), is(4));
}

Это не шутка, это упрощенный пример из типичной статьи про технологию unit тестирования, где в начале и конце — общие фразы про пользу и необходимость, а в середине такое…

Увидев такое, и перечитав для верности дважды, соискатель восклицает: “Что за лютый бред?..” Ведь, у него в коде практически нет методов, которые все необходимое получают через аргументы, а затем отдают однозначный результат по ним. Это типичные утилитарные методы, и они практически не меняются. А как быть со сложными процедурами, с внедренными зависимостями, с методами без возврата значений? Там это подход не применим от слова “совсем”.

Если на этом этапе упорный соискатель не машет рукой и погружается дальше, то вскоре обнаруживает, что для зависимостей используются МОКи, для методов которых определяется некоторое условное поведение, фактически заглушка. Тут у соискателя может снести крышу окончательно, если рядом не найдется доброго и терпеливого мидла/сеньора готового и умеющего все разъяснить… Иначе, соискатель истины совершенно теряет смысл того, “что такое unit тесты”, поскольку большая часть тестируемого метода оказывается некоей мок-фикцией, и что в таком случае тестируется — непонятно. Тем более непонятно, как это организовать для большого, многослойного приложения и зачем такое нужно. Таким образом вопрос в лучшем случае откладывается до лучших времен, в худшем — прячется в ящик прОклятых вещей.

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

Ключевая миссия


Для начала, предлагаю сформулировать в двух словах ключевую функцию(миссию) unit тестов и ключевой выигрыш. Тут возможны разные живописные варианты, но предлагаю рассмотреть этот:

Ключевая функция unit тестов — зафиксировать ожидаемое поведение системы.

и этот:

Ключевой выигрыш unit тестов — возможность “прогнать” весь функционал приложения за считанные секунды.

Рекомендую запомнить это для собеседований и немножко поясню. Любой функционал подразумевает правила использования и результаты. Эти требования приходят от бизнеса, через системную аналитику и реализуются в коде. Но код постоянно развивается, приходят новые требования и доработки, которые могут незаметно и неожиданно изменить что-то в готовом функционале. Именно тут на страже стоят unit тесты, которые фиксируют утвержденные правила, по которым должна работать система! В тестах фиксируется сценарий, который важен для бизнеса, и если после очередной доработки тест падает, значит, что-то упущено: либо ошибся разработчик или аналитик, либо новые требования противоречат существующим и следует их уточнять и т.д. Самое главное — “сюрприз” не проскочил.

Простой, стандартный unit тест позволил на самых ранних этапах обнаружить неожиданное, и вероятно нежелательное поведение системы. Между тем, система растет и ширится, вероятность упущения ее деталей тоже растет, и лишь сценарии unit тестов способны все помнить и вовремя предотвращать незаметные уходы в сторону. Это очень удобно и надежно, а главное удобство — быстрота. Приложение не нужно даже запускать и бродить по сотням его полей, форм или кнопок, требуется запустить выполнение тестов и за считанные секунды получить либо полную готовность либо баг.

Итак запомним: зафиксировать ожидаемое поведение в виде сценариев unit тестов, и моментально “прогнать” приложение без его запуска. Эта та безусловная ценность, которую позволяют достичь unit тесты.

Но, черт возьми, как?


Перейдем к самому интересному. Современные приложения активно избавляются от монолитности. Микросервисы, модули, “слои” — основные принципы организации рабочего кода, позволяющие достигать независимости, удобства повторного использования, обмена и переноса в системы и т.д. В нашей теме имеет ключевое значение именно слоистая структура и внедрение зависимостей.

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

Как не напрашивается это вкусное сравнение, приложение нельзя сравнить со слоеным тортом по той причине, что слои эти внедряются друг в друга, как зависимости:

  • контроллер внедряет в себя сервис/ы, к которым обращается за результатом
  • сервис внедряет в себя репозитории (DAO), может внедрять утилитарные компоненты
  • фасад предназначен для комбинирования работы множества сервисов или компоненты, соответственно — внедряет в себя их

Основная идея покрытия тестами всего этого добра в целом приложении: покрытие каждого слоя независимо от остальных слоев. Отсылка к независимости и прочим фишкам “антимонолитности”. Т.е. если в тестируемый сервис внедрен репозиторий — этот “гость” мокается в рамках тестирования сервиса, но персонально тестируется по честному уже в рамках теста репозитория. Таким образом, создаются тесты для каждого элемента каждого слоя, никто не забыт — все при делах.

Принцип слоеного теста


Перейдем к примерам, простое приложение на Java Spring Boot, код будет элементарный, так что суть легко понятна и аналогично применима для других современных языков/фреймворков. Задача у приложения будет простая — умножить число на 3, т.е. утроить (англ. triple), но при этом мы создадим многослойное приложение с внедрением зависимостей (dependency injection) и послойным покрытием с головы до пят.

image

В структуре созданы пакеты для трех слоев: controller, service, repo. Структура тестов аналогична.
Работать приложение будет так:

  1. с фронт-энда на контроллер приходит GET запрос с идентификатором числа, которое требуется утроить.
  2. контроллер запрашивает результат у своей зависимости — сервиса
  3. сервис запрашивает данные у своей зависимости — репозитория, умножает и возвращает результат контроллеру
  4. контроллер дополняет результат и возвращает на фронт-энд

Начнем с контроллера:

@RestController
@RequiredArgsConstructor
public class SomeController {
   private final SomeService someService; // dependency injection

   static final String RESP_PREFIX = "Результат: ";

   static final String PATH_GET_TRIPLE = "/triple/{numberId}";

   @GetMapping(path = PATH_GET_TRIPLE) // mapping method to GET with url=path
   public ResponseEntity<String> triple(@PathVariable(name = "numberId") int numberId) {
       int res = someService.tripleMethod(numberId);   // dependency call
       String resp = RESP_PREFIX + res;                // own logic
       return ResponseEntity.ok().body(resp);
   }
}

Типичный рест контроллер, имеет внедрение зависимости someService. Метод triple настроен на GET запрос по URL "/triple/{numberId}", где в переменной пути передается идентификатор числа. Сам метод можно разделить на две основные составляющие:

  • обращение к зависимости — запрос данных извне, либо вызов процедуры без результата
  • собственная логика — работа с имеющимися данными

Рассмотрим сервис:

@Service
@RequiredArgsConstructor
public class SomeService {
   private final SomeRepository someRepository; // dependency injection

   public int tripleMethod(int numberId) {
       Integer fromDB = someRepository.findOne(numberId);  // dependency call
       int res = fromDB * 3;                               // own logic
       return res;
   }
}

Тут подобная ситуация: внедрение зависимости someRepository, а метод состоит из обращения к зависимости и собственной логики.

Наконец — репозиторий, для простоты выполнен без базы данных:

@Repository
public class SomeRepository {
   public Integer findOne(Integer id){
       return id;
   }
}

Условный метод findOne якобы ищет в базе данных значение по идентификатору, но попросту возвращает тот же integer. На суть нашего примера это не влияет.

Если запустить наше приложение, то по настроенному url можно увидеть:



Работает! Многослойно! В прод…

Ах да, тесты…

Немного о сути. Написание тестов — тоже процесс творческий! Поэтому совершенно не уместна отговорка “я разработчик, а не тестер”. Хороший тест, как и хороший функционал требует изобретательности и красоты. Но прежде всего, необходимо определить элементарную структуру теста.

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

@Test
    void someMethod_test() {
        // prepare...

        int res = someService.someMethod(); 
        
        // check...
    }

Этот вызов могут окружать Подготовка и Проверка. Подготовка данных, в том числе входящих аргументов и описание поведения моков. Проверка результатов — обычно сравнение с ожидаемым значением, помните про фиксацию ожидаемого поведения? Итого, тест — это сценарий, который моделирует ситуацию и фиксирует, что она прошла ожидаемо и вернула ожидаемые результаты.

На примере контроллера попробуем подробно изобразить базовый алгоритм написания теста. Прежде всего, целевой метод контроллера принимает параметр int numberId, добавим его в наш сценарий:

int numberId = 42; // input path variable

Этот же numberId транзитом передается на вход методу сервиса, и тут самое время обеспечить сервис-мок:

@MockBean
private SomeService someService;

Собственный код метода контроллера работает с результатом полученным от сервиса, имитируем этот результат, а также вызов который его возвращает:


int serviceRes = numberId*3; // result from mock someService
// prepare someService.tripleMethod behavior
when(someService.tripleMethod(eq(numberId))).thenReturn(serviceRes);

Эта запись означает: «когда будет вызван someService.tripleMethod с аргументом равным numberId, вернуть значение serviceRes».

Кроме того, эта запись фиксирует факт, что данный метод сервиса должен быть вызван, что важный момент. Бывает что требуется зафиксировать вызов процедуры без результата, тогда используется иная запись, условно такая — «не делать ничего когда...»:


Mockito.doNothing().when(someService).someMethod(eq(someParam));

Повторюсь, здесь лишь имитация работы someService, честное тестирование с детальной фиксацией поведения someService будет реализовано отдельно. Более того, тут даже не важно, чтобы значение именно утроилось, если мы напишем


int serviceRes = numberId*5; 

это не сломает текущий сценарий, т.к. тут фиксируется не поведение someService, а поведение контроллера, принимающего результат someService как должное. Это совершенно логично, ведь целевой класс не может отвечать за поведение внедряемой зависимости, а вынужден ей доверять.

Итак мы определили поведение мока в нашем сценарии, следовательно при выполнении теста, когда внутри вызова целевого метода дело дойдет до мока, он вернет что попросили — serviceRes, и дальше с этим значением будет работать собственный код контроллера.

Далее помещаем в сценарий вызов целевого метода. Метод контроллера имеет особенность — он не вызывается в коде явно, а привязан через HTTP метод GET и URL, поэтому в тестах вызывается через специальный тестовый клиент. В Spring это MockMvc, в других фреймворках есть аналоги, например WebTestCase.createClient в Symfony. Итак, далее просто выполнение метода контроллера через маппинг по GET и URL.


       //// mockMvc.perform
       MockHttpServletRequestBuilder requestConfig = MockMvcRequestBuilders.get(SomeController.PATH_GET_TRIPLE, numberId);

       MvcResult mvcResult = mockMvc.perform(requestConfig)
           .andExpect(status().isOk())
           //.andDo(MockMvcResultHandlers.print())
           .andReturn()
       ;//// mockMvc.perform

Там же заодно проверяется, что такой маппинг вообще существует. Если вызов удачен — доходит дело до проверки-фиксации результатов. Например можно зафиксировать сколько раз был вызван метод мока:


// check of calling
Mockito.verify(someService, Mockito.atLeastOnce()).tripleMethod(eq(numberId));

В нашем случае это избыточно, т.к. его единственный вызов мы уже зафиксировали через when, но иногда это способ уместен.

А теперь главное — мы проверяем поведение собственного кода контроллера:


// check of result
assertEquals(SomeController.RESP_PREFIX+serviceRes, mvcResult.getResponse().getContentAsString());

Тут мы зафиксировали то, за что отвечает сам метод — что результат полученный от someService конкатенируется с префиксом контроллера, и именно эта строка уходит в тело response. Кстати, воочию в содержимом Body можно убедиться, если раскомментировать строку


//.andDo(MockMvcResultHandlers.print())

но обычно эта печать в консоль используется лишь как вспомогательная при отладке.

Таким образом у нас получился тестовый метод в тестовом классе контроллера:


@WebMvcTest(SomeController.class)
class SomeControllerTest {
   @MockBean
   private SomeService someService;

   @Autowired
   private MockMvc mockMvc;

   @Test
   void triple() throws Exception {
       int numberId = 42; // input path variable
       int serviceRes = numberId*3; // result from mock someService
       // prepare someService.tripleMethod behavior
       when(someService.tripleMethod(eq(numberId))).thenReturn(serviceRes);

       //// mockMvc.perform
       MockHttpServletRequestBuilder requestConfig = MockMvcRequestBuilders.get(SomeController.PATH_GET_TRIPLE, numberId);

       MvcResult mvcResult = mockMvc.perform(requestConfig)
           .andExpect(status().isOk())
           //.andDo(MockMvcResultHandlers.print())
           .andReturn()
       ;//// mockMvc.perform

       // check of calling
       Mockito.verify(someService, Mockito.atLeastOnce()).tripleMethod(eq(numberId));
       // check of result
       assertEquals(SomeController.RESP_PREFIX+serviceRes, mvcResult.getResponse().getContentAsString());
   }
}

Теперь настало время честного теста метода someService.tripleMethod, где аналогично есть вызов зависимости и собственный код. Готовим произвольный входящий аргумент и имитируем поведение зависимости someRepository:


int numberId = 42;
when(someRepository.findOne(eq(numberId))).then(AdditionalAnswers.returnsFirstArg());

Перевод: «когда будет вызван someRepository.findOne с аргументом равным numberId, вернуть тот же аргумент». Аналогичная ситуация — тут мы не проверяем логику зависимости, а верим ей на слово. Мы лишь фиксируем вызов зависимости в пределах данного метода. Принципиальна тут собственная логика сервиса, его зона ответственности:


assertEquals(numberId*3, res);

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


@ExtendWith(MockitoExtension.class)
class SomeServiceTest {
   @Mock
   private SomeRepository someRepository; // то, что мокируем

   @InjectMocks
   private SomeService someService; // куда внедряем то, что мокируем

   @Test
   void tripleMethod() {
       int numberId = 42;
       when(someRepository.findOne(eq(numberId))).then(AdditionalAnswers.returnsFirstArg());

       int res = someService.tripleMethod(numberId);

       assertEquals(numberId*3, res);
   }
}

Поскольку репозиторий у нас условно-игрушечный, то и тест получился соответствующий:


class SomeRepositoryTest {
   // no dependency injection
   private final SomeRepository someRepository = new SomeRepository();

   @Test
   void findOne() {
       int id = 777;
       Integer fromDB = someRepository.findOne(id);
       assertEquals(id, fromDB);
   }
}

Однако и тут весь скелет на месте: подготовка, вызов и проверка. Таким образом корректная работа someRepository.findOne зафиксирована.

Реальный репозиторий требует тестирования с поднятием базы данных в памяти или в тест-контейнере, миграции структуры и данных, иногда вставки тестовых записей. Зачастую это самый длительный слой тестирования, но не менее важный, т.к. фиксируется успешная миграция, сохранение моделей, корректная выборка и т.д. Организация тестирования базы данных выходит за рамки данной статьи, но как раз она подробно описана в мануалах. Внедрения зависимостей в репозитории нет и не нужно, его задача — работа с базой данных. В нашем случае, это был бы тест с предварительным сохранением записи в базу и последующим поиском по id.

Таким образом мы добились полного покрытия всей цепочки функционала. Каждый тест отвечает за работу собственного кода и фиксирует вызовы всех зависимостей. Чтобы протестировать приложение не требуется запускать его с полным поднятием контекста, это тяжело и долго. Сопровождение функционала быстрыми и легкими модульными тестами создает комфортную и надежную рабочую среду.

Кроме того, тесты повышают качество кода. В рамках независимого тестирования слоями часто приходится пересмотреть подход к организации кода. Например в сервисе метод создан метод first, он не маленький, он содержит и собственный код и моки, и, допустим, дробить его не имеет смысла, он покрыт тестом/ми по полной программе — определены все подготовки и проверки. Затем кто-то решает добавить в сервис метод second, в котором вызывается метод first. Вроде некогда обычная ситуация, но когда доходит до покрытия тестом — что-то не складывается… Для метода second придется описывать и сценарий second и дублировать сценарий подготовки first? Ведь не получится замокать метод first самого тестируемого класса.

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

  • вынести метод first в компонент-утилиту, которая внедряется как зависимость в сервис.
  • вынести метод second в некий сервис-фасад, который комбинирует разные методы внедренного сервиса или даже нескольких сервисов.

Оба эти варианта хорошо укладываются в принцип «слоев» и удобно тестируются с мокированием зависимостей. Прелесть в том, что каждый слой отвечает за свою работу, а все вместе они создают прочный каркас неуязвимости целой системы.

На дорожку...


Вопрос для собеседования: сколько раз в рамках тикета разработчик должен запускать тесты? Сколько угодно, но как минимум дважды:

  • перед началом работы, чтобы убедиться что все OK, а не выяснять потом, что уже было сломано, а не ты сломал
  • по окончании работы

Так зачем же писать тесты? Затем, что не стоит и пытаться в большом, сложном приложении все упомнить и предусмотреть, это нужно возложить на автоматику. Разработчик не владеющий авто-тестированием не готов участвовать в большом проекте, это сразу выявит любой собеседующий.

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

  • компоненты с внедренными зависимостями, приемы мокирования
  • контроллеры, т.к. есть нюансы вызова энд-пойнта
  • DAO, репозитории, включая поднятие тестовой базы и миграции

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

Приятной работы и высокого мастерства!

Код примера доступен по ссылке на github.com: https://github.com/denisorlov/examples/tree/main/unittestidea
Tags:
Hubs:
+8
Comments28

Articles

Change theme settings