Тестирование весенней загрузки — Данные и сервисы
Я думаю, что тестирование – это важная вещь в разработке программного обеспечения. И я не единственный такой. Если вы спросите любого разработчика, важны ли тесты или нет, они, вероятно, скажут вам то же самое.
Но реальность не так радужна. Почти во всех проектах, которые я видел, отсутствует либо наличие тестов, либо их качество. Это не просто один случай. Проблема носит системный характер.
Почему это происходит? Я считаю, что разработчики обычно не уделяют достаточного внимания улучшению знаний об использовании фреймворков тестирования. Поэтому, когда дело доходит до проверки бизнес-логики, программисты просто не знают, как это сделать.
Давайте восполним пробелы и посмотрим, что приготовил для нас Весенний тест.
Фрагменты кода взяты из этого репозитория . Вы можете клонировать его и запускать тесты, чтобы увидеть, как он работает.
Сервисный уровень + Издевается
Издевательства стали настолько широко распространены в средах тестирования, что издевательство и тестирование считаются почти синонимами.
Предположим, что у нас есть Человек, создающий сервис
.
@Service @RequiredArgsConstructor public class PersonCreateServiceImpl implementes PersonCreateService { private final PersonValidateService personValidateService; private final PersonRepository personRepository; @Override @Transactional public PersonDTO createPerson(String firstName, String lastName) { personValidateService.checkUserCreation(firstName, lastName); final var createdPerson = personRepository.saveAndFlush( new Person() .setFirstName(firstName) .setLastName(lastName) ); return DTOConverters.toPersonDTO(createdPerson); } }
Служба проверки личности
– это наш пользовательский интерфейс. PersonRepository
– это простое Хранилище данных Spring JpaRepository .
Давайте напишем модульный тест с использованием mocks.
class PersonCreateServiceImplMockingTest { private final PersonValidateService personValidateService = mock(PersonValidateService.class); private final PersonRepository personRepository = mock(PersonRepository.class); private final PersonCreateService service = new PersonCreateServiceImpl(personValidateService, personRepository); @Test void shouldFailUserCreation() { final var firstName = "Jack"; final var lastName = "Black"; doThrow(new ValidationFailedException("")) .when(personValidateService) .checkUserCreation(firstName, lastName); assertThrows( ValidationFailedException.class, () -> service.createPerson(firstName, lastName) ); } }
Ладно, это было довольно просто. Давайте подумаем о чем-то более сложном. Что делать, если создание пользователя пройдет успешно? Это требует немного большей решимости.
Во-первых, нам нужно издеваться Персональное представительство
таким образом, saveAndFlush
возвращает новый экземпляр Person
с заполненным полем id
. Во-вторых, нам нужно проверить, что результат PersonDTO
содержит ожидаемую информацию.
class PersonCreateServiceImplMockingTest { // initialization... @Test void shouldCreateNewUser() { final var firstName = "Lisa"; final var lastName = "Green"; when(personRepository.saveAndFlush(any())) .thenAnswer(invocation -> { Person person = invocation.getArgument(0); assert Objects.equals(person.getFirstName(), firstName); assert Objects.equals(person.getLastName(), lastName); return person.setId(1L); }); final var personDTO = service.createPerson(firstName, lastName); assertEquals(personDTO.getFirstName(), firstName); assertEquals(personDTO.getLastName(), lastName); assertNotNull(personDTO.getId()); } }
Это стало непросто. Но пока нет времени отдыхать. Предположим, что Служба создания пользователя
была расширена с помощью метода создать семью
.
@Service @RequiredArgsConstructor public class PersonCreateServiceImpl implements PersonCreateService { private final PersonValidateService personValidateService; private final PersonRepository personRepository; @Override @Transactional public ListcreateFamily(Iterable firstNames, String lastName) { final var people = new ArrayList (); firstNames.forEach(firstName -> people.add(createPerson(firstName, lastName))); return people; } @Override @Transactional public PersonDTO createPerson(String firstName, String lastName) { personValidateService.checkUserCreation(firstName, lastName); final var createdPerson = personRepository.saveAndFlush( new Person() .setFirstName(firstName) .setLastName(lastName) ); return DTOConverters.toPersonDTO(createdPerson); } }
Это тоже нуждается в тестах. Давайте попробуем написать один из них.
class PersonCreateServiceImplMockingTest { // initialization... @Test void shouldCreateFamily() { final var firstNames = List.of("John", "Samantha", "Kyle"); final var lastName = "Purple"; final var idHolder = new AtomicLong(0); when(personRepository.saveAndFlush(any())) .thenAnswer(invocation -> { Person person = invocation.getArgument(0); assert firstNames.contains(person.getFirstName()); assert Objects.equals(person.getLastName(), lastName); return person.setId(idHolder.incrementAndGet()); }); final var people = service.createFamily(firstNames, lastName); for (int i = 0; i < people.size(); i++) { final var personDTO = people.get(i); assertEquals(personDTO.getFirstName(), firstNames.get(i)); assertEquals(personDTO.getLastName(), lastName); assertNotNull(personDTO.getId()); } verify(personValidateService, times(3)).checkUserCreation(any(), any()); verify(personRepository, times(3)).saveAndFlush(any()); } }
Когда я смотрю на этот код, я не вижу ничего, кроме ерунды. Поток данных настолько сложен, что практически невозможно понять, что на самом деле делает тест. Более того, нет тестирования но проверка того, что некоторые конкретные методы вызывались в определенное время. “В чем разница?” вы можете спросить. Представьте, что выполнение метода saveAndFlush
было заменено пользовательским, который обновляет сущность и сохраняет предыдущее состояние в таблице архива (например, сохранить С Архивированием
). Хотя бизнес-логика та же, тест не пройдет из-за того, что новый метод не был подвергнут издевательствам.
Возможно, последнее утверждение было недостаточно убедительным. Давайте посмотрим декларацию Человека
сущность.
@Entity @Table(name = "person") public class Person { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "first_name") private String firstName; @Column(name = "last_name") private String lastName; private ZonedDateTime dateCreated; @PrePersist void prePersist() { dateCreated = ZonedDateTime.now(); } // getters, setters }
У него есть Предварительный список
обратный вызов, который устанавливает дату создания непосредственно перед вставкой новой записи в базу данных. Проблема в том, что его нельзя протестировать с помощью макетов. Логика вызывается поставщиком JPA внутренне. Издевательства просто не могут имитировать такое поведение.
Итак, давайте сделаем выводы. Макеты идеально подходят для тестирования тех функций, которые вы контролируете. Обычно это определяемые пользователем сервисы (например, Служба проверки личности
). Spring Data и JPA генерируют множество данных во время выполнения. Насмешки не помогут вам проверить это.
Уровень обслуживания + База данных H2
Если у вас есть служба, которая должна взаимодействовать с базой данных, единственный способ по-настоящему протестировать ее – запустить на реальном экземпляре БД. H2 ДБ – это первое, что приходит на ум.
К счастью, нам не нужны какие-либо сложные конфигурации и хитрое объявление компонентов для запуска базы данных в тестовой среде. Пружинный ботинок позаботится об этом.
Тест Jpa Данных
С чего мы начнем? Во-первых, нам нужно объявить набор тестов.
@DataJpaTest class PersonCreateServiceImplDataJpaTest { @Autowired private PersonRepository personRepository; @MockBean private PersonValidateService personValidateService; @Autowired private PersonCreateService personCreateService; }
@DataJpaTest
аннотация здесь творит чудеса. И, в частности, есть 4 пункта.
- Запуск встроенного экземпляра базы данных H2.
- Создание схемы базы данных в соответствии с объявленными классами сущностей.
- Добавление всех компонентов репозиториев в контекст приложения.
- Обертывание всего набора тестов аннотацией @Transactional. Таким образом, каждое выполнение теста становится независимым.
Вы, наверное, заметили аннотацию @MockBean
. Это функция Spring, которая не только издевается над интерфейсом, но и добавляет его в контекст приложения. Таким образом, он может быть автоматически подключен другими способами во время тестового запуска.
Теперь нам нужно создать экземпляр службы, которую мы собираемся протестировать.
@DataJpaTest class PersonCreateServiceImplDataJpaTest { @Autowired private PersonRepository personRepository; @MockBean private PersonValidateService personValidateService; @Autowired private PersonCreateService personCreateService; @TestConfiguration static class TestConfig { @Bean public PersonCreateService personCreateService( PersonRepository personRepository, PersonValidateService personValidateService ) { return new PersonCreateServiceImpl( personValidateService, personRepository ); } } }
На мой взгляд, наиболее гибкое решение обеспечивает аннотацию @TestConfiguration
. Это позволяет нам изменять существующий контекст приложения. Когда добавляется Служба создания пользователя
, ее можно легко ввести с помощью @Autowired
.
Хорошо, давайте начнем с простого теста счастливого пути метода создать семью
.
@DataJpaTest class PersonCreateServiceImplDataJpaTest { // initialization... @Test void shouldCreateOnePerson() { final var people = personCreateService.createFamily( List.of("Simon"), "Kirekov" ); assertEquals(1, people.size()); final var person = people.get(0); assertEquals("Simon", person.getFirstName()); assertEquals("Kirekov", person.getLastName()); assertTrue(person.getDateCreated().isBefore(ZonedDateTime.now())); } }
Как вы можете видеть, этот тест намного чище, короче и понятнее, чем тест с использованием mocks. Более того, теперь мы можем тестировать обратные вызовы в режиме гибернации (например, @prePersist
).
Ну, а этот был настоящим тортом. Но что, если произойдет исключение ValidationFailedException
? Это означает, что транзакция должна быть отменена. Давайте выясним это.
@DataJpaTest class PersonCreateServiceImplDataJpaTest { // initialization... @Test void shouldRollbackIfAnyUserIsNotValidated() { doThrow(new ValidationFailedException("")) .when(personValidateService) .checkUserCreation("John", "Brown"); assertThrows(ValidationFailedException.class, () -> personCreateService.createFamily( List.of("Matilda", "Vasya", "John"), "Brown" )); assertEquals(0, personRepository.count()); } }
Выполнение должно завершиться неудачей при создании "John"
. Это означает, что общее количество людей должно быть равно 0
потому что выброс исключения откатывает транзакцию.
expected: <0> but was: <2> Expected :0 Actual :2
Что-то пошло не так. Похоже, что транзакция по какой-то причине не была отменена. И это правда.
Я уже упоминал, что @DataJpaTest
завершает набор @Транзакционным
. Таким образом, набор тестов и сервис являются транзакционными. Уровень распространения аннотации по умолчанию равен ТРЕБУЕТСЯ
. Это означает, что вызов другого транзакционного метода не запускает новую транзакцию. Вместо этого он продолжает выполнять инструкции SQL в текущем. |/Исключение ValidationFailedException
происходящее не отменяет транзакцию, поскольку исключение не выходит за ее рамки. Так, граф возвращается 2
вместо того, чтобы 0
.
Я описал это явление в своей статье “Весенние данные — транзакционные предостережения”.
Что мы можем с этим поделать? Мы могли бы пометить Сервис создания человека.создать семью
распространение транзакций как REQUIRES_NEW. Это решает проблему с текущим тестом, но добавляет новые. Вы можете найти больше примеров в репозитории, который я отметил в начале статьи.
Если @DataJpaTest
вызывает такие странные проблемы, то в чем его цель? Что ж, его название описывает цель. Его следует использовать с тестами репозитория .
public interface PersonRepository extends JpaRepository{ @Query("select distinct p.lastName from Person p") Set findAllLastNames(); }
@DataJpaTest class PersonRepositoryDataJpaTest { @Autowired private PersonRepository personRepository; @Test void shouldReturnAlLastNames() { personRepository.saveAndFlush(new Person().setFirstName("John").setLastName("Brown")); personRepository.saveAndFlush(new Person().setFirstName("Kyle").setLastName("Green")); personRepository.saveAndFlush(new Person().setFirstName("Paul").setLastName("Brown")); assertEquals(Set.of("Brown", "Green"), personRepository.findAllLastNames()); } }
Видишь? Это идеально подходит. Тест выполняет один оператор SQL. В этом случае транзакционное поведение по умолчанию @DataJpaTest
становится удобным. Но уровни обслуживания гораздо сложнее. И для этого нам нужен другой инструмент.
Испытание пружинного ботинка
Давайте немного перепишем тестовое объявление.
@SpringBootTest(webEnvironment = WebEnvironment.NONE) @AutoConfigureTestDatabase class PersonCreateServiceImplSpringBootTest { @Autowired private PersonRepository personRepository; @MockBean private PersonValidateService personValidateService; @Autowired private PersonCreateService personCreateService; @BeforeEach void init() { personRepository.deleteAll(); } }
Существуют некоторые различия с альтернативой @DataJpaTest
.
Аннотация @SpringBootTest
запускает весь контекст Spring, а не только репозитории JPA. Еще одна важная вещь заключается в том, что он не заключает набор тестов в @Транзакционный
.Тот веб-среда. Параметр NONE
Аннотация @AutoConfigureTestDatabase
настраивает встроенную базу данных H2 и создает схему в соответствии с определенными сущностями. @DataJpaTest
уже включает его, поэтому объявлять их оба излишне (если только мы не хотим параметризовать @AutoConfigureTestDatabase
но это выходит за рамки).
Возможно, вы также заметили, что мы просто автоматически подключили Службу создания пользователей
без каких-либо дополнительных настроек. В связи с тем, что @Springboost
создает экземпляр каждого компонента по умолчанию, служба уже присутствует в контексте приложения.
Сброс базы данных в @beforeEach
обратный вызов необходим, так как @SpringBootTest
не обеспечивает транзакционное поведение. Но нам нужно поддерживать чистоту таблицы между запусками тестов.
Итак, давайте проведем тесты из примера @DataJpaTest
и посмотрим, как это работает.
@SpringBootTest(webEnvironment = WebEnvironment.NONE) @AutoConfigureTestDatabase class PersonCreateServiceImplSpringBootTest { @Autowired private PersonRepository personRepository; @MockBean private PersonValidateService personValidateService; @Autowired private PersonCreateService personCreateService; @BeforeEach void init() { personRepository.deleteAll(); } @Test void shouldCreateOnePerson() { final var people = personCreateService.createFamily( List.of("Simon"), "Kirekov" ); assertEquals(1, people.size()); final var person = people.get(0); assertEquals("Simon", person.getFirstName()); assertEquals("Kirekov", person.getLastName()); assertTrue(person.getDateCreated().isBefore(ZonedDateTime.now())); } @Test void shouldRollbackIfAnyUserIsNotValidated() { doThrow(new ValidationFailedException("")) .when(personValidateService) .checkUserCreation("John", "Brown"); assertThrows(ValidationFailedException.class, () -> personCreateService.createFamily( List.of("Matilda", "Vasya", "John"), "Brown" )); assertEquals(0, personRepository.count()); } }
Все работает как по волшебству.
Во всех наших тестовых примерах предполагалось, что Person Validate Service.check Создание пользователя
имеет простую логику проверки входных параметров. Но на самом деле это может быть неправдой. Служба также может взаимодействовать с базой данных для проверки предварительных условий. Итак, давайте подражать поведению.
Предположим, что валидатор не позволяет создавать нового человека, если есть человек с той же фамилией. Чтобы протестировать этот сценарий, нам нужно правильно смоделировать службу проверки личности и заранее вставить члена семьи, прежде чем вызывать метод
PersonCreateService.createFamily .
@SpringBootTest(webEnvironment = WebEnvironment.NONE) @AutoConfigureTestDatabase class PersonCreateServiceImplSpringBootTest { // initialization... @Test void shouldRollbackIfOneUserIsNotValidated() { doAnswer(invocation -> { final String lastName = invocation.getArgument(1); final var exists = personRepository.exists(Example.of(new Person().setLastName(lastName))); System.out.println("Person with " + lastName + " exists: " + exists); if (exists) { throw new ValidationFailedException("Person with " + lastName + " already exists"); } return null; }).when(personValidateService).checkUserCreation(any(), any()); personRepository.saveAndFlush(new Person().setFirstName("Alice").setLastName("Purple")); assertThrows(ValidationFailedException.class, () -> personCreateService.createFamily( List.of("Matilda"), "Purple" )); assertEquals(1, personRepository.count()); } }
Это работает!
Кстати, вы также можете запустить @DataJpaTest
пакеты без транзакций
. Вам просто нужно @Транзакционная(распространение)
аннотация.
Вывод
Спасибо вам за чтение! Это довольно длинная статья, и я рад, что вы ее прочитали. В следующий раз мы обсудим интеграцию тестовых контейнеров с Spring Test. Если у вас есть какие-либо вопросы или предложения, пожалуйста, оставьте свои комментарии ниже. Увидимся в следующий раз!
Оригинал: “https://dev.to/kirekov/spring-boot-testing-data-and-services-288f”