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.

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, sessionFactoryremove the parameters in the bean configLocationand configurationClassadd 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 sessionfactorywill take the form:
net.schastny.contactmanager.domaincom.acme.contactmanager.domaintruecreate-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.propertiesa 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 contents
db.user.name=sa
db.user.pass=

God knows what, but as an example, it will do. We make a copy log4j.xmland 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.propertiesto class attributes using@Value
class TestConfig {
	@Value('${db.user.name}')
	String userName
	@Value('${db.user.pass}')
	String userPass
	// ...
}

Add a bin LocalSessionFactoryBeanwhere 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 = falsehelps 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 nameMockMvcTest.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 function andExpect()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 @PathVariablealso 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.xmlif 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_ANONYMOUSas 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/javaand 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

Also popular now: