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

от автора

Всем привет.

Познакомившись с библиотекой 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

ссылка на оригинал статьи http://habrahabr.ru/post/171911/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *