[인프런 워밍업 스터디 클럽] 0기 백엔드 미션 - JPA 테스트 (Day7)

[인프런 워밍업 스터디 클럽] 0기 백엔드 미션 - JPA 테스트 (Day7)

미션

어느덧 스터디 클럽 7일차가 되었다. 오늘은 이전에 JDBC를 이용한 서비스 로직을 JPA로 변경해보는 실습을 가졌다.

그럼 이제 미션을 수행해보자.

진도표 7일차와 연결됩니다

우리는 JPA라는 개념을 배우고 유저 테이블에 JPA를 적용해 보았습니다. 몇 가지 문제를 통해 JPA를 연습해 봅시다! 🔥

imageimage


문제 1

문제1의 요구사항은 과제6에서 만들었던 기능들을 JPA로 구현하라고 하셨다. 따라서 강의에서 코치님께서 보여주신 과정으로 진행해보려고 한다.

 

step0. application.yml jpa 설정 추가

spring:
  datasource:
    url: "jdbc:mysql://localhost/fruit"
    username: "root"
    password: ""
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    hibernate:
      ddl-auto: none
    properties:
      hibernate:
        show_sql: true
        format_sql: true
        dialect: org.hibernate.dialect.MySQL8Dialect

step1. Fruit Entity를 JPA Entity화 하기!

package me.sungbin.entity.fruit;

import jakarta.persistence.*;
import org.hibernate.annotations.Comment;

import java.time.LocalDate;

/**
 * @author : rovert
 * @packageName : me.sungbin.entity.fruit
 * @fileName : Fruit
 * @date : 2/26/24
 * @description :
 * ===========================================================
 * DATE 			AUTHOR			 NOTE
 * -----------------------------------------------------------
 * 2/26/24       rovert         최초 생성
 */

@Entity
public class Fruit {

    @Id
    @Comment("Fruit 테이블의 Primary key")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(name = "warehousingDate", nullable = false)
    private LocalDate warehousingDate;

    @Column(nullable = false)
    private Long price;

    @Column(nullable = false)
    private boolean isSold = false;

    public Fruit() {
    }

    public Fruit(String name, LocalDate warehousingDate, Long price) {
        this.name = name;
        this.warehousingDate = warehousingDate;
        this.price = price;
    }

    public Fruit(String name, LocalDate warehousingDate, Long price, boolean isSold) {
        this.name = name;
        this.warehousingDate = warehousingDate;
        this.price = price;
        this.isSold = isSold;
    }

    public Fruit(Long id, String name, LocalDate warehousingDate, Long price, boolean isSold) {
        this.id = id;
        this.name = name;
        this.warehousingDate = warehousingDate;
        this.price = price;
        this.isSold = isSold;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public LocalDate getWarehousingDate() {
        return warehousingDate;
    }

    public Long getPrice() {
        return price;
    }

    public boolean isSold() {
        return isSold;
    }

    public void updateSoldInfo(boolean isSold) {
        this.isSold = isSold;
    }
}
  • Entity 어노테이션을 붙여서 엔티티로 만들고 기본 primary key와 auto_increment를 설정한다.

  • 그 외에, 컬럼들의 null 여부도 설정하였다.

  • 또한 warehousingDate의 필드에 컬럼 이름을 다시 넣은 이유는 mysql 쿼리가 동작할 때 warehousingDate로 컬럼이 인식이 안되고 warehousing_date로 인식을 하기 때문에 name 필드를 넣었다.

step2. JpaRepository를 상속받은 인터페이스 생성

기존의 FruitRepository 인터페이스를 FruitJdbcRepository로 파일명을 변경한 후, FruitRepository 클래스를 만든다.

FruitJPARepository로 만들어도 상관은 없지만, 통상적으로 편하게 FruitRepository로 해주는 것이다.

package me.sungbin.repository;

import me.sungbin.entity.fruit.Fruit;
import org.springframework.data.jpa.repository.JpaRepository;

/**
 * @author : rovert
 * @packageName : me.sungbin.repository
 * @fileName : FruitRepository
 * @date : 2/26/24
 * @description :
 * ===========================================================
 * DATE           AUTHOR        NOTE
 * -----------------------------------------------------------
 * 2/26/24       rovert         최초 생성
 */
public interface FruitRepository extends JpaRepository<Fruit, Long> {
}

 

step3. DTO 코드 변경

package me.sungbin.dto.fruit.request;

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import me.sungbin.entity.fruit.Fruit;
import org.springframework.format.annotation.DateTimeFormat;

import java.time.LocalDate;

/**
 * @author : rovert
 * @packageName : me.sungbin.dto.fruit.request
 * @fileName : SaveFruitInfoRequestDto
 * @date : 2/26/24
 * @description :
 * ===========================================================
 * DATE           AUTHOR        NOTE
 * -----------------------------------------------------------
 * 2/26/24       rovert         최초 생성
 */
public class SaveFruitInfoRequestDto {

