Тестирование контроллеров с помощью MockMvc

в 10:38, , рубрики: groovy, hibernate, java, mockmvc, spring, тестирование, метки: , , , ,

Всем привет.

Познакомившись с библиотекой MockMvc, я обнаружил "полное наличие отсутствия" её упоминаний на Хабре. Постараюсь восполнить этот пробел, тем более, что наше приложение ContactManager как раз нуждается в автоматизированном тестировании.

Итак, основная тема урока — добавить в приложение тесты для контроллеров. В качестве бонуса мы сделаем это по модной, «без-xml-ной» технологии.

Обновление структуры проекта

Вначале обновим версии библиотек. SpringFramework к настоящему времени обновился уже до версии 3.2.1, и включает в себя вожделенный MockMvc, поэтому данное обновление необходимо. Spring Security немного отстает, но это (почти) не проблема. Версия Hibernate тоже подросла до 4.1.9.Final. Полный файл проекта вы найдете в репозитории (ссылка в конце статьи).

Переход на 4 версию Hibernate требует небольшой доработки файла data.xml. Нужно поменять 3 на 4 в имени пакета org.springframework.orm.hibernate4, в бине sessionFactory убрать параметры configLocation и configurationClass и вместо них добавить параметр packagesToScan, куда перенести список пакетов классов с Hibernate-маппингом из файла hibernate.cfg.xml. Сам этот файл можно удалить, он нам уже не нужен. В итоге бин sessionfactory примет вид:

	<bean id="sessionFactory"
		class="org.springframework.orm.hibernate4.LocalSessionFactoryBean">
		<property name="dataSource" ref="dataSource" />
		<property name="packagesToScan">
			<list>
				<value>net.schastny.contactmanager.domain</value>
				<value>com.acme.contactmanager.domain</value>
			</list>
		</property> 
		<property name="hibernateProperties">
			<props>
				<prop key="hibernate.show_sql">true</prop>
				<prop key="hibernate.hbm2ddl.auto">create-drop</prop>
				<prop key="hibernate.dialect">${jdbc.dialect}</prop>
				<prop key="hibernate.connection.charSet">UTF-8</prop>
			</props>
		</property>
	</bean>

Также нам понадобятся тестовые ресурсы, поэтому создаем каталог src/test/resources, копируем в него настройки безопасности security.xml, создаем testdb.properties — файл свойств для базы данных. Без него можно обойтись, но опять таки в учебных целях мы посмотрим, как можно установить свойства в бины извне. Содержимое файла

db.user.name=sa
db.user.pass=

Не бог весть что, но в качестве примера подойдет. Делаем копию log4j.xml и с ресурсами на этом все. Переходим к исходным кодам.

Создаем каталог src/test/groovy, пакет com.acme.contactmanager.test

Чтобы мавен при сборке из командной строки нашел наши груви-тесты, добавим в pom.xml build-helper-maven-plugin

Spring-конфигурация для тестов

Создадим файл спринговой конфигурации TestConfig.groovy

@Configuration
@ComponentScan(['net.schastny.contactmanager.dao', 'com.acme.contactmanager.dao', 'net.schastny.contactmanager.web', 'net.schastny.contactmanager.service'])
@PropertySource('classpath:testdb.properties')
@ImportResource('classpath:security.xml')
@EnableTransactionManagement
class TestConfig {
// ...
}

Названия аннотаций говорят сами за себя:

  • это конфигурация
  • компоненты нужно искать в указанных пакетах (напоминаю: это груви и массив заключается в квадратные скобки)
  • нужно загрузить файл пропертей testdb.properties
  • не забыть про security.xml
  • и да, нам нужны будут транзакции

Кстати, по поводу транзакций. У нас для классов DAO и сервиса явно разделены интерфейсы и реализации, но вообще можно ограничиться одним только классом реализации. В таком случае в аннотации надо будет указать, чтобы proxyTarget создавались автоматически, иначе будут проблемы: @EnableTransactionManagement(proxyTargetClass = true)

Далее. Привяжем свойства из testdb.properties к атрибутам класса с помощью @Value

class TestConfig {

	@Value('${db.user.name}')
	String userName

	@Value('${db.user.pass}')
	String userPass

	// ...
}

