Spring Securityを使っているWebアプリのUnitテストについて調べたのでメモしておく。

基本的に次のSpringのドキュメントを動かしてみただけなので詳細はSpringのドキュメントを見てください。

dependency

<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>

※spring-boot-security-testsというものもあったがダウンロードできなかったので上記のdependencyを使えば大丈夫です。

SecurityContext

UnitテストのコードでSecurityContextHolderを使ってUserをセットしても認証できなかったので上記のDependencyを追加して@WithMockUserなどのspring-security-testが用意している手段を使ってテストする必要があります。

test test test

Webアプリをテストする前にspring-security-testを触ってみる。
例えば、以下の様なServiceをテストする。

@Service
public class SpringSecurityService {

public boolean isLoggedIn() {
return !ObjectUtils.isEmpty(getAuthentication())
&& getAuthentication().isAuthenticated();
}

public Optional<UserDetails> getCurrentUserDetails() {
if (isLoggedIn()) {
Object principal = getAuthentication().getPrincipal();
if (principal instanceof UserDetails) {
return Optional.of((UserDetails) principal);
}
}
return Optional.empty();
}

private Authentication getAuthentication() {
return SecurityContextHolder.getContext().getAuthentication();
}
}

テストコードを書いてみる。

@WithMockUserをつけるだけでorg.springframework.security.core.userdetails.UserがSecurityContextにセットされる。
username, password, rolesが指定できる。

@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest(classes = SpringBootSecuritySampleApplication.class)
public class SpringSecurityServiceTest {

@Autowired
private SpringSecurityService springSecurityService;

@Test
@WithMockUser(username="alice",roles={"USER"})
public void testIsLoggedIn() {
Assert.assertTrue(springSecurityService.isLoggedIn());
}

@Test
public void testIsLoggedInNoLogin() {
Assert.assertFalse(springSecurityService.isLoggedIn());
}

@Test
@WithMockUser(username="alice",roles={"USER"})
public void testGetCurrentUserDetails() {
UserDetails userDetails = springSecurityService.getCurrentUserDetails().orElse(null);
Assert.assertNotNull(userDetails);
Assert.assertEquals("alice", userDetails.getUsername());
Assert.assertEquals(1, userDetails.getAuthorities().size());
}

大半は、CustomUserを使っていると思うので@WithUserDetailsを使って使用するUserDetailsServiceを指定してUserを取得するか、カスタムUserアノテーションを作る必要がある。

今回作ったサンプルがUserEntityを作っていないのでUserDetailsServiceも当然作っていない。おそらくDefaultなUserDetailsServiceがあるはずだけども、取り方がよくわからなかったので@WithSecurityContextを使って、@WithMockCustomUserを作ってみる。

@WithMockCustomUserと指定できるようにアノテーションを作る。
Custom propertyとしてemailを追加してみる。

@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class)
public @interface WithMockCustomUser {
String username() default "user";

String password() default "password";

String email() default "user@example.com";

String[] roles() default { "USER" };
}

WithMockCustomUserSecurityContextFactoryを実装する必要があるので実装する。

※UserEntityを作っていないのでCustomUserDetailsをInnerクラスで定義している。なので、Entity部分は読み替えてください。

public class WithMockCustomUserSecurityContextFactory
implements WithSecurityContextFactory<WithMockCustomUser> {
@Override
public SecurityContext createSecurityContext(WithMockCustomUser customUser) {
SecurityContext context = SecurityContextHolder.createEmptyContext();

List<GrantedAuthority> authorities = new ArrayList<>();
for (String role : customUser.roles()) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
}

CustomUserDetails principal = new CustomUserDetails(customUser.username(),
customUser.password(), authorities, customUser.email());
Authentication auth = new UsernamePasswordAuthenticationToken(principal,
"password", principal.getAuthorities());
context.setAuthentication(auth);
return context;
}

@Data
public class CustomUserDetails extends User {
private String email;
public CustomUserDetails(String username, String password,
Collection<? extends GrantedAuthority> authorities,
String email) {
super(username, password, authorities);
this.email = email;
}
}
}

以下の様にカスタムユーザアノテーションが使えるようになる。