    @NotBlank(message = "과일 이름이 공란일 수 없습니다.")
    @NotNull(message = "과일 이름이 null일 수는 없습니다.")
    private String name;

    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
    private LocalDate warehousingDate;


    @Min(value = 0, message = "가격이 음수가 될 수는 없습니다.")
    private long price;

    public SaveFruitInfoRequestDto(String name, LocalDate warehousingDate, long price) {
        this.name = name;
        this.warehousingDate = warehousingDate;
        this.price = price;
    }

    public String getName() {
        return name;
    }

    public LocalDate getWarehousingDate() {
        return warehousingDate;
    }

    public long getPrice() {
        return price;
    }

    public Fruit toEntity() {
        return new Fruit(name, warehousingDate, price, false);
    }
}

과일 정보를 저장할 때, toEntity() 메서드에 Fruit 생성자의 마지막애 false를 추가하였다. 왜냐하면 DTO에서 엔티티로 변경을 할 때 판매유무를 확실히 미판매로 해두려고 하기 때문이다.

 

step4. 서비스 로직 수정

기존 FruitService를 FruitJdbcService로 변경하고 FruitService를 새로 만든다.

일단 먼저 서비스 코드를 전체 보여주겠다.

 

package me.sungbin.service;

import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto;
import me.sungbin.dto.fruit.request.UpdateFruitRequestDto;
import me.sungbin.dto.fruit.response.GetFruitResponseDto;
import me.sungbin.entity.fruit.Fruit;
import me.sungbin.repository.FruitRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
 * @author : rovert
 * @packageName : me.sungbin.service
 * @fileName : FruitService
 * @date : 2/26/24
 * @description :
 * ===========================================================
 * DATE           AUTHOR        NOTE
 * -----------------------------------------------------------
 * 2/26/24       rovert         최초 생성
 */

@Service
@Transactional(readOnly = true)
public class FruitService {

    private final FruitRepository fruitRepository;

    public FruitService(FruitRepository fruitRepository) {
        this.fruitRepository = fruitRepository;
    }

    /**
     * 문제 1번 (과제6 문제 1)
     * @param requestDto
     */
    @Transactional
    public void saveFruitInfo(SaveFruitInfoRequestDto requestDto) {
        Fruit fruit = requestDto.toEntity();

        this.fruitRepository.save(fruit);
    }

    /**
     * 문제 1번 (과제 6 문제 2)
     * @param requestDto
     */
    @Transactional
    public void updateFruitInfo(UpdateFruitRequestDto requestDto) {
        Fruit fruit = this.fruitRepository.findById(requestDto.getId()).orElseThrow(IllegalArgumentException::new);
        fruit.updateSoldInfo(true);
    }

    /**
     * 문제 1번 (과제 6 문제 3)
     * @param name
     * @return
     */
    public GetFruitResponseDto getFruitInfo(String name) {
        GetFruitResponseDto fruitData = this.fruitRepository.getFruitSalesInfo(name);

        validateGetFruitAmount(fruitData.getSalesAmount(), fruitData.getNotSalesAmount());

        return fruitData;
    }

