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