요즘에 테스트 코드 짜는 것에 집중하고 있고 많이 공부하고 있습니다.
현재 전에 작업했던 java 8, spring, mybatis, jsp, 했던 프로젝트를 java 11, spring boot, jpa , thymeleaf, security를 사용하여 마이그레이션하고 있습니다.
https://github.com/KOSMO-Togather <<<이전에 프로젝트
https://github.com/jisu3316/togatherWithJpa <<< 현재 프로젝트
현재 프로젝트 구조입니다.
여기서 모임 엔티티는 멤버 엔티티와 연관관계가 맺어져 있기 때문에 모임을 만들 때 시큐리티를 적용해서 인증 객체에서 로그인한 사용자를 꺼내와서 모임을 만들 때 값을 넘겨주어야 했습니다.
GroupTab
package team1.togather.domain.groupTab;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import team1.togather.domain.AuditingFields;
import team1.togather.domain.groupTab.ingrouptab.MemberInGroupTab;
import team1.togather.domain.member.Member;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;
import static javax.persistence.FetchType.LAZY;
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class GroupTab extends AuditingFields {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String groupLocation;
private String groupName;
private String groupIntro;
private String interest;
private int memberLimit;
private String userId;
@OneToOne(cascade = CascadeType.ALL)
@JoinColumn(name = "group_upload_file_id")
private GroupUploadFile groupUploadFile;
@ManyToOne(fetch = LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "member_id")
private Member member;
@OneToMany(mappedBy = "groupTab")
private List<MemberInGroupTab> membersInGroupTabs = new ArrayList<>();
public GroupTab(String groupLocation, String groupName, String groupIntro, String interest, int memberLimit, GroupUploadFile groupUploadFile, Member member) {
this.groupLocation = groupLocation;
this.groupName = groupName;
this.groupIntro = groupIntro;
this.interest = interest;
this.memberLimit = memberLimit;
this.groupUploadFile = groupUploadFile;
this.member = member;
this.userId = member.getUserId();
}
public static GroupTab of(String groupLocation, String groupName, String groupIntro, String interest, int memberLimit, GroupUploadFile groupUploadFile, Member member) {
return new GroupTab(
groupLocation,
groupName,
groupIntro,
interest,
memberLimit,
groupUploadFile,
member
);
}
public void modifyGroupTabName(String groupName) {
this.groupName = groupName;
}
public void modifyGroupTabLocation(String groupLocation) {
this.groupLocation = groupLocation;
}
public void modifyGroupTabIntro(String groupIntro) {
this.groupIntro = groupIntro;
}
public void modifyGroupTabMemberLimit(int memberLimit) {
this.memberLimit = memberLimit;
}
public void modifyGroupTabUploadFile(GroupUploadFile groupUploadFile) {
this.groupUploadFile = groupUploadFile;
}
public void addMemberInGroupTab(MemberInGroupTab memberInGroupTab) {
this.membersInGroupTabs.add(memberInGroupTab);
memberInGroupTab.addGroupTab(this);
}
}
GroupTabController
@PostMapping("/new")
public String saveGroupTab(@Valid @ModelAttribute GroupTabRequestDto groupTabRequestDto, BindingResult bindingResult, Authentication authentication) throws IOException {
if (bindingResult.hasErrors()) {
log.info("error={}", bindingResult);
return "groupTabs/createGroupTabForm";
}
UploadFile attachFile = fileStore.storeFile(groupTabRequestDto.getAttachFile()); // UUID에서 리턴받은 파일네임
groupTabRequestDto.setUploadFile(attachFile);
Member member = (Member) authentication.getPrincipal();
groupTabService.saveGroupTab(groupTabRequestDto.toDto(member));
return "redirect:/";
}
GroupTabRequestDto
public GroupTabDto toDto(Member member) {
return GroupTabDto.of(
this.groupLocation,
this.groupName,
this.groupIntro,
this.interest,
this.memberLimit,
this.uploadFile,
MemberDto.from(member)
);
}
MemberDto
public static MemberDto from(Member entity) {
return new MemberDto(
entity.getId(),
entity.getUsername(),
entity.getUserId(),
entity.getPassword(),
entity.getEmail(),
entity.getBirth(),
entity.getGender(),
entity.getCategory_first(),
entity.getCategory_second(),
entity.getCategory_third()
);
}
GroupTabService
public void saveGroupTab(GroupTabDto dto) {
Member member = memberRepository.getReferenceById(dto.getMemberDto().getMemberId());
groupTabRepository.save(dto.toEntity(member));
}
GroupTabDto
public GroupTab toEntity(Member member) {
return GroupTab.of(
groupLocation,
groupName,
groupIntro,
interest,
memberLimit,
toGroupUploadFile(uploadFile),
member
);
}
public GroupUploadFile toGroupUploadFile(UploadFile uploadFile) {
GroupUploadFile groupUploadFile1 = groupUploadFile.of(uploadFile);
return groupUploadFile1;
}
컨트롤러에서 groupTabRequest 객체로 값을 받아와서 비즈니스 로직에 값을 넘겨주는 toDto 메서드를 통해 변환 시켜준후 save 해주는 로직입니다.
컨트롤러에서 세 번째 파라미터로
Authentication authentication
Authentication을 받아 옵니다.
그러면 이게 웹 위에서 작동하게 된다면 로그인이 되어있고 인증된 사용자면
Member member= (Member) authentication.getPrincipal(); 로 받아 올 수 있습니다.
하지만 이걸 테스트하려고 한다면 테스트 코드 내에서 인증된 객체를 넣어줘야 합니다.
현재 프로젝트에서 UserDetails는 커스텀 되어있는 상태이고 유니크 값인 userId를 넘겨줘야 하는데
기본적인 테스트 코드 짜기도 어려운데 시큐리티를 적용하려니깐 너무 어려웠습니다.
또, 파일까지 넘기는 구조여서 multipartformdata까지 테스트해야 했습니다...
GroupTabControllerTest
package team1.togather.controller.grouptab;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import team1.togather.config.file.FileStore;
import team1.togather.dto.GroupTabDto;
import team1.togather.dto.request.GroupTabRequestDto;
import team1.togather.security.configs.TestSecurityConfig;
import team1.togather.security.configs.annotation.WithMember;
import team1.togather.service.GroupTabService;
import java.io.FileInputStream;
import java.io.IOException;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.then;
import static org.mockito.BDDMockito.willDoNothing;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@DisplayName("View 컨트롤러 - 그룹")
@Import({TestSecurityConfig.class})
@WebMvcTest(GroupTabController.class)
class GroupTabControllerTest {
@Autowired
private MockMvc mvc;
@MockBean
private GroupTabService groupTabService;
@MockBean
private FileStore fileStore;
@WithMockUser
@DisplayName("[view][GET] 새 모임 개설 페이지")
@Test
void givenNothing_whenRequesting_thenReturnsNewGroupTabPage() throws Exception {
// Given
// When & Then
mvc.perform(get("/groupTabs/new"))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
.andExpect(view().name("groupTabs/createGroupTabForm"));
}
@WithMember(value = "jisu1")
@DisplayName("[view][POST] 새 모임 개설 - 정상 호출")
@Test
void givenNewGroupTabInfo_whenRequesting_thenSavesNewGroupTab() throws Exception {
// Given
GroupTabRequestDto groupTabRequestDto = createGroupTabRequestDto();
willDoNothing().given(groupTabService).saveGroupTab(any(GroupTabDto.class));
// When & Then
mvc.perform(
multipart("/groupTabs/new")
.file("image", groupTabRequestDto.getAttachFile().getBytes())
.param("groupLocation", groupTabRequestDto.getGroupLocation())
.param("groupName", groupTabRequestDto.getGroupName())
.param("groupIntro", groupTabRequestDto.getGroupIntro())
.param("interest", groupTabRequestDto.getInterest())
.param("memberLimit", String.valueOf(groupTabRequestDto.getMemberLimit()))
.with(requestPostProcessor -> { // 3
requestPostProcessor.setMethod("POST");
return requestPostProcessor;
})
.contentType(MediaType.MULTIPART_FORM_DATA) // 4
.with(csrf())
)
.andExpect(status().is3xxRedirection())
.andExpect(view().name("redirect:/"))
.andExpect(redirectedUrl("/"));
then(groupTabService).should().saveGroupTab(any(GroupTabDto.class));
}
private GroupTabRequestDto createGroupTabRequestDto() throws IOException {
return GroupTabRequestDto.of("서울", "테스트 모임", "테스트 모임 소개","관심사",10, createFile());
}
private MockMultipartFile createFile() throws IOException {
return new MockMultipartFile("image",
"test.png",
MediaType.IMAGE_PNG_VALUE,
new FileInputStream("C:/Users/kjs76/Desktop/jisu/file/test.png"));
}
}
제가 이번에 포스팅할 것은 새 모임 개설 - 정상 호출 부분입니다.
createGroupTabRequestDto() 메서드를 통해 requestDto를 만들어줍니다.
여기서 MultipartFile은 MockMultipartFile 객체를 리턴해주는 createFile() 메서드를 통해 생성해줍니다.
그 후의 groupTabService에서 saveGroupTab 메소드를 호출하는지 테스트합니다.
그 후의 MockMvc로 테스트하게 됩니다.
여기서 MockMvc란 실제 객체와 비슷하지만 테스트에 필요한 기능만 가지는 가짜 객체를 만들어서 애플리케이션 서버에 배포하지 않고도 스프링 MVC 동작을 재현할 수 있는 클래스를 의미합니다.
MockMvc에서 지원하는 것들
- mvc.perform(get("/groupTabs/new")) : MockMvc를 통해 /groupTabs/new 주소로 multipart 요청
- file("attachFile", groupTabRequestDto.getAttachFile(). getByte()) : 나는 requestDto에 multipar필드가 있어서 이것을 넘겨주었다.
- . andExpect(status(). is 3 xxRedirection()): mvc.perform의 결과를 검증, 현재 컨트롤러에서 redirect를 사용하고 있다. 이 외에도 isOk, isBadRequest 등 다양하게 있다.
- . param: API 테스트할 때 사용될 요청 파라미터를 설정한다.
- . andExpected(jsonPath("$. name", is(name))) : JSON 응답 값을 필드별로 검증할 수 있는 메서드, $를 기준으로 필드명을 명시한다.
- andExpected(view(). name("redirect:/") : 리턴해주는 뷰의 네임을 검사한다.
- andExpected(redirectedUrl("/") : 리다이렉트 해주는 url 검사한다.
- . andDo(print()): 요청, 응답에 대해서 정리해서 보여줌!
이렇게까지만 하면 일반적인 MultipartFile 테스트를 할 수 있습니다.
하지만 저는 GroupTabController에서 인증된 사용자의 객체를 가져와야 하기 때문에 이 테스트가 통과하지 않고 있었습니다.
그래서 구글링 하던 도중에 @WithUserDetails라는 어노테이션을 찾게 되어서 시도하였지만 커스텀한 userDetails를 찾을 수 없다는 에러,
데이터를 @BeforeTestMethod를 사용하여 멤버를 setUp 해줘도 유저를 찾을 수 없다는 에러,,, 와 함께 3일을 날렸습니다...
포기하려던 찰나에 귀인이 나타나셔서 글을 보고 성공하게 되었습니다.... 감사합니다..
@WithSecurityContext
이 방식은 커스텀 어노테이션을 만들어서 Authentication 객체를 바인딩하는 과정을 가진 다고 합니다.
컨트롤러에서 바인딩받고 있는 Authentication 이 제가 커스텀한 userDetailes를 통한 member 받기 위해 사용하였습니다.
위의 컨트롤러를 테스트하기 위해서는 member가 SecurityCOntextHolder에 담겨있어야 합니다.
그래서 커스텀 어노테이션을 만들어야 합니다. 현재 메서드 위의 @WithMember 어노테이션이 있는데 이것을 왜 만들고 어떻게 사용하는지 알아보겠습니다.
@WithMember(value = "jisu1")
어노테이션 만들기
@WithSecurityContext를 적용하기 위해서는 어노테이션을 만들어야 합니다. 그 이유는
해당 인터페이스를 구현해야 하는데 제네릭 바운디 드 타입이 Annotation 이기 때문입니다.
따라서 다음과 같이 커스텀 어노테이션을 만들어 주면 됩니다.
@WithMember
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMemberSecurityContextFactory.class)
public @interface WithMember {
String value();
}
해당 어노테이션은 런타임까지 유지해야 하기 때문에 Retiention은 RetentionPlicy.RUNTIME을 주셔야 합니다.
어노테이션이 들고 있는 값은 각각의 서비스마다 Authentication 객체를 생성함에 있어 필요한 속성을 채워 주면 됩니다.
저는 어차피 테스트를 하기 위함이어서 userId만 할당해주기 위해 String 값 하나만 넣어주었습니다.
그리고 앞서 말한 인터페이스를 구현하고 있는 클래스를 명시해주면 됩니다.
WithSecurityContextFactory
package team1.togather.security.configs.annotation;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.test.context.support.WithSecurityContextFactory;
import team1.togather.domain.member.Member;
import team1.togather.domain.member.Role;
import java.util.HashSet;
import java.util.Set;
import java.util.stream.Collectors;
public class WithMemberSecurityContextFactory implements WithSecurityContextFactory<WithMember> {
@Override
public SecurityContext createSecurityContext(WithMember withMember) {
String userId = withMember.value();
Member of = Member.of(
"김지수",
userId,
"1234",
"jisu@email.com",
"2022-08-07",
"M",
"category_first",
"category_second",
"category_third",
roles("ROLE_USER", "사용자권한")
);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
of,
null,
of.getMemberRoles()
.stream()
.map(Role::getRoleName)
.map(SimpleGrantedAuthority::new).collect(Collectors.toList())
);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
return context;
}
private Set<Role> roles(String roleName, String roleDesc) {
Set<Role> roles = new HashSet<>();
Role role = new Role();
role.setRoleName(roleName);
role.setRoleDesc(roleDesc);
roles.add(role);
return roles;
}
}
진짜 한 번만 천천히 읽으면 엄청 간단한 로직입니다.
각각의 서비스에서 사용하고 있는 인증 객체를 만들어서 저는 Member 타입을 사용하고 있어서 이것을 SecurityContext에 세팅해주면 끝납니다.
SecurityContext에 Authentication을 set 하기 위해선 Authentication 타입이 필요하기 때문에
스프링 시큐리티가 가지고 있는 UsernamePasswordAuthenticationToken을 이용하여 어댑터 역할로 사용했습니다.
따라서 각자의 서비스에 어댑터 역할을 하는 클래스가 있다면 해당하는 클래스를 사용하시면 됩니다.
이제 테스트에 인증 객체가 필요한 경우 이번 글에서 만든
@WithMember(value = "jisu1")
애노테이션을 테스트 위에 붙여주시면 됩니다.
해보니깐 몇 줄 없는 코드인데 정확히 알고 있어야 해결할 수 있는 문제였습니다.
테스트 작성에는 사람마다 여러 가지 방법이 있다고 합니다.
테스트에서 나타내고자 하는 목적을 가장 뚜렷하게 표현할 수 있는 방법을 찾아서 선택하는 것이 중요합니다.
아직 저만의 방법을 찾고 있고 현재 글은 제 문제에 대한 글을 써주신 블로그를 참고하여 작성하였습니다.
reference
'Spring Boot > Spring Security' 카테고리의 다른 글
[Spring Security] @AuthenticationPrincipal 어노테이션 사용하기 (0) | 2022.08.17 |
---|---|
[Spring Security] Authentication 인증 (0) | 2022.06.30 |
[Spring Securtiy] Spring Security의 Filter (0) | 2022.06.29 |
[Spring Security] CSRF (0) | 2022.06.29 |
[Spring Security] 예외 처리 및 요청 캐시 필터 (0) | 2022.06.29 |
댓글