@Test
@WithMockCustomUser(username = "admin", email = "admin@ishiis.net")
public void testCustomUser() throws Exception {
mockMvc.perform(MockMvcRequestBuilders.get("/home"))
.andExpect(MockMvcResultMatchers.status().is2xxSuccessful())
.andExpect(MockMvcResultMatchers.content().string("Login User Name:admin"))
.andExpect(MockMvcResultMatchers.content().string(Matchers.containsString("admin")));
}

まぁ、emailは使ってないのですが・・・。

Webアプリのテスト

前置きが長くなってしまいましたが、Webアプリのテストをします。

まず、SecurityConfigから。

DataSourceは省略しています。

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.defaultSuccessUrl("/home").failureUrl("/login")
.usernameParameter("username").passwordParameter("password")
.and()
.logout().logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
.logoutSuccessUrl("/");
}
}

/だけ許可して、ログインに失敗したら/loginに遷移させるようにしている。/loginはspring securityが用意しているdefaultの画面を使っている。

Contorollerは次のものを適当につくった。

@Controller
public class HomeController {

@Autowired
private SpringSecurityService springSecurityService;

@RequestMapping("/")
@ResponseBody
public String root(){
return "ROOT PAGE";
}

@RequestMapping("/home")
@ResponseBody
public String home(){
return "Login User Name:" + springSecurityService.getCurrentUsername().orElse("unknown username");
}
}

テストコードを書いてみる

Webアプリケーションのテストをするために、WebApplicationContextMockMvcが必要になる。

/homeに対するテストコードを書いてみる。

@RunWith(SpringRunner.class)
@SpringBootTest(classes = SpringBootSecuritySampleApplication.class)
public class HomeControllerTest {

@Autowired
private WebApplicationContext context;

private MockMvc mockMvc;

@Before
public void setUp() {
mockMvc = MockMvcBuilders.webAppContextSetup(context)
.apply(SecurityMockMvcConfigurers.springSecurity()).build();
}

@Test
@WithMockCustomUser(username = "admin", email = "admin@ishiis.net")
public void testCustomUser() throws Exception {
mockMvc.perform(MockMvcRequestBuilders.get("/home"))
.andExpect(MockMvcResultMatchers.status().is2xxSuccessful())
.andExpect(MockMvcResultMatchers.content().string("Login User Name:admin"))
.andExpect(MockMvcResultMatchers.content().string(Matchers.containsString("admin")));
}

@Test
public void testHomeNotLogIn() throws Exception {
mockMvc.perform(MockMvcRequestBuilders.get("/home"))
.andExpect(MockMvcResultMatchers.status().is3xxRedirection())
.andExpect(MockMvcResultMatchers.redirectedUrlPattern("**/login"));
}

}

MockMvcに対してSpringSecurityを適用するにはMockMvcBuildersの#applyでSecurityMockMvcConfigurers#springSecurityを指定する必要がある。

ちなみに以下の様にMockMvcRequestBuildersでログインしているユーザをセットする方法もある。
UserDetailsをimplしているオブジェクトが指定できる。

@Test
public void testHomeLoggedInUsingWithUser() throws Exception {
Collection<GrantedAuthority> authorities =new ArrayList<>() ;
authorities.add(new SimpleGrantedAuthority("ADMIN"));
mockMvc.perform(MockMvcRequestBuilders.get("/home").with(user(new User("admin", "password", authorities))))
.andExpect(MockMvcResultMatchers.status().is2xxSuccessful())
.andExpect(MockMvcResultMatchers.content().string("Login User Name:admin"))
.andExpect(MockMvcResultMatchers.content().string(Matchers.containsString("admin")));
}

ユーザ認証を使っているアプリであれば頻繁にテストに.with(user(new User()))と書くことになるのでannotationで書けるようにしておいたほうが可読性もあがるのでいいかもしれない。

まとめ

SpringBoot1.4からannotation周りが若干変わってて困ったが、簡単にテストできる様になっているのでいい感じですね。

おわり。

参考

  1. http://docs.spring.io/spring-security/site/docs/current/reference/html/test-method.html
  2. http://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-testing.html