    private void validateGetFruitAmount(long salesAmount, long notSalesAmount) {
        if (salesAmount == 0L && notSalesAmount == 0L) {
            throw new IllegalArgumentException("존재하는 과일이 없습니다.");
        }
    }
}

위의 코드를 보면 나머지는 대략 이해는 되는데 getFruitInfo의 getFruitSalesInfo 메서드는 처음 볼 것이다. 우리가 배운 범위에서

getFruitSalesInfo는 data jpa에서 기본으로 제공해주는 함수는 아니기 때문이다. 바로 이것은 repository에 @Query 어노테이션과 사용자 정의 JPQL 쿼리를 사용하였다. 그 이유는 집계함수로 인하여 불기파 사용하였다. 아래는 수정된 repository 코드이다.

package me.sungbin.repository;

import me.sungbin.dto.fruit.response.GetFruitResponseDto;
import me.sungbin.entity.fruit.Fruit;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;

/**
 * @author : rovert
 * @packageName : me.sungbin.repository
 * @fileName : FruitRepository
 * @date : 2/26/24
 * @description :
 * ===========================================================
 * DATE           AUTHOR        NOTE
 * -----------------------------------------------------------
 * 2/26/24       rovert         최초 생성
 */

@Transactional(readOnly = true)
public interface FruitRepository extends JpaRepository<Fruit, Long> {

    @Query("SELECT new me.sungbin.dto.fruit.response.GetFruitResponseDto(SUM(case when f.isSold = true then f.price else 0 end), SUM(case when f.isSold = false then f.price else 0 end)) FROM Fruit f WHERE f.name = :name")
    GetFruitResponseDto getFruitSalesInfo(@Param("name") String name); // 과제 6 문제 3번
}

 

step5. 테스트

이제 위의 변경된 코드를 가지고 테스트를 해서 검증해보자. fruit 테이블을 조회하면 아래처럼 비어있다고 하자.

image

생성 테스트

image

image

그리고 몇개의 데이터를 만들고 테이블에 잘 insert 되었는지 확인해보았다.

image

수정

image

image

image

합산 조회

현재 데이터의 테이블이 아래와 같다고 하자.

image

그러면 테스트 해보자.

image

image

step6. 테스트 코드

이전과 같은 아래의 테스트코드로 실행 해보았다.

package me.sungbin.controller.fruit;

import com.fasterxml.jackson.databind.ObjectMapper;
import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto;
import me.sungbin.dto.fruit.request.UpdateFruitRequestDto;
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.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDate;

import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

/**
 * @author : rovert
 * @packageName : me.sungbin.controller.fruit
 * @fileName : FruitControllerTest
 * @date : 2/26/24
 * @description :
 * ===========================================================
 * DATE           AUTHOR        NOTE
 * -----------------------------------------------------------
 * 2/26/24       rovert         최초 생성
 */

@Transactional
@SpringBootTest
@AutoConfigureMockMvc
class FruitControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    @DisplayName("문제 1번 통합 테스트 - 실패 (가격이 음수거나 과일 이름이 공란)")
    void question1_test_fail_caused_by_price_is_minus_or_fruit_name_is_empty() throws Exception {
        SaveFruitInfoRequestDto requestDto = new SaveFruitInfoRequestDto("", LocalDate.of(2024, 1, 1), -1000);

        this.mockMvc.perform(post("/api/v1/fruit")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(requestDto)))
                .andDo(print())
                .andExpect(status().isBadRequest());
    }

    @Test
    @DisplayName("문제 1번 통합 테스트 - 성공")
    void question1_test_success() throws Exception {
        SaveFruitInfoRequestDto requestDto = new SaveFruitInfoRequestDto("파인애플", LocalDate.of(2024, 2, 2), 20000);

        this.mockMvc.perform(post("/api/v1/fruit")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(requestDto)))
                .andDo(print())
                .andExpect(status().isOk());
    }

    @Test
    @DisplayName("문제 2번 통합 테스트 - 성공")
    void question2_test_success() throws Exception {
        UpdateFruitRequestDto requestDto = new UpdateFruitRequestDto(1);

        this.mockMvc.perform(put("/api/v1/fruit")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(requestDto)))
                .andDo(print())
                .andExpect(status().isOk());
    }

    @Test
    @DisplayName("문제 3번 통합 테스트 - 성공")
    void question3_test_success() throws Exception {
        this.mockMvc.perform(get("/api/v1/fruit/stat")
                        .param("name", "사과"))
                .andDo(print())
                .andExpect(status().isOk());
    }
}

image


문제 2

