ContactManager, part 3. Testing controllers with MockMvc
- Tutorial
Hello.
Having got acquainted with the MockMvc library, I found out " presence of absence " of its references on Habré. I’ll try to fill this gap, especially since our ContactManager application just needs automated testing.
So, the main topic of the lesson is to add tests for controllers to the application. As a bonus, we will do it according to the fashionable, “no-xml-th” technology.
First, update the library versions. SpringFramework has now been updated to version 3.2.1, and includes the coveted MockMvc, so this update is necessary. Spring Security is slightly behind, but this is (almost) no problem. The Hibernate version also grew to 4.1.9.Final. The full project file can be found in the repository (link at the end of the article).
Switching to version 4 of Hibernate requires a little revision of the file
We will also need test resources, so we create a directory
God knows what, but as an example, it will do. We make a copy
Create a directory
Create a spring configuration file
The names of the annotations speak for themselves:
Further. Bind properties from
Add a bin
And finally, the “cherry on the cake” of our configuration will be
I repeat - the configuration of the project itself has remained old, based on xml. This configuration applies only to tests.
But it was all a saying, it was time for a fairy tale. It is logical to expect that working with MockMvc consists of 3 steps: building a mock object, sending an HTTP request to the controller and actually analyzing the results. For the first step - building a mock object - we will use a builder based on WebApplicationContext.
Create a class with a mnemonic name
Everything is simple to disgrace. You can already test something. Take a look at our controller and see that a method
Actually, we write
Almost no explanation required:
We start, it works. Hooray, the first test is ready. What's next? List of contacts.
All the same GET request, we get the map in the parameters, fill it in and return the name view. We already know how to send GET requests, it remains only to add a result check. We are writing a second test.
Again, not a single extra line. After completing the request, we checked the name view, which he returned to us, and checked that all the attributes of the model are in place. For a more detailed study of these attributes, you can get a link to the actual MvcResult object using the andReturn () function
So far, everything is going well, but there is still a lot of work ahead. It's time to add something to our list. The controller method looks like this:
Finally, the POST method, plus an awesome-looking parameter
Well, the last method remains - delete ().
Passing is
That's all, all methods are covered with automatic tests, cheers! Or not cheers? The attentive reader will ask - what about security ?! Why did we connect
It is logical to assume that MockMvc should have support for working with filters. In fact, the builder has a method
Now we have a rights check when accessing urls, but we need to introduce ourselves to the system somehow. Let's try to make a “feint with my ears” and directly set the value to SecirityContextHolder.
It looks believable, try to execute the add () method, which requires the ROLE_USER privilege. Babah! Did not work out
The SecurityRequestPostProcessors.java class will help us with this . I will not dwell on its contents, just copy it into a folder
We remove the call
That is, in essence, we execute the request on behalf of user user1, with all his rights. And it works great! In the log we see the desired
But do not rush to delete the old test versions, they will still be useful to us. Indeed, in this form they are testing nothing more than unauthorized access to our system. And the same home () and index () methods should work, because these urls do not impose any restrictions on authentication. And they work!
Back to the add () method. What does our application do when we try to maintain contact while being unauthorized? Shows us the login page! In terms of a redirect, this means a redirect to /login.jsp
Therefore, we replace the verification of the result of an unauthorized request to save the contact with another:
In the same way, the delete () method must implement the absence of authorization. And on behalf of the user “admin”, the removal works and this is correct.
And it remains to test another option - when a user with the rights ROLE_USER tries to delete a record. In this case, he should see error 403, or rather, the forward on /error403.jsp. The body of the test method for this scenario will look like this (the contact id in this case does not matter, just set / 1):
That's all. As a result, we got 12 test methods, 3 for each of 4 urls. They check unauthorized access, access with the rights ROLE_USER and ROLE_ADMIN. Along with the controllers, we tested the methods of services and DAO.
GitHub project source code
Having got acquainted with the MockMvc library, I found out " presence of absence " of its references on Habré. I’ll try to fill this gap, especially since our ContactManager application just needs automated testing.
So, the main topic of the lesson is to add tests for controllers to the application. As a bonus, we will do it according to the fashionable, “no-xml-th” technology.
Project structure update
First, update the library versions. SpringFramework has now been updated to version 3.2.1, and includes the coveted MockMvc, so this update is necessary. Spring Security is slightly behind, but this is (almost) no problem. The Hibernate version also grew to 4.1.9.Final. The full project file can be found in the repository (link at the end of the article).
Switching to version 4 of Hibernate requires a little revision of the file
data.xml
. You need to change 3 to 4 in the package name org.springframework.orm.hibernate4
, sessionFactory
remove the parameters in the bean configLocation
and configurationClass
add a parameter instead packagesToScan
, where to transfer the list of class packages with Hibernate mapping from the file hibernate.cfg.xml
. This file itself can be deleted; we no longer need it. As a result, the bin sessionfactory
will take the form:net.schastny.contactmanager.domain com.acme.contactmanager.domain
true create-drop ${jdbc.dialect} UTF-8
We will also need test resources, so we create a directory
src/test/resources
, copy the security settings into it security.xml
, and create testdb.properties
a properties file for the database. You can do without it, but again, for educational purposes, we will see how you can set properties in bins from the outside. File contentsdb.user.name=sa
db.user.pass=
God knows what, but as an example, it will do. We make a copy
log4j.xml
and with resources on it all. We pass to the source codes. Create a directory
src/test/groovy
, packagecom.acme.contactmanager.test
In order to find our groove tests during assembly from the command line, add the build-helper-maven-plugin to pom.xml
Spring configuration for tests
Create a spring configuration file
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 {
// ...
}
The names of the annotations speak for themselves:
- this is configuration
- components should be looked for in the specified packages (recall: these are grooves and the array is enclosed in square brackets)
- need to upload file
testdb.properties
- don't forget about
security.xml
- and yes, we will need transactions
By the way, regarding transactions. For DAO and service classes, interfaces and implementations are clearly separated, but in general you can limit yourself to the implementation class alone. In this case, the annotation will need to indicate that proxyTarget are created automatically, otherwise there will be problems:@EnableTransactionManagement(proxyTargetClass = true)
Further. Bind properties from
testdb.properties
to class attributes using@Value
class TestConfig {
@Value('${db.user.name}')
String userName
@Value('${db.user.pass}')
String userPass
// ...
}
Add a bin
LocalSessionFactoryBean
where we will use the obtained properties. Here we see the familiar to uspackagesToScan
@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
}
This thing hibernate.temp.use_jdbc_metadata_defaults = false
helps when it slows down the context start on getting metadata from the database.
And finally, the “cherry on the cake” of our configuration will be
HibernateTransactionManager
@Bean
public HibernateTransactionManager transactionManager() {
HibernateTransactionManager txManager = new HibernateTransactionManager()
txManager.autodetectDataSource = false
txManager.sessionFactory = sessionFactory().object
txManager
}
I repeat - the configuration of the project itself has remained old, based on xml. This configuration applies only to tests.
Start testing
But it was all a saying, it was time for a fairy tale. It is logical to expect that working with MockMvc consists of 3 steps: building a mock object, sending an HTTP request to the controller and actually analyzing the results. For the first step - building a mock object - we will use a builder based on WebApplicationContext.
Create a class with a mnemonic name
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()
}
}
Everything is simple to disgrace. You can already test something. Take a look at our controller and see that a method
home()
where a simple redirect is a good candidate for a test test @RequestMapping("/")
public String home() {
return "redirect:/index";
}
Actually, we write
@Test
public void home() {
MockHttpServletRequestBuilder request = MockMvcRequestBuilders.get("/")
ResultActions result = mockMvc.perform(request)
result.andExpect(MockMvcResultMatchers.redirectedUrl("/index"))
}
Almost no explanation required:
- using MockMvcRequestBuilders.get ("/") we get a wrapper for the GET request for url "/"
- mockMvc.perform (request) returns the result
- as a result, we verify that the redirect to the desired URL has returned
The functionandExpect()
gives great opportunities for checking the result, here are examples from Javadoc that give a general idea of its work: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!"));
We start, it works. Hooray, the first test is ready. What's next? List of contacts.
@RequestMapping("/index")
public String listContacts(Map map) {
map.put("contact", new Contact());
map.put("contactList", contactService.listContact());
map.put("contactTypeList", contactService.listContactType());
return "contact";
}
All the same GET request, we get the map in the parameters, fill it in and return the name view. We already know how to send GET requests, it remains only to add a result check. We are writing a second test.
@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"))
}
Again, not a single extra line. After completing the request, we checked the name view, which he returned to us, and checked that all the attributes of the model are in place. For a more detailed study of these attributes, you can get a link to the actual MvcResult object using the andReturn () function
MvcResult mvcResult = result.andReturn()
assert mvcResult.modelAndView.model.contactTypeList.size() == 3
So far, everything is going well, but there is still a lot of work ahead. It's time to add something to our list. The controller method looks like this:
@RequestMapping(value = "/add", method = RequestMethod.POST)
public String addContact(@ModelAttribute("contact") Contact contact, BindingResult result) {
contactService.addContact(contact);
return "redirect:/index";
}
Finally, the POST method, plus an awesome-looking parameter
@ModelAttribute Contact contact
. But it is not all that bad. A quick google search for “mockmvc ModelAttribute” also gives the result . Such mapping can simply be replaced with a set of query parameters. Add parameters in the query function is to be expected as follows: param(Stirng name, String... values)
. We write @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)
}
Well, the last method remains - delete ().
@RequestMapping("/delete/{contactId}")
public String deleteContact(@PathVariable("contactId") Integer contactId) {
contactService.removeContact(contactId);
return "redirect:/index";
}
Passing is
@PathVariable
also not a problem, just add it to the 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)
}
That's all, all methods are covered with automatic tests, cheers! Or not cheers? The attentive reader will ask - what about security ?! Why did we connect
security.xml
if there is no mention of users and roles in the tests ?! And he will be right. In the third part of the lesson, we will add support for working with SpringSecurity.Add Authentication
It is logical to assume that MockMvc should have support for working with filters. In fact, the builder has a method
addFilter()
in which we can pass an instance springSecurityFilterChain
. Change our test as follows: @Autowired
FilterChainProxy springSecurityFilterChain
@Before
public void setup() {
this.mockMvc = MockMvcBuilders.webAppContextSetup(this.wac)
.addFilter(springSecurityFilterChain) // добавляем фильтр безопасности
.dispatchOptions(true).build()
}
Now we have a rights check when accessing urls, but we need to introduce ourselves to the system somehow. Let's try to make a “feint with my ears” and directly set the value to SecirityContextHolder.
List authorities = AuthorityUtils.createAuthorityList("ROLE_USER");
Authentication auth = new UsernamePasswordAuthenticationToken("user1", "1111", authorities);
SecurityContextHolder.getContext().setAuthentication(auth);
It looks believable, try to execute the add () method, which requires the ROLE_USER privilege. Babah! Did not work out
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
as if hints to us that our feint with our ears did not work. But I hasten to reassure - there is no fault of ours here; Security support has not yet been implemented in tests . That is why in the beginning I wrote that integration with SpringSecurity is "almost no problem." There is a problem, but it is solvable. The SecurityRequestPostProcessors.java class will help us with this . I will not dwell on its contents, just copy it into a folder
src/test/java
and show how it can be used in our needs. We remove the call
setAuthentication(auth)
that turned out to be useless , and in the add () method we add one line to the query structure: //...
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"))
// ...
That is, in essence, we execute the request on behalf of user user1, with all his rights. And it works great! In the log we see the desired
Granted Authorities: ROLE_USER
But do not rush to delete the old test versions, they will still be useful to us. Indeed, in this form they are testing nothing more than unauthorized access to our system. And the same home () and index () methods should work, because these urls do not impose any restrictions on authentication. And they work!
Back to the add () method. What does our application do when we try to maintain contact while being unauthorized? Shows us the login page! In terms of a redirect, this means a redirect to /login.jsp
Therefore, we replace the verification of the result of an unauthorized request to save the contact with another:
result.andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login.jsp"))
In the same way, the delete () method must implement the absence of authorization. And on behalf of the user “admin”, the removal works and this is correct.
And it remains to test another option - when a user with the rights ROLE_USER tries to delete a record. In this case, he should see error 403, or rather, the forward on /error403.jsp. The body of the test method for this scenario will look like this (the contact id in this case does not matter, just set / 1):
mockMvc.perform(MockMvcRequestBuilders.get("/delete/1")
.with(SecurityRequestPostProcessors.userDetailsService("user1")))
.andExpect(MockMvcResultMatchers.forwardedUrl("/error403.jsp"))
That's all. As a result, we got 12 test methods, 3 for each of 4 urls. They check unauthorized access, access with the rights ROLE_USER and ROLE_ADMIN. Along with the controllers, we tested the methods of services and DAO.
GitHub project source code