Добавим бин LocalSessionFactoryBean, где будем использовать полученные свойства. Здесь видим уже знакомый нам packagesToScan

	@Bean
	public LocalSessionFactoryBean sessionFactory() {

		LocalSessionFactoryBean bean = new LocalSessionFactoryBean()

		bean.packagesToScan = [
			'com.acme.contactmanager.domain',
			'net.schastny.contactmanager.domain'] as String[]

		Properties props = new Properties()
		props."hibernate.connection.driver_class" = "org.h2.Driver"
		props."hibernate.connection.url" = "jdbc:h2:mem:db1;DB_CLOSE_DELAY=-1;MVCC=TRUE;DB_CLOSE_ON_EXIT=FALSE"
		props."hibernate.connection.username" = userName
		props."hibernate.connection.password" = userPass
		props."hibernate.dialect" = "org.hibernate.dialect.H2Dialect"
		props."hibernate.hbm2ddl.auto" = "create-drop"
		props."hibernate.temp.use_jdbc_metadata_defaults" = "false"

		bean.hibernateProperties = props
		
		bean
	}

Вот эта штука hibernate.temp.use_jdbc_metadata_defaults = false помогает, когда тормозит старт контекста на получении метаданных из БД

И наконец «вишенкой на торте» нашей конфигурации будет HibernateTransactionManager

	@Bean
	public HibernateTransactionManager transactionManager() {
		HibernateTransactionManager txManager = new HibernateTransactionManager()
		txManager.autodetectDataSource = false
		txManager.sessionFactory = sessionFactory().object
		txManager
	}

Повторюсь — конфигурация самого проекта осталась старой, на основе xml. Эта конфигурация распространяется только на тесты.

Начинаем тестирование

Но это все была присказка, настало время сказки. Логично ожидать, что работа с MockMvc состоит из 3 шагов: построение мок-объекта, отправка HTTP-запроса контроллеру и собственно анализ результатов. Для первого шага — построения мок-объекта — мы воспользуемся билдером на основе WebApplicationContext.

Создаем класс с мнемоническим названием MockMvcTest.groovy

@RunWith(SpringJUnit4ClassRunner.class)
@WebAppConfiguration
@ContextConfiguration(classes = [ TestConfig.class ] )
class MockMvcTest {

	@Autowired
	WebApplicationContext wac

	MockMvc mockMvc
	
	@Before
	public void setup() {
		this.mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).dispatchOptions(true).build()
	}
}

Все просто до безобразия. Можно уже что-нибудь протестировать. Заглянем в наш контроллер и увидим, что хорошим кандидатом на пробный тест служит метод home(), где стоит простой редирект

	@RequestMapping("/")
	public String home() {
		return "redirect:/index";
	}

Собственно, так и пишем

	@Test
	public void home() {
        MockHttpServletRequestBuilder request = MockMvcRequestBuilders.get("/")
		ResultActions result = mockMvc.perform(request)
		result.andExpect(MockMvcResultMatchers.redirectedUrl("/index"))
	}

Пояснений практически не требуется:

  • с помощью MockMvcRequestBuilders.get("/") получаем обертку для GET-запроса на урл "/"
  • mockMvc.perform(request) возвращает результат
  • в результате проверяем, что вернулся редирект на нужный урл

Функция andExpect() дает большие возможности для проверки полученного результата, вот примеры из Javadoc, дающие общее представление о её работе:

mockMvc.perform(get("/person/1"))
   .andExpect(status.isOk())
   .andExpect(content().mimeType(MediaType.APPLICATION_JSON))
   .andExpect(jsonPath("$.person.name").equalTo("Jason"));

 mockMvc.perform(post("/form"))
   .andExpect(status.isOk())
   .andExpect(redirectedUrl("/person/1"))
   .andExpect(model().size(1))
   .andExpect(model().attributeExists("person"))
   .andExpect(flash().attributeCount(1))
   .andExpect(flash().attribute("message", "success!"));

Запускаем, работает. Ура, первый тест готов. Что там дальше? Список контактов.

	@RequestMapping("/index")
	public String listContacts(Map<String, Object> map) {

		map.put("contact", new Contact());
		map.put("contactList", contactService.listContact());
		map.put("contactTypeList", contactService.listContactType());

		return "contact";
	}

Все тот же GET-запрос, получаем мапу в параметрах, заполняем её и возвращаем имя view. Отправлять GET-запросы мы уже умеем, остается только добавить проверку результата. Пишем второй тест.

	@Test
	public void index() {

		ResultActions result = mockMvc.perform(MockMvcRequestBuilders.get("/index"))

		result.andExpect(MockMvcResultMatchers.view().name("contact"))
			.andExpect(MockMvcResultMatchers.model().attributeExists("contact"))
			.andExpect(MockMvcResultMatchers.model().attributeExists("contactList"))
			.andExpect(MockMvcResultMatchers.model().attributeExists("contactTypeList"))
	}