요구사항은 우리가게를 거쳐갔던 과일의 개수를 구하는 문제이다. 여기서 의도는 거쳐갔던이므로 판매가 되었던 것중의 과일의 이름을 카운트해보겠다.

 

step0. 응답 DTO 생성

 package me.sungbin.dto.fruit.response;

/**
 * @author : rovert
 * @packageName : me.sungbin.dto.fruit.response
 * @fileName : CountFruitNameResponseDto
 * @date : 2/26/24
 * @description :
 * ===========================================================
 * DATE 			AUTHOR			 NOTE
 * -----------------------------------------------------------
 * 2/26/24       rovert         최초 생성
 */
public class CountFruitNameResponseDto {

    private final long count;

    public CountFruitNameResponseDto(long count) {
        this.count = count;
    }

    public long getCount() {
        return count;
    }
}

step1. 레파지토리에 jpa 메서드 선언

package me.sungbin.repository;

import me.sungbin.dto.fruit.response.GetFruitResponseDto;
import me.sungbin.entity.fruit.Fruit;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;

/**
 * @author : rovert
 * @packageName : me.sungbin.repository
 * @fileName : FruitRepository
 * @date : 2/26/24
 * @description :
 * ===========================================================
 * DATE 			AUTHOR			 NOTE
 * -----------------------------------------------------------
 * 2/26/24       rovert         최초 생성
 */

@Transactional(readOnly = true)
public interface FruitRepository extends JpaRepository<Fruit, Long> {

    @Query("SELECT new me.sungbin.dto.fruit.response.GetFruitResponseDto(SUM(case when f.isSold = true then f.price else 0 end), SUM(case when f.isSold = false then f.price else 0 end)) FROM Fruit f WHERE f.name = :name")
    GetFruitResponseDto getFruitSalesInfo(@Param("name") String name); // 과제 6 문제 3번

    long countByNameAndIsSoldIsTrue(String name);
}
  • countByNameAndIsSoldIsTrue 메서드가 방금 작성한 코드이다.

step2. 서비스 코드 작성

package me.sungbin.service;

import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto;
import me.sungbin.dto.fruit.request.UpdateFruitRequestDto;
import me.sungbin.dto.fruit.response.CountFruitNameResponseDto;
import me.sungbin.dto.fruit.response.GetFruitResponseDto;
import me.sungbin.entity.fruit.Fruit;
import me.sungbin.repository.FruitRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
 * @author : rovert
 * @packageName : me.sungbin.service
 * @fileName : FruitService
 * @date : 2/26/24
 * @description :
 * ===========================================================
 * DATE 			AUTHOR			 NOTE
 * -----------------------------------------------------------
 * 2/26/24       rovert         최초 생성
 */

@Service
@Transactional(readOnly = true)
public class FruitService {

    private final FruitRepository fruitRepository;

    public FruitService(FruitRepository fruitRepository) {
        this.fruitRepository = fruitRepository;
    }

    /**
     * 문제 1번 (과제6 문제 1)
     * @param requestDto
     */
    @Transactional
    public void saveFruitInfo(SaveFruitInfoRequestDto requestDto) {
        Fruit fruit = requestDto.toEntity();

        this.fruitRepository.save(fruit);
    }

    /**
     * 문제 1번 (과제 6 문제 2)
     * @param requestDto
     */
    @Transactional
    public void updateFruitInfo(UpdateFruitRequestDto requestDto) {
        Fruit fruit = this.fruitRepository.findById(requestDto.getId()).orElseThrow(IllegalArgumentException::new);
        fruit.updateSoldInfo(true);

        this.fruitRepository.save(fruit);
    }

    /**
     * 문제 1번 (과제 6 문제 3)
     * @param name
     * @return
     */
    public GetFruitResponseDto getFruitInfo(String name) {
        GetFruitResponseDto fruitData = this.fruitRepository.getFruitSalesInfo(name);

        validateGetFruitAmount(fruitData.getSalesAmount(), fruitData.getNotSalesAmount());

        return fruitData;
    }

    /**
     * 문제 2번
     * @param name
     * @return
     */
    public CountFruitNameResponseDto countFruitName(String name) {
        long count = this.fruitRepository.countByNameAndIsSoldIsTrue(name);

        return new CountFruitNameResponseDto(count);
    }

