Java

[Swagger] 테스트 코드, Swagger 등 개발 시 우선순위 몇 가지

ride-dev 2024. 1. 17. 00:52

프로젝트를 진행할 때, 몇가지 사항들을 우선순위로 두기로 했습니다.

 

테스트 코드 작성하기, 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에 엔드포인트가 늘어갈 때마다,

퍼즐의 남은 조각을 맞추는 것처럼 성취감이 느껴지는 작업인 것 같습니다.

 

테스트 코드를 작성하는 것이 익숙하지 않지만,

이번 프로젝트를 통해 최대한 익숙해질 수 있도록 할 예정입니다.

728x90