Вновь ни одной лишней строчки. Выполнив запрос, мы проверили имя view, которое он нам вернул, и проверили, что все атрибуты модели на месте. Для более детального изучения этих атрибутов можно получить ссылку на фактический объект MvcResult с помощью функции andReturn()

MvcResult mvcResult  = result.andReturn()
assert mvcResult.modelAndView.model.contactTypeList.size() == 3

Пока все идет неплохо, но впереди ещё много работы. Пора уже что-нибудь добавить в наш список. Метод контроллера выглядит так:

	@RequestMapping(value = "/add", method = RequestMethod.POST)
	public String addContact(@ModelAttribute("contact") Contact contact, BindingResult result) {

		contactService.addContact(contact);

		return "redirect:/index";
	}

Наконец-то POST-метод да плюс ещё устрашающего вида параметр @ModelAttribute Contact contact. Но не все так плохо. Беглое гугление по запросу «mockmvc ModelAttribute» тутже дает результат. Подобный маппинг можно просто заменить набором параметров запроса. Функция добавления параметров в запрос вполне ожидаемо выглядит так: param(Stirng name, String... values). Пишем

	@Autowired
	ContactService contactService

	@Test
	public void add() {

		// получаем список из БД и проверяем, что он пустой
		def contacts = contactService.listContact()
		assert !contacts

		// для добавления нового контакта нам нужен его тип
		def contactTypes = contactService.listContactType()
		assert contactTypes

		//  создаем POST-запрос, набиваем его параметрами и выполняем
		mockMvc.perform(MockMvcRequestBuilders.post("/add")
				.param("firstname",'firstname')
				.param("lastname",'lastname')
				.param("email",'firstname.lastname@gmail.com')
				.param("telephone",'555-1234')
				.param("contacttype.id", contactTypes[0].id.toString())
		.andExpect(MockMvcResultMatchers.redirectedUrl("/index"))

		// проверяем содержимое БД
		contacts = contactService.listContact()

		// список не пустой и у контакта присутствует id
		assert contacts
		assert contacts[0].id

		// удаляем созданный контакт, возвращая БД в первоначальное состояние
		contactService.removeContact(contacts[0].id)
}

Ну и остается последний метод — delete().

	@RequestMapping("/delete/{contactId}")
	public String deleteContact(@PathVariable("contactId") Integer contactId) {

		contactService.removeContact(contactId);

		return "redirect:/index";
	}

Передача @PathVariable тоже не составляет проблемы, просто добавим её в URL.

	@Test
	public void delete() {

		// создаем контакт через сервис
		def contactTypes = contactService.listContactType()
		assert contactTypes

		Contact contact = new Contact(
				firstname : 'firstname',
				lastname : 'lastname',
				email : 'firstname.lastname@gmail.com',
				telephone : '555-1234',
				contacttype : contactTypes[0]
			)
		
		contactService.addContact(contact)
		assert contact.id

		def contacts = contactService.listContact()
		// в груви contacts.id дает список всех id контактов
		assert contact.id in contacts.id

		// выполняем POST-запрос , добавляя в URL id созданного контакта 
		// ${contact.id} - это не спринговый placeholder, это GString!
		mockMvc.perform(MockMvcRequestBuilders.get("/delete/${contact.id}")
			.andExpect(MockMvcResultMatchers.redirectedUrl("/index"))
		
		// проверяем, что контакт удалился
		def contacts = contactService.listContact()
		assert !(contact.id in contacts.id)
	}

Вот и все, все методы покрыты автоматическими тестами, ура! Или не ура? Внимательный читатель спросит — а как же безопасность?! Зачем мы подключали security.xml, если в тестах нет никакого упоминания о пользователях и ролях?! И будет прав. В третьей части урока мы добавим поддержку работы со SpringSecurity.

Добавляем аутентификацию

Логично предположить, что MockMvc должен иметь поддержку работы с фильтрами. В самом деле, в билдере присутствует метод addFilter(), в который мы можем передать экземпляр springSecurityFilterChain. Изменим наш тест следующим образом:

	@Autowired
	FilterChainProxy springSecurityFilterChain
	
	@Before
	public void setup() {
		this.mockMvc = MockMvcBuilders.webAppContextSetup(this.wac)
				.addFilter(springSecurityFilterChain) // добавляем фильтр безопасности
				.dispatchOptions(true).build()
	}	

Теперь у нас есть проверка прав при обращении к урлам, но нужно каким-то образом представиться системе. Попробуем сделать «финт ушами» и напрямую установить значение в SecirityContextHolder.

		List<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList("ROLE_USER");
		Authentication auth = new UsernamePasswordAuthenticationToken("user1", "1111", authorities);
		SecurityContextHolder.getContext().setAuthentication(auth);

Выглядит правдоподобно, попробуем выполнить метод add(), который требует наличия привилегии ROLE_USER. Бабах! Не получилось

DEBUG: org.springframework.security.web.access.intercept.FilterSecurityInterceptor - Secure object: FilterInvocation: URL: /add; Attributes: [ROLE_USER]
DEBUG: org.springframework.security.web.access.intercept.FilterSecurityInterceptor - Previously Authenticated: org.springframework.security.authentication.
AnonymousAuthenticationToken@d4551ca6: Principal: guest; Credentials: [PROTECTED]; Authenticated: true; Details: org.springframework.security.web.authentication.
WebAuthenticationDetails@957e: RemoteIpAddress: 127.0.0.1; SessionId: null; Granted Authorities: ROLE_ANONYMOUS
DEBUG: org.springframework.security.web.access.ExceptionTranslationFilter - Access is denied (user is anonymous); redirecting to authentication entry point

Granted Authorities: ROLE_ANONYMOUS как бы намекает нам, что наш финт ушами не сработал. Но спешу успокоить — нашей вины тут нет, поддержа Security в тестах ещё пока не реализована. Именно поэтому в начале я написал, что интеграция со SpringSecurity «почти не проблема». Проблема таки есть, но она решаема.

В этом нам поможет класс SecurityRequestPostProcessors.java. Я не буду останавливаться на его содержимом, просто скопирую его в папку src/test/java и покажу, как его можно использовать в наших нуждах.

Убираем оказавшийся бесполезным вызов setAuthentication(auth), а в методе add() в конструкцию запроса добавляем одну строчку:

		//... 
		mockMvc.perform(MockMvcRequestBuilders.post("/add")
				.param("firstname",'firstname')
				.param("lastname",'lastname')
				.param("email",'firstname.lastname@gmail.com')
				.param("telephone",'555-1234')
				.param("contacttype.id", contactTypes[0].id.toString())
				.with(SecurityRequestPostProcessors.userDetailsService("user1"))) // добавляем поддержку Security 
				.andExpect(MockMvcResultMatchers.redirectedUrl("/index"))
		// ...

То есть по сути мы выполняем запрос от имени пользователя user1, со всеми его правами. И он замечательно работает! В логе видим искомое Granted Authorities: ROLE_USER

Но не спешите удалять старые варианты тестов, они ещё нам пригодятся. Ведь в таком виде они тестируют ни что иное, как неавторизованный доступ к нашей системе. И те же методы home() и index() должны работать, потому что эти урлы не налагают никаких ограничений на аутентификацию. И они работают!

Вернемся к методу add(). Что делает наше приложение, когда мы пытаемся сохранить контакт, будучи неавторизованными? Показывает нам страницу логина! В терминах редиректа это означает редирект на /login.jsp
Поэтому проверку результата неавторизованного запроса на сохранение контакта мы заменяем на другую:

result.andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login.jsp"))

Таким же образом на отсутствие авторизации должен реалировать и метод delete(). А от имени пользователя «admin» удаление работает и это правильно.

И осталось протестировать ещё один вариант — когда удалить запись пытается пользователь с правами ROLE_USER. В этом случае он должен увидеть ошибку 403, а точнее — форвард на /error403.jsp. Тело тестового метода для этого сценария будет выглядеть так (id контакта в данном случае не играет роли, просто поставим /1):

		mockMvc.perform(MockMvcRequestBuilders.get("/delete/1")
				.with(SecurityRequestPostProcessors.userDetailsService("user1")))
				.andExpect(MockMvcResultMatchers.forwardedUrl("/error403.jsp"))

Вот и все. В итоге у нас получилось 12 тестовых методов, по 3 на каждый из 4 урлов. Они проверяют неавторизованный доступ, доступ с правами ROLE_USER и ROLE_ADMIN. Попутно с контроллерами мы протестировали методы сервисов и ДАО.

Исходный код проекта на GitHub

Автор: monzdrpower

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js