Познакомившись с библиотекой 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. Попутно с контроллерами мы протестировали методы сервисов и ДАО.
ссылка на оригинал статьи http://habrahabr.ru/post/171911/
Добавить комментарий