    private void validateGetFruitAmount(long salesAmount, long notSalesAmount) {
        if (salesAmount == 0L && notSalesAmount == 0L) {
            throw new IllegalArgumentException("존재하는 과일이 없습니다.");
        }
    }
}
  • countFruitName 메서드가 내가 방금 작성한 메서드이다.

step3. 컨트롤러 코드 작성

package me.sungbin.controller.fruit;

import jakarta.validation.Valid;
import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto;
import me.sungbin.dto.fruit.request.UpdateFruitRequestDto;
import me.sungbin.dto.fruit.response.CountFruitNameResponseDto;
import me.sungbin.dto.fruit.response.GetFruitResponseDto;
import me.sungbin.service.FruitService;
import org.springframework.web.bind.annotation.*;

/**
 * @author : rovert
 * @packageName : me.sungbin.controller.fruit
 * @fileName : FruitController
 * @date : 2/26/24
 * @description :
 * ===========================================================
 * DATE           AUTHOR        NOTE
 * -----------------------------------------------------------
 * 2/26/24       rovert         최초 생성
 */

@RestController
@RequestMapping("/api/v1/fruit")
public class FruitController {

    private final FruitService fruitService;

    public FruitController(FruitService fruitService) {
        this.fruitService = fruitService;
    }

    @PostMapping
    public void saveFruitInfo(@RequestBody @Valid SaveFruitInfoRequestDto requestDto) {
        this.fruitService.saveFruitInfo(requestDto);
    }

    @PutMapping
    public void updateFruitInfo(@RequestBody UpdateFruitRequestDto requestDto) {
        this.fruitService.updateFruitInfo(requestDto);
    }

    @GetMapping("/stat")
    public GetFruitResponseDto getFruitInfo(@RequestParam String name) {
        return this.fruitService.getFruitInfo(name);
    }
    
    @GetMapping("/count")
    public CountFruitNameResponseDto countFruitName(@RequestParam String name) {
        return this.fruitService.countFruitName(name);
    }
}
  • countFruitName이 방금 작성한 컨트롤러 코드이다.

step4. 테스트

image

아래와 같이 DB 데이터가 있다고 하자. 그리고 포스트맨으로 테스트해보자.

image

image

step5. 테스트 코드

@Test
@DisplayName("과제 7번 문제 2번 통합 테스트 - 실패 (파라미터 존재 X)")
void lesson7_question2_fail_test_caused_by_not_exists_request_parameter() throws Exception {
    this.mockMvc.perform(get("/api/v1/fruit/count"))
            .andDo(print())
            .andExpect(status().isBadRequest());
}

@Test
@DisplayName("과제 7번 문제 2번 통합 테스트 - 성공")
void lesson7_question2_test_success() throws Exception {
    this.mockMvc.perform(get("/api/v1/fruit/count")
                    .param("name", "사과"))
            .andDo(print())
            .andExpect(status().isOk());
}

이번에는 실패 케이스와 성공 케이스 2개를 작성했으며 결과는 아래와 같다.

image


문제 3

문제 3은 아직 판매되지 않은 과일 정보 리스트 중에 특정 금액 이상 혹은 이하의 과일 목록을 받는 것이다.

 

step0. 응답 DTO 생성

package me.sungbin.dto.fruit.response;

import java.time.LocalDate;

/**
 * @author : rovert
 * @packageName : me.sungbin.dto.fruit.response
 * @fileName : FruitResponseDto
 * @date : 2/26/24
 * @description :
 * ===========================================================
 * DATE           AUTHOR        NOTE
 * -----------------------------------------------------------
 * 2/26/24       rovert         최초 생성
 */
public class FruitResponseDto {
    
    private final String name;
    
    private final long price;
    
    private final LocalDate warehousingDate;

    public FruitResponseDto(String name, long price, LocalDate warehousingDate) {
        this.name = name;
        this.price = price;
        this.warehousingDate = warehousingDate;
    }

    public String getName() {
        return name;
    }

    public long getPrice() {
        return price;
    }

    public LocalDate getWarehousingDate() {
        return warehousingDate;
    }
}

step1. 요청 DTO 생성

package me.sungbin.dto.fruit.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

