스프링과 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에러 페이지를 계속 보여주게 된다.