프로젝트를 진행할 때, 몇가지 사항들을 우선순위로 두기로 했습니다.
테스트 코드 작성하기, Swagger 및 Logger, REST API 형식 최대한 지키기
작업량이 늘 수록 테스트 코드의 중요성이 커집니다.
JUnit을 사용하여 메서드 별로 코드를 살펴볼 수 있기 때문에,
문제가 되는 코드를 손쉽게 발견할 수 있습니다.
Logger 또한 테스트 코드의 중요성과 마찬가지로 코드의 흐름을 살펴볼 수 있기 때문에 중요합니다.
호출된 메서드와 매개변수를 명시하고, 그에 따른 결과를 출력하도록 했습니다.
이를 통해 메서드 실행에 따른 객체의 변화를 추적할 수 있습니다.
public Optional<Member> findById(Long id) {
Optional<Member> findMembers = memberRepository.findById(id);
if (findMembers.isPresent()) {
LOGGER.info("[findById] member data dose existed : {}", findMembers);
}
LOGGER.info("[findById] member data dose not existed, id: {}", id);
return findMembers;
}
테스트 초반부 @BeforeEach를 통해 더미데이터를 생성했습니다.
초기에 필요한 더미데이터의 양이 많아지면, 생성 메서드를 만들고 사용할까 고민하다가
애플리케이션이 실행될 때, sql문으로 더미데이터를 생성하도록 했습니다.
spring.jpa.defer-datasource-initialization=true
당장은 사용자에 대한 테스트가 전부지만, 앞으로 계속해서 테스트 코드를 작성할 것이고,
마찬가지로 더 많은 더미데이터를 생성할 것이기 때문입니다.
물론 메서드의 기능 테스트를 위한 더미데이터는 아래와 같이 직접 생성하기로 했습니다.
package com.pj.oil.service;
import com.pj.oil.dto.LoginRequestDto;
import com.pj.oil.dto.MemberUpdateFormDto;
import com.pj.oil.entity.Member;
import com.pj.oil.entity.Role;
import com.pj.oil.entity.UserStatus;
import com.pj.oil.repository.MemberRepository;
import jakarta.transaction.Transactional;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Rollback;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(SpringExtension.class)
@SpringBootTest
@Transactional
public class MemberServiceTest {
@Autowired
MemberService memberService;
@Autowired
MemberRepository memberRepository;
@BeforeEach
public void init() {
//로그인용
Member member = Member.builder()
.id(1L)
.userId("test1")
.password("pw")
.username("name")
.nickname("nickname1")
.email("email@email.com")
.role(Role.USER)
.issueDate(LocalDate.now())
.userStatus(UserStatus.NORMAL)
.build();
//수정용
Member member2 = Member.builder()
.id(2L)
.userId("test2")
.password("pw")
.username("name2")
.nickname("nickname2")
.email("email@email.com")
.role(Role.USER)
.issueDate(LocalDate.now())
.userStatus(UserStatus.NORMAL)
.build();
memberRepository.save(member);
memberRepository.save(member2);
}
@AfterEach
public void clean() {
memberRepository.deleteAll();
}
@Test
@Rollback
public void 회원가입() throws Exception {
//given
Member member = Member.builder()
.id(3L)
.userId("test3")
.password("pw")
.username("name")
.nickname("nickname3")
.email("email@email.com")
.role(Role.USER)
.issueDate(LocalDate.now())
.userStatus(UserStatus.NORMAL)
.build();
//when
Long saveId = memberService.signup(member);
//then
assertEquals(member.getUsername(), memberRepository.findById(saveId).get().getUsername());
System.out.println(memberRepository.findById(saveId).get().toString());
}
@Test
public void 중복회원예외() throws Exception {
//given
Member member = Member.builder()
.id(2L)
.userId("test2")
.password("pw")
.username("name")
.nickname("nickname2")
.email("email@email.com")
.role(Role.USER)
.issueDate(LocalDate.now())
.userStatus(UserStatus.NORMAL)
.build();
//when
//then
assertThrows(IllegalStateException.class, () -> {
memberService.signup(member);
});
}
@Test
public void 회원id로_조회() throws Exception {
//given
//when
Optional<Member> member = memberService.findById(1L);
//then
member.ifPresent(value -> assertEquals("test1", value.getUserId()));
}
@Test
public void 회원전체조회() throws Exception {
//given
//when
List<Member> member = memberService.findAllMembers();
//then
assertFalse(member.isEmpty());
}
@Test
public void 로그인() throws Exception {
LoginRequestDto dto = LoginRequestDto.builder()
.userId("test1")
.password("pw")
.build();
Long loginMemberId = memberService.login(dto);
Optional<Member> member = memberService.findById(loginMemberId);
//then
assertFalse(member.isEmpty());
}
@Test
public void 사용자수정() throws Exception {
String userId = "test2";
MemberUpdateFormDto dto = MemberUpdateFormDto.builder()
.nickname("new")
.password("newpw")
.build();
Long updateMemberId = memberService.updateMember(userId, dto);
Optional<Member> member = memberService.findById(updateMemberId);
System.out.println(member.toString());
//then
assertEquals(dto.getNickname(), member.get().getNickname());
}
}
REST API를 고도화하면 클라이언트에게 후속 작업을 수행하는 방법을 알려줄 수 있습니다.
HATEOAS는 REAT API를 향상하여 데이터를 반환할 뿐만 아니라,
리소스에 관한 작업을 수행하는 방법의 정보를 제공합니다.
작업 클래스 내부의 메서드를 EntityModel 객체에 link 형태로 담고, 클라이언트에게 보여줄 수 있습니다.
예컨대, 사용자 1명을 조회했다면, 조회한 사용자 정보와 모든 사용자를 조회할 수 있는 링크를 클라이언트에게 보냅니다.
코드에 대한 지식이 해박하지 않아도 후속작업을 처리하고 협업을 강화할 수 있다는 장점이 있습니다.
Swagger를 사용하여 엔드포인트와 메서드 역할, 스키마에 대한 설명을 추가하여 Swagger 페이지만 확인해도,
이 애플리케이션의 기능과 흐름을 손쉽게 파악할 수 있습니다.
package com.pj.oil.controller;
import com.pj.oil.dto.LoginRequestDto;
import com.pj.oil.dto.MemberUpdateFormDto;
import com.pj.oil.entity.Member;
import com.pj.oil.service.MemberService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.server.mvc.WebMvcLinkBuilder;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.net.URI;
import java.util.List;
import java.util.Optional;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn;
@RestController
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
@Operation(summary = "사용자 전체 조회", description = "db에 있는 사용자 전체 데이터 조회")
@ApiResponse(content = @Content(schema = @Schema(implementation = Member.class)))
@GetMapping(value = "/v1/member")
public ResponseEntity<List<Member>> findAllMembers() {
return ResponseEntity.ok(memberService.findAllMembers());
}
@Operation(summary = "사용자 1명 조회", description = "db에 있는 사용자 1명 데이터 사용자 id로 조회")
@ApiResponse(content = @Content(schema = @Schema(implementation = Member.class)))
@GetMapping(value = "/v1/member/{id}")
public ResponseEntity<EntityModel<Member>> findById(@PathVariable Long id) {
Optional<Member> findMembers = memberService.findById(id);
if (findMembers.isEmpty()) { // 예외처리 구문
ResponseEntity.noContent();
}
// 조회한 member 뿐만 아니라 모든 member 를 조회할 수 있는 link 를 entityModel 에 담기
EntityModel<Member> entityModel = EntityModel.of(findMembers.get());
WebMvcLinkBuilder link = linkTo(methodOn(this.getClass()).findAllMembers());
entityModel.add(link.withRel("all-members"));
return ResponseEntity.ok(entityModel);
}
@Operation(summary = "사용자 회원가입", description = "요청받은 사용자 1명 데이터 db에 저장")
@ApiResponse(content = @Content(schema = @Schema(implementation = Member.class)))
@PostMapping(value = "/v1/member")
public ResponseEntity<EntityModel<Member>> signup(@RequestBody Member member) {
Long savedMemberId = memberService.signup(member);
Optional<Member> savedMember = memberService.findById(savedMemberId);
if (savedMember.isEmpty()) { // 예외처리 구문
ResponseEntity.badRequest();
}
// 생성한 member 를 조회할 수 있는 uri 를 Location 헤더에 담기
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(savedMemberId)
.toUri();
// 생성한 member 뿐만 아니라 모든 member 를 조회할 수 있는 link 를 entityModel 에 담기
EntityModel<Member> entityModel = EntityModel.of(savedMember.get());
WebMvcLinkBuilder link = linkTo(methodOn(this.getClass()).findAllMembers());
entityModel.add(link.withRel("all-members"));
return ResponseEntity.created(location).body(entityModel);
}
@Operation(summary = "사용자 로그인", description = "요청받은 사용자 데이터와 db에 있는 사용자 데이터 검증 후 결과 반환")
@ApiResponse(content = @Content(schema = @Schema(implementation = Member.class)))
@PostMapping(value = "/v1/login")
public ResponseEntity<EntityModel<Member>> login(@RequestBody LoginRequestDto loginRequestDto) {
Long loginMemberId = memberService.login(loginRequestDto);
Optional<Member> loginMember = memberService.findById(loginMemberId);
if (loginMember.isEmpty()) { // 예외처리 구문
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
// 로그인한 member 를 조회할 수 있는 uri 를 Location 헤더에 담기
// login 경로로 시작했기 때문에 path 를 새롭게 설정함
URI location = ServletUriComponentsBuilder.fromCurrentContextPath()
.path("/v1/member/{id}")
.buildAndExpand(loginMemberId)
.toUri();
// 로그인한 member 뿐만 아니라 모든 member 를 조회할 수 있는 link 를 entityModel 에 담기
EntityModel<Member> entityModel = EntityModel.of(loginMember.get());
WebMvcLinkBuilder link = linkTo(methodOn(this.getClass()).findAllMembers());
entityModel.add(link.withRel("all-members"));
return ResponseEntity.ok().location(location).body(entityModel);
}
@Operation(summary = "사용자 1명 정보 수정", description = "요청받은 사용자 데이터와 db에 있는 사용자 데이터 검증 후 정보 수정")
@ApiResponse(content = @Content(schema = @Schema(implementation = Member.class)))
@PatchMapping(value = "/v1/member/{id}")
public ResponseEntity<EntityModel<Member>> updateNicknameAndPassword(@PathVariable String userId, @RequestBody MemberUpdateFormDto memberUpdateFormDto) {
Long updateMemberId = memberService.updateMember(userId, memberUpdateFormDto);
Optional<Member> loginMember = memberService.findById(updateMemberId);
// 수정한 member 를 조회할 수 있는 uri 를 Location 헤더에 담기
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("")
.buildAndExpand(updateMemberId)
.toUri();
if (loginMember.isEmpty()) { // 예외처리 구문
return ResponseEntity.noContent().location(location).build();
}
// 수정한 member 뿐만 아니라 모든 member 를 조회할 수 있는 link 를 entityModel 에 담기
EntityModel<Member> entityModel = EntityModel.of(loginMember.get());
WebMvcLinkBuilder link = linkTo(methodOn(this.getClass()).findAllMembers());
entityModel.add(link.withRel("all-members"));
return ResponseEntity.created(location).body(entityModel);
}
}
테스트 코드를 작성하는 것은 시간이 많이 들지만,
메서드 하나 하나를 작성할 때마다, Swagger에 엔드포인트가 늘어갈 때마다,
퍼즐의 남은 조각을 맞추는 것처럼 성취감이 느껴지는 작업인 것 같습니다.
테스트 코드를 작성하는 것이 익숙하지 않지만,
이번 프로젝트를 통해 최대한 익숙해질 수 있도록 할 예정입니다.
'Java' 카테고리의 다른 글
[SpringSecurity] SpringSecurity6, SpringBoot3.x.x , JWT(access, refresh) - 1 사전 준비(개요, docker, 초기 설정) (0) | 2024.01.21 |
---|---|
[Http Client] RestTemplate에서 RestClient 적용으로 최적화 (2) | 2024.01.19 |
[JSP] JSP Fragment (프래그먼트) (1) | 2024.01.01 |
[Spring MVC] 아키텍처 모델 종류; Dispatcher Servlet, Model 1, Model 2, Front Controller (1) | 2023.12.26 |
[SpringBoot] 로깅 (1) | 2023.12.26 |