/**
 * @author : rovert
 * @packageName : me.sungbin.dto.fruit.request
 * @fileName : FruitRequestDto
 * @date : 2/26/24
 * @description :
 * ===========================================================
 * DATE           AUTHOR        NOTE
 * -----------------------------------------------------------
 * 2/26/24       rovert         최초 생성
 */
public class FruitRequestDto {

    @NotBlank(message = "option은 공란일 수 없습니다.")
    @NotNull(message = "option은 반드시 있어야 합니다.")
    private final String option;

    private final long price;

    public FruitRequestDto(String option, long price) {
        this.option = option;
        this.price = price;
    }

    public String getOption() {
        return option;
    }

    public long getPrice() {
        return price;
    }
}
  • 요청 DTO에는 spring starter validation을 추가하여 예외 처리도 해두었다.

step2. Repository의 쿼리 메서드 추가

쿼리 메서드 대신에 @Query를 사용하여 DTO로 반환시킬 수 있다. 과제 7의 1번처럼 말이다. 하지만 본 과제의 취지와 맞지 않은 것 같기에 과제7의 1번(문제 3번)은 @Query로 사용했으니 이번엔 안 사용하고 해보겠다.

package me.sungbin.repository;

import me.sungbin.dto.fruit.response.FruitResponseDto;
import me.sungbin.dto.fruit.response.GetFruitResponseDto;
import me.sungbin.entity.fruit.Fruit;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

/**
 * @author : rovert
 * @packageName : me.sungbin.repository
 * @fileName : FruitRepository
 * @date : 2/26/24
 * @description :
 * ===========================================================
 * DATE 			AUTHOR			 NOTE
 * -----------------------------------------------------------
 * 2/26/24       rovert         최초 생성
 */

@Transactional(readOnly = true)
public interface FruitRepository extends JpaRepository<Fruit, Long> {

    @Query("SELECT new me.sungbin.dto.fruit.response.GetFruitResponseDto(SUM(case when f.isSold = true then f.price else 0 end), SUM(case when f.isSold = false then f.price else 0 end)) FROM Fruit f WHERE f.name = :name")
    GetFruitResponseDto getFruitSalesInfo(@Param("name") String name); // 과제 6 문제 3번

    long countByNameAndIsSoldIsTrue(String name);

    List<Fruit> findAllByPriceGreaterThanEqualAndIsSoldIsFalse(long price);

    List<Fruit> findAllByPriceLessThanEqualAndIsSoldIsFalse(long price);
}

step3. 서비스 코드 추가

package me.sungbin.service;

import me.sungbin.dto.fruit.request.FruitRequestDto;
import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto;
import me.sungbin.dto.fruit.request.UpdateFruitRequestDto;
import me.sungbin.dto.fruit.response.CountFruitNameResponseDto;
import me.sungbin.dto.fruit.response.FruitResponseDto;
import me.sungbin.dto.fruit.response.GetFruitResponseDto;
import me.sungbin.entity.fruit.Fruit;
import me.sungbin.repository.FruitRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

/**
 * @author : rovert
 * @packageName : me.sungbin.service
 * @fileName : FruitService
 * @date : 2/26/24
 * @description :
 * ===========================================================
 * DATE 			AUTHOR			 NOTE
 * -----------------------------------------------------------
 * 2/26/24       rovert         최초 생성
 */

@Service
@Transactional(readOnly = true)
public class FruitService {

    private final FruitRepository fruitRepository;

    public FruitService(FruitRepository fruitRepository) {
        this.fruitRepository = fruitRepository;
    }

    /**
     * 문제 1번 (과제6 문제 1)
     * @param requestDto
     */
    @Transactional
    public void saveFruitInfo(SaveFruitInfoRequestDto requestDto) {
        Fruit fruit = requestDto.toEntity();

        this.fruitRepository.save(fruit);
    }

    /**
     * 문제 1번 (과제 6 문제 2)
     * @param requestDto
     */
    @Transactional
    public void updateFruitInfo(UpdateFruitRequestDto requestDto) {
        Fruit fruit = this.fruitRepository.findById(requestDto.getId()).orElseThrow(IllegalArgumentException::new);
        fruit.updateSoldInfo(true);

        this.fruitRepository.save(fruit);
    }

