스프링과 JPA 기반 웹 애플리케이션 개발 #30 프로필 수정 테스트
해당 내용은 인프런, 스프링과 JPA 기반 웹 애플리케이션 개발의 강의 내용을 바탕으로 작성된 내용입니다.

강의를 학습하며 요약한 내용을 출처를 표기하고 블로깅 또는 문서로 공개하는 것을 허용합니다 라는 원칙 하에 요약 내용을 공개합니다. 출처는 위에 언급되어있듯, 인프런, 스프링과 JPA 기반 웹 애플리케이션 개발입니다.
제가 학습한 소스코드는 https://github.com/n00nietzsche/jakestudy_webapp 에 지속적으로 업로드 됩니다. 매 커밋 메세지에 강의의 어디 부분까지 진행됐는지 기록해놓겠습니다.
Authentication이 필요하다.@WithMockUser로는 처리할 수 없다.@WithAccount@WithAccount 커스텀 애노테이션@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithAccountSecurityContextFactory.class)
public @interface WithAccount {
String value();
}
SecurityContextFactory 구현public class WithAccountSecurityContextFacotry implements WithSecurityContextFactory<WithAccount> {
// 빈을 주입받을 수 있다.
// Authentication 만들고 SecurityContext에 넣어주기
UserDetails principal = accountService.loadUserByUsername(nickname);
Authentication authentication = new UsernamePasswordAuthenticationToken(principal, principal.getPassword(), principal.getAuthorities());
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
}
@WithMockUser 로는 확인할 수 없다.@WithUserDetails 로도 확인할 수 없다.@BeforeEach 전에 @WithUserDetails가 먼저 실행돼서 유저를 찾지 못한다.setupBefore 라는 엘리먼트 값이 있지만, 현재 Junit5에서 버그로 인하여 적용이 잘 안됨 (스프링부트 2.4.2에는 패치됐다는 이야기가 있음)@WithSecurityContext 애노테이션을 이용할 것임.@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithAccountSecurityContextFactory.class)
public @interface WithAccount {
String nickname();
}
nickname을 받고, 팩토리를 이용해서 SecurityContext에 Authentication 정보를 넣어줄 것임.
@RequiredArgsConstructor
// 제네릭 타입에는 애노테이션 타입을 주면 된다.
// 이 클래스는 빈으로 등록되기 때문에 얼마든지 빈을 주입받을 수 있다.
public class WithAccountSecurityContextFactory implements WithSecurityContextFactory<WithAccount> {
private final AccountService accountService;
@Override
public SecurityContext createSecurityContext(WithAccount withAccount) {
String nickname = withAccount.nickname();
String password = "test1234";
String email = nickname + "@test.com";
// 회원 가입
SignUpForm signUpForm = new SignUpForm();
signUpForm.setEmail(email);
signUpForm.setPassword(password);
signUpForm.setNickname(nickname);
accountService.signUpAndSendEmail(signUpForm);
// 이게 원래 `@UserDetails` 애노테이션이 하던 일이다.
// Authentication 만들기 및 SecurityContext 에 넣어주기
UserDetails principal = accountService.loadUserByUsername(nickname);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(principal, principal.getPassword(), principal.getAuthorities());
// 빈 시큐리티 컨텍스트 만들고, 거기에 AuthenticationToken 넣기
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authenticationToken);
return context;
}
}
WithSecurityContextFactory<WithAccount>의 구현인 createSecurityContext() 메소드를 작성해준 부분이다.
반환 값은 SecurityContext의 구현체이며, SeucirtyContext 객체를 만드는 방법은 SecurityContextHolder()의 strategy에 따라 다른데 MODE_THREADLOCAL이 기본 전략이며, SecurityContextImpl() 생성자를 활용한다.
SecurityContextFactory라는 이름답게, SecurityContext를 만들고 내부에 Authentication을 세팅해주고 끝난다. Authentication을 세팅할 때는 AuthenticationToken을 생성하여 넣어주면 된다.
@SpringBootTest
@AutoConfigureMockMvc
class SettingsControllerTest {
@Autowired MockMvc mockMvc;
@Autowired AccountRepository accountRepository;
@BeforeEach
public void beforeEach() { }
@AfterEach
public void afterEach() {
accountRepository.deleteAll();
}
/**
* 스프링 시큐리티 관련 테스트
* 이번엔 진짜 DB 에서 User 가 바뀌었는지 확인해야 하기 때문에
* @WithMockUser 로는 확인할 수 없다.
* - DB 에 없는 데이터로 그냥 principal만 넣어서 있는 척 하는 거기 때문.
* @WithUserDetails 로도 확인할 수 없다.
* - @BeforeEach 전에 @WithUserDetails가 먼저 실행돼서 유저를 찾지 못한다.
* - 사실 setupBefore 라는 엘리먼트 값이 있지만, 현재 Junit5에서 버그로 인하여 적용이 잘 안됨
* @WithSecurityContext 애노테이션을 이용할 것임.
* https://docs.spring.io/spring-security/site/docs/current/reference/html5/#test
*/
@DisplayName("프로필 수정하기 - 입력값 정상")
@WithAccount(nickname = "jake")
@Test
public void updateProfile() throws Exception {
String settingsProfileURL = "/settings/profile";
String bio = "짧은 소개";
mockMvc.perform(post(settingsProfileURL)
.param("bio", bio)
.with(csrf()))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl(settingsProfileURL))
.andExpect(flash().attributeExists("message"));
Account loginAccount = accountRepository.findByNickname("jake");
assertEquals(bio, loginAccount.getBio());
}
@DisplayName("프로필 수정하기 - 입력값 너무 길게")
@WithAccount(nickname = "jake")
@Test
public void updateProfileTooLongBio() throws Exception {
String settingsProfileURL = "/settings/profile";
String bio = "긴소개----------------------------------------------------------------------------------------------------------------------------------";
mockMvc.perform(post(settingsProfileURL)
.param("bio", bio)
.with(csrf()))
.andExpect(status().isOk())
.andExpect(view().name("settings/profile"))
.andExpect(model().attributeExists("account"))
.andExpect(model().attributeExists("profile"))
.andExpect(model().hasErrors());
Account loginAccount = accountRepository.findByNickname("jake");
assertNull(loginAccount.getBio());
}
@DisplayName("프로필 수정하기 폼")
@WithAccount(nickname = "jake")
@Test
public void updateProfileForm() throws Exception {
String settingsProfileURL = "/settings/profile";
mockMvc.perform(get(settingsProfileURL))
.andExpect(status().isOk())
.andExpect(model().attributeExists("account"))
.andExpect(model().attributeExists("profile"));
}
}
이전과 다르게 특별한 건 없다. @WithAccount()를 이용하여 SecurityContext와 디비에 생성된 계정을 이용하며, @AfterEach()를 통해 매번 계정은 디비에서 지워준다.
public String updateProfile(@LoginAccount Account loginAccount,
// @ModelAttribute 애노테이션은 생략 가능하다.
// 파라미터의 순서가 @ModelAttribute 로 가져오는 것 이후에 Model이 와야 한다.
// 순서를 안지키면 400에러가 뜬다.
// BindingResult 도 Model 보다 앞에 있어야 한다.
// 파라미터의 순서가 영향을 미친다는 사실을 처음알았다.
// Model을 최대한 뒤로 빼자.
@ModelAttribute @Valid Profile profile,
Errors errors,
RedirectAttributes redirectAttributes,
Model model) {
...
이건 좀 잡다한 건데, 여기서 엄청 시간을 소비했다.
Model 파라미터는 @ModelAttribute나 BindingResult 파라미터들보다 뒤에 와야 한다. 안그러면 @Valid 과정에서 컨트롤러 메소드로 정상적으로 진입하지 못하고 400에러 페이지를 계속 보여주게 된다.