공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.
회원 관리 페이지를 구현하고 그에 따른 테스트를 작성한다. 이번 작업은 기존에 했던 작업과 유사하게 컨트롤러와 서비스 계층을 중심으로 하여 작성한다.
1. UserAccountClientResponse 클래스 작성
이 클래스는 외부 API의 회원 목록 응답을 처리하는 역할을 한다. record를 사용해 불변 객체로 정의되어 있으며, 두 주요 필드인 Embedded와 Page를 포함하고 있다. 이 필드들은 각각 JSON의 _embedded와 page 필드와 매핑된다.
package org.example.projectboardadmin.dto.response;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.example.projectboardadmin.dto.UserAccountDto;
import java.util.List;
public record UserAccountClientResponse(
@JsonProperty("_embedded") Embedded embedded,
@JsonProperty("page") Page page
) {
public static UserAccountClientResponse empty() {
return new UserAccountClientResponse(
new Embedded(List.of()),
new Page(1, 0, 1, 0)
);
}
public static UserAccountClientResponse of(List<UserAccountDto> userAccounts) {
return new UserAccountClientResponse(
new Embedded(userAccounts),
new Page(userAccounts.size(), userAccounts.size(), 1, 0)
);
}
public List<UserAccountDto> userAccounts() { return this.embedded().userAccounts(); }
public record Embedded(List<UserAccountDto> userAccounts) {}
public record Page(
int size,
long totalElements,
int totalPages,
int number
) {}
}
- empty() 메서드
- 비어 있는 기본값을 가진 객체를 반환한다. 이 메서드는 응답 데이터가 없을 때 사용되며, 빈 리스트와 초기화된 페이지 정보를 반환한다.
- of(List<UserAccountDto> userAccounts)
- 주어진 회원 계정 리스트를 사용해 UserAccountClientResponse 객체를 생성한다. 이때 Embedded는 해당 리스트를 감싸고, Page는 리스트의 크기를 바탕으로 설정된다.
- userAccounts()
- Embedded 객체 안에 포함된 회원 계정 리스트를 반환하며, 이를 통해 API 응답에서 직접 계정 리스트를 가져올 수 있다.
- 내부의 Embedded 클래스
- 회원 계정 리스트를 포함하고, Page 클래스는 페이지 정보(크기, 총 요소 수, 총 페이지 수, 현재 페이지 번호)를 담고 있다.
2. UserAccountDto 수정
이 클래스는 회원 계정의 데이터를 담기 위해 사용되는 Record 클래스다. 기존에는 회원의 권한 정보를 포함하기 위해 RoleType 필드를 가지고 있었지만, 이번 변경에서 해당 필드는 삭제되었다. 어드민 시스템은 주로 관리 기능을 수행하기 때문에 권한 정보보다는 사용자 기본 정보에 집중하기 위한 설계 변경이다.
package org.example.projectboardadmin.dto;
import org.example.projectboardadmin.domain.constant.RoleType;
import java.time.LocalDateTime;
import java.util.Set;
public record UserAccountDto(
String userId,
String email,
String nickname,
String memo,
LocalDateTime createdAt,
String createdBy,
LocalDateTime modifiedAt,
String modifiedBy
) {
public static UserAccountDto of(String userId, String email, String nickname, String memo) {
return UserAccountDto.of(userId, email, nickname, memo, null, null, null, null);
}
public static UserAccountDto of(String userId, String email, String nickname, String memo, LocalDateTime createdAt, String createdBy, LocalDateTime modifiedAt, String modifiedBy) {
return new UserAccountDto(userId, email, nickname, memo, createdAt, createdBy, modifiedAt, modifiedBy);
}
}
- RoleType 필드 삭제
기존에는 Set<RoleType> 필드를 통해 사용자의 역할(예: ADMIN, USER 등)을 관리했다. 하지만 어드민 모듈에서는 이러한 역할 정보를 받을 필요가 없다고 판단했다. 어드민 모듈의 경우 역할에 따라 접근 권한을 분리하기보다는, 모든 사용자가 동일한 관리 기능을 사용할 수 있는 구조로 설계된다. 이러한 설계 변경으로 인해 DTO에서 불필요한 필드를 제거하고 간결하게 유지할 수 있었다. - 회원 계정 관련 필드
이 클래스는 사용자의 기본 정보(아이디, 이메일, 닉네임, 메모)와 생성 및 수정 정보를 담고 있다. 이는 어드민 모듈에서 관리하는 데 필요한 최소한의 정보다. - of 메서드
UserAccountDto 객체를 생성하기 위한 정적 팩토리 메서드로, 기본 정보만 설정하거나, 생성 및 수정 정보를 포함한 객체를 생성할 수 있다. 어드민에서 필요한 정보에 맞게 객체를 유연하게 생성할 수 있도록 설계되었다.
3. UserAccountManagementService 작성
회원 관리와 관련된 비즈니스 로직을 처리하는 서비스 계층을 작성한다.
package org.example.projectboardadmin.service;
import lombok.RequiredArgsConstructor;
import org.example.projectboardadmin.dto.UserAccountDto;
import org.springframework.stereotype.Service;
import java.util.List;
@RequiredArgsConstructor
@Service
public class UserAccountManagementService {
public List<UserAccountDto> getUserAccounts() {
return List.of();
}
public UserAccountDto getUserAccount(String userId) {
return null;
}
public void deleteUserAccount(String userId) {
}
}
- 기본적인 CRUD 메서드들이 포함되어 있으며, 실제 구현은 추후 추가된다.
4. 테스트 코드 수정
컨트롤러 테스트에서 RoleType 필드가 제거된 새로운 DTO 구조를 반영하여 테스트 코드를 수정한다.
기존에는 Set<RoleType> 필드를 사용해 회원의 역할을 설정했지만, 어드민 모듈에서는 해당 필드가 필요 없기 때문에 이를 테스트 코드에서도 제거했다.
private UserAccountDto createUserAccountDto() {
return UserAccountDto.of(
"ecTest",
//Set.of(RoleType.ADMIN), 삭제 됨
"eunchan-test@email.com",
"eunchan-test",
"test memo"
);
}
5. 컨트롤러 테스트 작성
회원 관리 페이지와 API 요청을 테스트하는 코드로, 어드민 시스템의 핵심 기능을 검증한다.
이 테스트는 서비스 계층과 상호작용하며, 각각의 기능이 정상적으로 동작하는지 확인하는 목적을 가진다. 테스트 코드는 MockMvc를 이용해 컨트롤러의 HTTP 요청과 응답을 시뮬레이션한다.
package org.example.projectboardadmin.controller;
import org.example.projectboardadmin.config.SecurityConfig;
import org.example.projectboardadmin.dto.UserAccountDto;
import org.example.projectboardadmin.service.UserAccountManagementService;
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.test.web.servlet.MockMvc;
import java.util.List;
import static org.mockito.BDDMockito.*;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@DisplayName("컨트롤러 - 회원 관리")
@Import(SecurityConfig.class)
@WebMvcTest(UserAccountManagementController.class)
class UserAccountManagementControllerTest {
private final MockMvc mvc;
@MockBean private UserAccountManagementService userAccountManagementService;
public UserAccountManagementControllerTest(@Autowired MockMvc mvc) {
this.mvc = mvc;
}
@DisplayName("[view][GET] 회원 관리 페이지 - 정상 호출")
@Test
void givenNothing_whenRequestingUserAccountManagementView_thenReturnsUserAccountManagementView() throws Exception {
// Given
given(userAccountManagementService.getUserAccounts()).willReturn(List.of());
// When & Then
mvc.perform(get("/management/user-accounts"))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
.andExpect(view().name("management/user-accounts"))
.andExpect(model().attribute("userAccounts", List.of()));
then(userAccountManagementService).should().getUserAccounts();
}
@DisplayName("[data][GET] 회원 1개 - 정상 호출")
@Test
void givenUserAccountId_whenRequestingUserAccount_thenReturnsUserAccount() throws Exception {
// Given
String userId = "eunchan";
UserAccountDto userAccountDto = createUserAccountDto(userId, "Eunchan");
given(userAccountManagementService.getUserAccount(userId)).willReturn(userAccountDto);
// When & Then
mvc.perform(get("/management/user-accounts/" + userId))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.userId").value(userId))
.andExpect(jsonPath("$.nickname").value(userAccountDto.nickname()));
then(userAccountManagementService).should().getUserAccount(userId);
}
@DisplayName("[view][POST] 회원 삭제 - 정상 호출")
@Test
void givenUserAccountId_whenRequestingDeletion_thenRedirectsToUserAccountManagementView() throws Exception {
// Given
String userId = "eunchan";
willDoNothing().given(userAccountManagementService).deleteUserAccount(userId);
// When & Then
mvc.perform(
post("/management/user-accounts/" + userId)
.with(csrf())
)
.andExpect(status().is3xxRedirection())
.andExpect(view().name("redirect:/management/user-accounts"))
.andExpect(redirectedUrl("/management/user-accounts"));
then(userAccountManagementService).should().deleteUserAccount(userId);
}
private UserAccountDto createUserAccountDto(String userId, String nickname) {
return UserAccountDto.of(
userId,
"eunchan-test@email.com",
nickname,
"test memo"
);
}
}
- 회원 관리 페이지 호출 테스트
- 요청 URL: GET /management/user-accounts
- 시나리오: 회원 관리 페이지를 요청하면, 빈 회원 목록을 반환하고, 정상적으로 HTML 뷰가 렌더링 되어야 한다.
- 검증 항목: HTTP 상태 코드 200, 뷰 이름, 모델에 빈 회원 목록이 전달되는지 확인한다.
- 단일 회원 정보 조회 테스트
- 요청 URL: GET /management/user-accounts/{userId}
- 시나리오: 특정 회원의 ID로 정보를 조회하면, 해당 회원의 정보를 JSON 형식으로 반환해야 한다.
- 검증 항목: HTTP 상태 코드 200, 반환된 JSON의 userId와 nickname이 기대한 값과 일치하는지 확인한다.
- 회원 삭제 요청 테스트
- 요청 URL: POST /management/user-accounts/{userId}
- 시나리오: 특정 회원을 삭제 요청하면, 삭제가 정상적으로 이루어지고 회원 관리 페이지로 리다이렉트된다.
- 검증 항목: HTTP 상태 코드 3xx, 리다이렉트 URL이 기대한 값과 일치하는지 확인한다.
- 테스트에서 반복적으로 사용할 DTO는 createUserAccountDto 메서드로 생성했다.
6. 서비스 테스트 작성
RestTemplate을 이용해 외부 API와 통신하는 서비스 계층을 테스트한다.
package org.example.projectboardadmin.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.example.projectboardadmin.dto.UserAccountDto;
import org.example.projectboardadmin.dto.properties.ProjectProperties;
import org.example.projectboardadmin.dto.response.UserAccountClientResponse;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.autoconfigure.web.client.AutoConfigureWebClient;
import org.springframework.boot.test.autoconfigure.web.client.RestClientTest;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.client.MockRestServiceServer;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
@ActiveProfiles("test")
@DisplayName("비즈니스 로직 - 회원 관리")
class UserAccountManagementServiceTest {
// @Disabled("실제 API 호출 결과 관찰용이므로 평상시엔 비활성화")
@DisplayName("실제 API 호출 테스트")
@SpringBootTest
@Nested
class RealApiTest {
private final UserAccountManagementService sut;
@Autowired
public RealApiTest(UserAccountManagementService sut) {
this.sut = sut;
}
@DisplayName("회원 API을 호출하면, 회원 정보를 가져온다.")
@Test
void givenNothing_whenCallingUserAccountApi_thenReturnsUserAccountList() {
// Arrange
// Act
List<UserAccountDto> result = sut.getUserAccounts();
// Assert
System.out.println(result.stream().findFirst()); // 이 테스트의 목적은 API 호출 결과를 관찰하는 것
assertThat(result).isNotNull();
}
}
@DisplayName("API mocking 테스트")
@EnableConfigurationProperties(ProjectProperties.class)
@AutoConfigureWebClient(registerRestTemplate = true)
@RestClientTest(UserAccountManagementService.class)
@Nested
class RestTemplateTest {
private final UserAccountManagementService sut;
private final ProjectProperties projectProperties;
private final MockRestServiceServer server;
private final ObjectMapper mapper;
@Autowired
public RestTemplateTest(
UserAccountManagementService sut,
ProjectProperties projectProperties,
MockRestServiceServer server,
ObjectMapper mapper
) {
this.sut = sut;
this.projectProperties = projectProperties;
this.server = server;
this.mapper = mapper;
}
@DisplayName("회원 목록 API을 호출하면, 회원들을 가져온다.")
@Test
void givenNothing_whenCallingUserAccountsApi_thenReturnsUserAccountList() throws Exception {
// Given
UserAccountDto expectedUserAccount = createUserAccountDto("eunchan", "Eunchan");
UserAccountClientResponse expectedResponse = UserAccountClientResponse.of(List.of(expectedUserAccount));
server
.expect(requestTo(projectProperties.board().url() + "/api/userAccounts?size=10000"))
.andRespond(withSuccess(
mapper.writeValueAsString(expectedResponse),
MediaType.APPLICATION_JSON
));
// When
List<UserAccountDto> result = sut.getUserAccounts();
// Then
assertThat(result).first()
.hasFieldOrPropertyWithValue("userId", expectedUserAccount.userId())
.hasFieldOrPropertyWithValue("nickname", expectedUserAccount.nickname());
server.verify();
}
@DisplayName("회원 ID와 함께 회원 API을 호출하면, 회원을 가져온다.")
@Test
void givenUserAccountId_whenCallingUserAccountApi_thenReturnsUserAccount() throws Exception {
// Given
String userId = "eunchan";
UserAccountDto expectedUserAccount = createUserAccountDto(userId, "Eunchan");
server
.expect(requestTo(projectProperties.board().url() + "/api/userAccounts/" + userId))
.andRespond(withSuccess(
mapper.writeValueAsString(expectedUserAccount),
MediaType.APPLICATION_JSON
));
// When
UserAccountDto result = sut.getUserAccount(userId);
// Then
assertThat(result)
.hasFieldOrPropertyWithValue("userId", expectedUserAccount.userId())
.hasFieldOrPropertyWithValue("nickname", expectedUserAccount.nickname());
server.verify();
}
@DisplayName("회원 ID와 함께 회원 삭제 API을 호출하면, 회원을 삭제한다.")
@Test
void givenUserAccountId_whenCallingDeleteUserAccountApi_thenDeletesUserAccount() throws Exception {
// Given
String userId = "eunchan";
server
.expect(requestTo(projectProperties.board().url() + "/api/userAccounts/" + userId))
.andExpect(method(HttpMethod.DELETE))
.andRespond(withSuccess());
// When
sut.deleteUserAccount(userId);
// Then
server.verify();
}
}
private UserAccountDto createUserAccountDto(String userId, String nickname) {
return UserAccountDto.of(
userId,
"eunchan-test@email.com",
nickname,
"test memo"
);
}
}
@DisplayName("회원 API를 호출하면, 회원 목록을 가져온다.")
- 실제 API를 호출해 회원 목록을 가져오는 기능을 테스트한다.
- UserAccountManagementService의 getUserAccounts() 메서드를 호출하여 실제 API에서 회원 데이터를 가져오는지 확인한다.
- 이 테스트는 실제 API 호출 결과를 확인하기 위해 사용되며, 응답 데이터가 제대로 받아지는지 검증한다. 실제 환경에서 API를 호출하기 때문에 테스트 결과는 API의 응답에 따라 달라질 수 있다.
@DisplayName("Mock API로 회원 목록을 호출하면, 회원 정보를 가져온다.")
- Mock API를 이용해 회원 목록을 가져오는 기능을 테스트한다.
- MockRestServiceServer를 이용해 외부 API를 모의로 설정하여, API가 호출되었을 때 예상된 결과를 반환하도록 설정한다.
- 반환된 리스트에서 userId와 nickname 값이 올바르게 매핑되는지 검증한다.
- 실제 API 호출 없이 진행되므로 네트워크 환경과 무관하게 테스트를 안정적으로 수행할 수 있다.
@DisplayName("특정 회원 ID로 API를 호출하면, 회원 정보를 가져온다.")
- 특정 회원 ID로 API를 호출하여 해당 회원 정보를 가져오는 기능을 테스트한다.
- API 요청에 대한 Mock 응답을 설정하고, 해당 userId로 요청이 들어왔을 때 예상된 회원 정보가 반환되는지 확인한다.
- 특정 사용자를 조회하는 기능이 정상적으로 동작하는지 검증하며, 반환된 userId와 nickname 값이 예상과 일치하는지 확인한다.
@DisplayName("회원 삭제 API를 호출하면, 회원을 삭제한다.")
- 특정 회원을 삭제하는 API를 테스트한다.
- userId를 전달해 API가 DELETE 요청을 처리하는지 검증하고, 삭제가 정상적으로 이루어졌는지 확인한다.
- Mock API를 통해 테스트를 진행하며, 삭제 요청이 올바르게 전달되고 해당 요청이 성공적으로 처리되는지 확인한다.
이번 서비스 테스트를 통해 외부 API와의 통신이 올바르게 이루어지고, 각 기능이 예상대로 동작하는지 확인했다. 실제 API 호출과 Mock API 테스트를 조합해 안정적이고 유연한 테스트 환경을 구축했다. 이러한 테스트 기반은 어드민 시스템의 유지보수와 기능 확장 시 큰 도움이 될 것이다.
'BackEnd > Project' 카테고리의 다른 글
[Admin] Ch03. 게시글 관리 페이지 구현(1) (0) | 2024.08.21 |
---|---|
[Admin] Ch03. 로그인 페이지 기능 테스트 정의 (0) | 2024.08.21 |
[Admin] Ch03. 회원 관리 페이지 기능 테스트 정의(1) (0) | 2024.08.20 |
[Admin] Ch03. 댓글 관리 페이지 기능 테스트 정의 (0) | 2024.08.20 |
[Admin] Ch03. 게시글 관리 페이지 기능 테스트 정의 (0) | 2024.08.20 |