    /**
     * 문제 1번 (과제 6 문제 3)
     * @param name
     * @return
     */
    public GetFruitResponseDto getFruitInfo(String name) {
        GetFruitResponseDto fruitData = this.fruitRepository.getFruitSalesInfo(name);

        validateGetFruitAmount(fruitData.getSalesAmount(), fruitData.getNotSalesAmount());

        return fruitData;
    }

    /**
     * 문제 2번
     * @param name
     * @return
     */
    public CountFruitNameResponseDto countFruitName(String name) {
        long count = this.fruitRepository.countByNameAndIsSoldIsTrue(name);

        return new CountFruitNameResponseDto(count);
    }

    public List<FruitResponseDto> findSoldFruitListOfPrice(FruitRequestDto requestDto) {
        if (Objects.equals(requestDto.getOption(), "GTE")) {
            return this.fruitRepository.findAllByPriceGreaterThanEqualAndIsSoldIsFalse(requestDto.getPrice())
                    .stream().map(fruit -> new FruitResponseDto(fruit.getName(), fruit.getPrice(), fruit.getWarehousingDate()))
                    .collect(Collectors.toList());
        } else if (Objects.equals(requestDto.getOption(), "LTE")) {
            return this.fruitRepository.findAllByPriceLessThanEqualAndIsSoldIsFalse(requestDto.getPrice())
                    .stream().map(fruit -> new FruitResponseDto(fruit.getName(), fruit.getPrice(), fruit.getWarehousingDate()))
                    .collect(Collectors.toList());
        } else {
            throw new IllegalArgumentException("옵션은 GTE 혹은 LTE이여야 합니다.");
        }
    }

    private void validateGetFruitAmount(long salesAmount, long notSalesAmount) {
        if (salesAmount == 0L && notSalesAmount == 0L) {
            throw new IllegalArgumentException("존재하는 과일이 없습니다.");
        }
    }
}



  • 옵션이 올바르지 못할 경우 런 타임 에러 발생

step4. 컨트롤러 코드 추가

package me.sungbin.controller.fruit;

import jakarta.validation.Valid;
import me.sungbin.dto.fruit.request.FruitRequestDto;
import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto;
import me.sungbin.dto.fruit.request.UpdateFruitRequestDto;
import me.sungbin.dto.fruit.response.CountFruitNameResponseDto;
import me.sungbin.dto.fruit.response.FruitResponseDto;
import me.sungbin.dto.fruit.response.GetFruitResponseDto;
import me.sungbin.service.FruitService;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
 * @author : rovert
 * @packageName : me.sungbin.controller.fruit
 * @fileName : FruitController
 * @date : 2/26/24
 * @description :
 * ===========================================================
 * DATE 			AUTHOR			 NOTE
 * -----------------------------------------------------------
 * 2/26/24       rovert         최초 생성
 */

@RestController
@RequestMapping("/api/v1/fruit")
public class FruitController {

    private final FruitService fruitService;

    public FruitController(FruitService fruitService) {
        this.fruitService = fruitService;
    }

    @PostMapping
    public void saveFruitInfo(@RequestBody @Valid SaveFruitInfoRequestDto requestDto) {
        this.fruitService.saveFruitInfo(requestDto);
    }

    @PutMapping
    public void updateFruitInfo(@RequestBody UpdateFruitRequestDto requestDto) {
        this.fruitService.updateFruitInfo(requestDto);
    }

    @GetMapping("/stat")
    public GetFruitResponseDto getFruitInfo(@RequestParam String name) {
        return this.fruitService.getFruitInfo(name);
    }

    @GetMapping("/count")
    public CountFruitNameResponseDto countFruitName(@RequestParam String name) {
        return this.fruitService.countFruitName(name);
    }

    @GetMapping("/list")
    public List<FruitResponseDto> findSoldFruitListOfPrice(FruitRequestDto requestDto) {
        return this.fruitService.findSoldFruitListOfPrice(requestDto);
    }
}

 

step5. 테스트

현재 DB 데이터는 아래와 같다.

image

그럴때 테스트를 해보겠다.

GTE

image

image

LTE

image

image

step6. 테스트 코드

이제 테스트 코드를 작성해보자. 아래는 전체 테스트 코드다!

