오늘은 Member API에 관해 정리할 예정이다.
전반적인 코드 설명과 구조 변경에 대한 설명을 할 예정이다.
기존에 Exception과 Advice를 전부 묶어서 관리하였다.
그 결과 각자 개발 후 머지를 하는 과정에서 conflict이 자주 발생하였다.
또한 예외가 많아질수록 관리하기 어렵다는 의견도 많았다. 따라서 도메인별로 예외를 관리하도록 하였다.
Member API는 현재 간단하게 되어있다.
추후 추가될 예정이지만 현재는 Member 조회와 비밀번호 변경이 만들어져 있다.
Member
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String password;
protected Member() {
}
private Member(final Long id, final String email, final String password) {
this.id = id;
this.email = email;
this.password = password;
}
public static Member from(final String email, final String password) {
validateCreateMember(email, password);
return new Member(null, email, password);
}
public static Member from(final Long id, final String email, final String password) {
validateCreateMember(email, password);
return new Member(id, email, password);
}
private static void validateCreateMember(final String email, final String password) {
if (!isEmailFormat(email)) {
throw new MemberEmailFormatInvalidException(email);
}
if (isEmpty(password)) {
throw new MemberPasswordBlankException();
}
}
private static boolean isEmailFormat(final String email) {
return Pattern.matches("^[_a-z0-9-]+(.[_a-z0-9-]+)*@(?:\\w+\\.)+\\w+$", email);
}
private static boolean isEmpty(final String password) {
return password == null || password.isBlank();
}
public boolean isEmailSameWith(final String email) {
return this.email.equals(email);
}
public boolean isPasswordSameWith(final String password) {
return this.password.equals(password);
}
public void changePassword(final String newPassword) {
this.password = newPassword;
}
public Long getId() {
return id;
}
public String getEmail() {
return email;
}
public String getPassword() {
return password;
}
}
멤버의 경우 이메일로 로그인을 할 예정이기에 단순하게 이메일과 비밀번호만을 가지고 있다.
또한 여러 validation을 통해 검증을 한다.
Member Controller
@RequestMapping("/api/members")
@RestController
public class MemberController {
private final MemberService memberService;
public MemberController(final MemberService memberService) {
this.memberService = memberService;
}
@GetMapping
public ResponseEntity<MemberResponse> getUsingMemberIdAndEmail(@JwtLogin final Member member) {
return ResponseEntity.status(HttpStatus.OK)
.body(memberService.getUsingMemberIdAndEmail(member));
}
@PatchMapping
public ResponseEntity<MemberResponse> changeMemberPassword(@JwtLogin final Member member,
@RequestBody @Valid final ChangePasswordRequest changePasswordRequest) {
return ResponseEntity.status(HttpStatus.OK)
.body(memberService.changeMembersPassword(member, changePasswordRequest));
}
}
앞서 말했듯이 간단하게 조회와 비밀번호 변경이 만들어져 있다.
Member Service
@Service
public class MemberService {
private final MemberRepository memberRepository;
public MemberService(final MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Transactional(readOnly = true)
public MemberResponse getUsingMemberIdAndEmail(final Member member) {
return MemberResponse.from(member);
}
@Transactional
public MemberResponse changeMembersPassword(final Member member, final ChangePasswordRequest changePasswordRequest) {
if (!member.isPasswordSameWith(changePasswordRequest.getCurrentPassword())) {
throw new NotCurrentPasswordException();
}
validateNewPassword(changePasswordRequest);
member.changePassword(changePasswordRequest.getNewPassword());
return MemberResponse.from(member);
}
public void validateNewPassword(final ChangePasswordRequest changePasswordRequest) {
String newPassword = changePasswordRequest.getNewPassword();
String newPasswordCheck = changePasswordRequest.getNewPasswordCheck();
if (!newPassword.equals(newPasswordCheck)) {
throw new NewPasswordsNotMatchingException();
}
}
}
서비스로직도 매우 간단하다. 조회의 경우 단순하게 멤버의 정보를 Dto에 담아서 반환하는 것이다.
비밀번호 변경의 경우 validation을 통해 검증이 이루어진 다음 변경이 된다.
Test
로직 개선
통합 테스트 코드에서는 테스트 격리가 필요하다.
통합 테스트가 순서대로 작동될 때 이전 테스트에 영향을 줄 수 있기 때문이다.
아래와 같이 @SpringBootTest와 @Transactional을 붙여주었다.
@Transactional
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
또한 RANDOM_PORT 속성을 추가로 주고 있었다.
문제는 @Transactional 어노테이션이 붙여도 작동하지 않는다는 것이다.
RANDOM_PORT 혹은 DEFINED_PORT 속성을 사용하는 경우 실제 서블릿 환경으로 별도의 스레드에서 테스트 작동해서 트랜잭션이 분리되기 때문이었다.
그래서 RANDOM_PORT 속성이 붙은 통합테스트 환경에서 테스트 수행시마다 @Transcational과 같은 역할을 해주기 위해서 기존에 Sql문을 주입해서 Truncate로 테이블을 초기화해 주는 방식을 이용했었다.
SET FOREIGN_KEY_CHECKS = 0;
TRUNCATE TABLE Member;
TRUNCATE TABLE Board;
SET FOREIGN_KEY_CHECKS = 1;
위의 코드의 문제는 통합테스트 마다 data.sql을 붙여주여야 하고, 다른 어노테이션도 중복된다.
또한 테이블이 생길 때마다 추가해주어야 한다는 단점이 존재하였다.
이를 해결하고자 아래와 같이 수정하였다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class IntegrationTest {
@Autowired
private JdbcTemplate jdbcTemplate;
@LocalServerPort
private int port;
@BeforeEach
void init() {
RestAssured.port = this.port;
List<String> truncateAllTablesQuery = jdbcTemplate.queryForList("SELECT CONCAT('TRUNCATE TABLE ', TABLE_NAME, ';') AS q FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'chatUniv'", String.class);
truncateAllTables(truncateAllTablesQuery);
}
private void truncateAllTables(final List<String> truncateAllTablesQuery) {
jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0");
for (String truncateQuery : truncateAllTablesQuery) {
jdbcTemplate.execute(truncateQuery);
}
jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1");
}
}
중요한 부분은 truncateAllTables인데, 테스트 격리를 위해 sql을 작성하였다.
FOREIGN KEY_CHECKS = 0은 쿼리문으로 제약 조건을 해제하는 것을 의미한다.
이를 통해 테이블들을 쉽게 제거할 수 있다.
모든 테이블을 제거 후 다시 FOREIGN KEY_CHECKS = 1을 통해 제약 조건을 설정한다.
MemberController
우선 통합테스트이다.
public class MemberControllerIntegrationTest extends IntegrationTest {
private static final String BEARER_ = "Bearer ";
@Autowired
private AuthService authService;
@DisplayName("토큰을 통해 현재 회원의 아이디와 이메일을 조회한다.")
@Test
void get_current_members_id_and_email() {
// given
authService.register(new MemberCreateRequest("a@a.com", "1234"));
TokenResponse tokenResponse = authService.login(new MemberLoginRequest("a@a.com", "1234"));
// when
Response response = RestAssured.given()
.header("Authorization", BEARER_ + tokenResponse.getAccessToken())
.when()
.get("/api/members");
// then
Assertions.assertAll(() -> {
response.then()
.statusCode(HttpStatus.OK.value());
Assertions.assertEquals("1", response.body().jsonPath().get("memberId").toString());
Assertions.assertEquals("a@a.com", response.body().jsonPath().get("email").toString());
});
}
@DisplayName("토큰과 현재 비밀번호, 새 비밀번호, 새 비밀번호 재입력을 입력해 회원의 비밀번호를 수정한다.")
@Test
void change_current_members_password() {
// given
authService.register(new MemberCreateRequest("a@a.com", "1234"));
TokenResponse tokenResponse = authService.login(new MemberLoginRequest("a@a.com", "1234"));
ChangePasswordRequest changePasswordRequest =
new ChangePasswordRequest("1234", "5678", "5678");
// when
Response response = RestAssured.given()
.header("Authorization", BEARER_ + tokenResponse.getAccessToken())
.contentType(ContentType.JSON)
.body(makeJson(changePasswordRequest))
.when()
.patch("/api/members");
// then
Assertions.assertAll(() -> {
response.then()
.statusCode(HttpStatus.OK.value());
Assertions.assertEquals("1", response.body().jsonPath().get("memberId").toString());
Assertions.assertEquals("a@a.com", response.body().jsonPath().get("email").toString());
});
}
private String makeJson(Object object) {
try {
return new ObjectMapper().writeValueAsString(object);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}
이후 단위 테스트이다.
@WebMvcTest(MemberController.class)
@AutoConfigureRestDocs
public class MemberControllerUnitTest {
@MockBean
private MemberService memberService;
@MockBean
private ArgumentResolverConfig argumentResolverConfig;
@Autowired
private MockMvc mockMvc;
private MockTestHelper mockTestHelper;
@BeforeEach
void init() {
mockTestHelper = new MockTestHelper(mockMvc);
}
@DisplayName("현재 로그인한 회원의 인조키와 이메일을 반환한다.")
@Test
public void get_using_member_id_and_email() throws Exception {
//given
Member member = createMember();
MemberResponse memberResponse = MemberResponse.from(member);
given(memberService.getUsingMemberIdAndEmail(any(Member.class))).willReturn(memberResponse);
// when & then
mockTestHelper.createMockRequestWithTokenAndWithoutContent(get("/api/members"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.memberId").value(member.getId()))
.andExpect(jsonPath("$.email").value(member.getEmail()))
.andDo(MockMvcResultHandlers.print())
.andDo(customDocument("member_id_and_email",
requestHeaders(
headerWithName(HttpHeaders.AUTHORIZATION).description("로그인 후 제공되는 Bearer 토큰")
),
responseFields(
fieldWithPath(".memberId").description("로그인한 MEMBER의 ID"),
fieldWithPath(".email").description("로그인한 MEMBER의 EMAIL")
)
))
.andReturn();
}
@DisplayName("토큰이 없을 때 현재 회원정보를 조회하면 401에러와 토큰이 없음이 반환된다.")
@Test
public void fail_to_get_using_member_id_and_email_No_Token() throws Exception {
// given
// when & then
mockTestHelper.createMockRequestWithoutTokenAndContent(get("/api/members"))
.andExpect(status().isUnauthorized())
.andDo(MockMvcResultHandlers.print());
}
@DisplayName("현재 비밀번호, 새 비밀번호, 새 비밀번호 재입력을 성공적으로 입력하면 비밀번호가 교체된다. ")
@Test
public void change_current_members_password() throws Exception {
//given
Member member = createMember();
MemberResponse memberResponse = MemberResponse.from(member);
ChangePasswordRequest changePasswordRequest =
new ChangePasswordRequest("1234", "5678", "5678");
given(memberService.changeMembersPassword(any(Member.class), any(ChangePasswordRequest.class)))
.willReturn(memberResponse);
// when & then
mockTestHelper.createMockRequestWithTokenAndContent(patch("/api/members"), changePasswordRequest)
.andExpect(status().isOk())
.andExpect(jsonPath("$.memberId").value(member.getId()))
.andExpect(jsonPath("$.email").value(member.getEmail()))
.andDo(MockMvcResultHandlers.print())
.andDo(customDocument("change_member_password",
requestHeaders(
headerWithName(HttpHeaders.AUTHORIZATION).description("로그인 후 제공되는 Bearer 토큰")
),
requestFields(
fieldWithPath(".currentPassword").description("기존 비밀번호"),
fieldWithPath(".newPassword").description("새 비밀번호"),
fieldWithPath(".newPasswordCheck").description("새 비밀번호 재입력")
),
responseFields(
fieldWithPath(".memberId").description("로그인한 MEMBER의 ID"),
fieldWithPath(".email").description("로그인한 MEMBER의 EMAIL")
)
));
}
@DisplayName("토큰이 없을 경우 401에러와 함께 비밀번호 변경을 실패한다. ")
@Test
public void fail_to_change_password_Unauthorized() throws Exception {
//given
ChangePasswordRequest changePasswordRequest =
new ChangePasswordRequest("1234", "5678", "5678");
// when & then
mockTestHelper.createMockRequestWithoutTokenAndWithContent(patch("/api/members"), changePasswordRequest)
.andExpect(status().isUnauthorized());
}
@DisplayName("입력한 현재 비밀번호가 기존과 다르면 400에러와 메시지를 반환하며 비밀번호 변경을 실패한다.")
@Test
public void fail_to_change_password_Not_Current_Password() throws Exception {
//given
ChangePasswordRequest changePasswordRequest =
new ChangePasswordRequest("5678", "5678", "5678");
given(memberService.changeMembersPassword(any(Member.class), any(ChangePasswordRequest.class)))
.willThrow(NotCurrentPasswordException.class);
// when & then
mockTestHelper.createMockRequestWithTokenAndContent(patch("/api/members"), changePasswordRequest)
.andExpect(status().isBadRequest())
.andDo(MockMvcResultHandlers.print());
}
@DisplayName("입력한 현재 비밀번호가 기존과 다르면 400에러와 메시지를 반환하며 비밀번호 변경을 실패한다.")
@Test
public void fail_to_change_password_New_Password_Unmatched() throws Exception {
//given
ChangePasswordRequest changePasswordRequest =
new ChangePasswordRequest("1234", "5678", "9012");
given(memberService.changeMembersPassword(any(Member.class), any(ChangePasswordRequest.class)))
.willThrow(NewPasswordsNotMatchingException.class);
// when & then
mockTestHelper.createMockRequestWithTokenAndContent(patch("/api/members"), changePasswordRequest)
.andExpect(status().isBadRequest())
.andDo(MockMvcResultHandlers.print());
}
}
성공 로직의 경우 문서화가 되어있지만(RestDoc) 예외의 경우 아직 문서화가 이루어지지 않았다.
이는 추후 추가할 예정이다.
위 로직을 문서화하면 아래와 같은 결과가 나온다.
이렇게 깔끔하게 나오게 된다.
나머지 테스트의 결과도 통과하는 것을 볼 수 있다. (자세한 코드는 아래 깃허브 링크가 있습니다.)
자세한 코드는 아래 깃허브 주소를 통해 확인할 수 있습니다.
https://github.com/kimtaesoo99/ChatUniv
'프로젝트 > ChatUniv' 카테고리의 다른 글
[프로젝트] ChatUniv 페이징 최적화 (2) | 2023.09.02 |
---|---|
[프로젝트] ChatUniv GPT 적용 (0) | 2023.08.26 |
[프로젝트] ChatUniv Comment API, 동적 테스트 #4 (0) | 2023.07.30 |
[프로젝트] ChatUniv Board API #3 (0) | 2023.07.16 |
[프로젝트] ChatUniv 전반적인 설계 및 Auth구조 #1 (0) | 2023.07.04 |