package me.sungbin.controller.fruit;

import com.fasterxml.jackson.databind.ObjectMapper;
import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto;
import me.sungbin.dto.fruit.request.UpdateFruitRequestDto;
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.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDate;

import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

/**
 * @author : rovert
 * @packageName : me.sungbin.controller.fruit
 * @fileName : FruitControllerTest
 * @date : 2/26/24
 * @description :
 * ===========================================================
 * DATE 			AUTHOR			 NOTE
 * -----------------------------------------------------------
 * 2/26/24       rovert         최초 생성
 */

@Transactional
@SpringBootTest
@AutoConfigureMockMvc
class FruitControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    @DisplayName("문제 1번 통합 테스트 - 실패 (가격이 음수거나 과일 이름이 공란)")
    void question1_test_fail_caused_by_price_is_minus_or_fruit_name_is_empty() throws Exception {
        SaveFruitInfoRequestDto requestDto = new SaveFruitInfoRequestDto("", LocalDate.of(2024, 1, 1), -1000);

        this.mockMvc.perform(post("/api/v1/fruit")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(requestDto)))
                .andDo(print())
                .andExpect(status().isBadRequest());
    }

    @Test
    @DisplayName("문제 1번 통합 테스트 - 성공")
    void question1_test_success() throws Exception {
        SaveFruitInfoRequestDto requestDto = new SaveFruitInfoRequestDto("파인애플", LocalDate.of(2024, 2, 2), 20000);

        this.mockMvc.perform(post("/api/v1/fruit")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(requestDto)))
                .andDo(print())
                .andExpect(status().isOk());
    }

    @Test
    @DisplayName("문제 2번 통합 테스트 - 성공")
    void question2_test_success() throws Exception {
        UpdateFruitRequestDto requestDto = new UpdateFruitRequestDto(1);

        this.mockMvc.perform(put("/api/v1/fruit")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(requestDto)))
                .andDo(print())
                .andExpect(status().isOk());
    }

    @Test
    @DisplayName("문제 3번 통합 테스트 - 성공")
    void question3_test_success() throws Exception {
        this.mockMvc.perform(get("/api/v1/fruit/stat")
                        .param("name", "사과"))
                .andDo(print())
                .andExpect(status().isOk());
    }

    @Test
    @DisplayName("과제 7번 문제 2번 통합 테스트 - 실패 (파라미터 존재 X)")
    void lesson7_question2_fail_test_caused_by_not_exists_request_parameter() throws Exception {
        this.mockMvc.perform(get("/api/v1/fruit/count"))
                .andDo(print())
                .andExpect(status().isBadRequest());
    }

    @Test
    @DisplayName("과제 7번 문제 2번 통합 테스트 - 성공")
    void lesson7_question2_test_success() throws Exception {
        this.mockMvc.perform(get("/api/v1/fruit/count")
                        .param("name", "사과"))
                .andDo(print())
                .andExpect(status().isOk());
    }

    @Test
    @DisplayName("과제 7번 문제 2번 통합 테스트 - 실패 (파라미터 존재 X)")
    void lesson7_question3_fail_test_caused_by_not_exists_request_parameter() throws Exception {
        this.mockMvc.perform(get("/api/v1/fruit/list"))
                .andDo(print())
                .andExpect(status().isBadRequest());
    }

    @Test
    @DisplayName("과제 7번 문제 3번 통합 테스트 - 성공")
    void lesson7_question3_test_success() throws Exception {
        this.mockMvc.perform(get("/api/v1/fruit/list")
                        .param("option", "GTE")
                        .param("price", "3000"))
                .andDo(print())
                .andExpect(status().isOk());
    }
}

image


회고

JPA의 편리함을 많이 깨닫는 하루였다. 하지만 쿼리메서드를 작성할 때 조건이 엄청 길어지는 것이 내가 보기엔 단점 같다.

아래의 짤이 있다. JPA도 이런 취급을 받을 날이 안 왔으면 하는 마음에서 글을 마무리하려 한다.

 

image


📚 참고

https://m.blog.naver.com/PostView.naver?blogId=190208&logNo=222145961004&categoryNo=51&proxyReferer=

댓글을 작성해보세요.