서론
인강은 끝이 났고, 인강 내용을 바탕으로 첫 테스트 코드를 적용한 토이 프로젝트 내 코드들을 조금 리뷰해보려고 한다.
프로젝트를 간단히 소개하자면, 그냥 롤 챔피언 정보를 JSON으로 받아와서 무언가를 하는 서비스를 구현 할 예정이다.
우선 돌아가게는 작성했는데 테스트 코드를 처음 직접 활용하다보니 부족한 부분이 많을 수 있다.
혹여 지나가다 보시는 고수분들께서 틀린 점이 있다면 지적좀 해주시면 감사할 따름..
단위 테스트
Champion의 Repository에 대한 테스트를 작성했다.
package com.example.lolchampionsinvestment.domain.champion;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
class ChampionRepositoryTest {
@Autowired
private ChampionRepository championRepository;
@DisplayName("챔피언을 등록한다.")
@Test
void test(){
//given
Champion Aatrox = createChampion("Aatrox", 1000, "설명", LocalDateTime.now());
Champion Teemo = createChampion("Teemo", 10, "설명", LocalDateTime.now());
int beforeSize = championRepository.findAll().size();
championRepository.saveAll(List.of(Aatrox, Teemo));
//when
int currentSize = championRepository.findAllByNameIn(List.of("Aatrox","Teemo")).size();
//then
assertEquals(beforeSize + 2, currentSize);
}
private static Champion createChampion(String name, int price, String description, LocalDateTime createDateTime) {
return Champion.builder()
.name(name)
.price(price)
.description(description)
.createDateTime(createDateTime)
.build();
}
}
빌더 패턴을 given에 작성했었고, green 테스트가 끝나고 난 후 리팩토링을 통해 메서드화하였다.
개선 및 학습 필요
RED, GREEN, REFACTOR의 과정을 통해 테스트를 작성하려고 했으나 아래 사항을 학습하여 지식을 보완하여야 한다.
1. RED의 경우를 정확히 어떤 식으로 두어야 할지 모르겠어서 두 경우를 모두 작성했었다.
- Champion 객체를 build 시에 예외를 발생 시킬 수 있는 값을 던져주었다. (description = null)
- 정상 입력 후 등록한 뒤 then에서 Size비교를 검증 실패의 값으로 던져줬다 (before = after)
통합 테스트
1. Service test
Repository에 등록하기 위한 로직을 작성한 Service를 테스트했다.
package com.example.lolchampionsinvestment.api.service.champion;
import com.example.lolchampionsinvestment.domain.champion.Champion;
import com.example.lolchampionsinvestment.domain.champion.ChampionRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
@Transactional
@RequiredArgsConstructor
public class ChampionDataParsingService {
private final ChampionRepository championRepository;
private static String path = "/static/json/championFull.json";
private static String imgURL = "http://ddragon.leagueoflegends.com/cdn/13.14.1/img/champion/";
public void championsInsertTable() {
List<Map<String, Object>> championList = championsMapping();
List<Champion> champions = new ArrayList<>();
for(Map<String, Object> map : championList) {
Champion champion = Champion.builder()
.name((String) map.get("name"))
.price((int) (Math.random() * 9 + 1) * 1000)
.img(imgURL + (String) map.get("id") + ".png")
.description((String) map.get("description"))
.createDateTime(LocalDateTime.now())
.build();
champions.add(champion);
}
championRepository.saveAll(champions);
}
public List<Map<String, Object>> championsMapping() {
List<Map<String, Object>> championList = new ArrayList<>();
try {
ObjectMapper objectMapper = new ObjectMapper();
String championJsonString = getChampions();
Map<String, Object> championsMap = objectMapper.readValue(championJsonString, new TypeReference<Map<String, Object>>() {});
Map<String, Map<String, Object>> championMap = (Map<String, Map<String, Object>>) championsMap.get("data");
championList = championMap.values().stream().map(championData -> Map.of(
"id", championData.get("id"),
"name", championData.get("name"),
"description", championData.get("lore")
)).toList();
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
return championList;
}
private String getChampions() {
return readLines();
}
private String readLines() {
return new BufferedReader(
new InputStreamReader(
ChampionDataParsingService.class.getResourceAsStream(path)
)
).lines().collect(Collectors.joining());
}
}
서비스 로직은 우선적으로 구현을 위해 json 파일의 경로를 직접 입력하였지만 배포까지 고려했을 때 어떻게 할 지 생각중인 부분이다.
배포 시 관리자의 권한에서, json파일을 받아와서 업로드 시키면 서버의 json파일을 교체하고 새로 데이터를 파싱시켜 추가된 챔피언 정보를 업데이트 시킬지, 아니면 Crawler와 같은 기능을 하는 로직을 작성하여 직접 라이엇 개발자 포털에서 파싱하여 사용할 지에 대한 부분이 고민중이며 아직 방향은 잡지 않았다.
여튼, 서버 내 json file을 파싱하여 원하는 값만 매핑한 뒤 DB에 insert시키는 로직을 작성했고 이를 테스트하고자 했다.
package com.example.lolchampionsinvestment.api.service.champion;
import com.example.lolchampionsinvestment.domain.champion.Champion;
import com.example.lolchampionsinvestment.domain.champion.ChampionRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@ActiveProfiles("test")
@Transactional
class ChampionDataParsingServiceTest {
@Autowired
ChampionDataParsingService championDataParsingService;
@Autowired
ChampionRepository championRepository;
@DisplayName("json파일을 읽어와서 테이블에 챔피언 정보를 등록한다.")
@Test
void test(){
//given
List<Map<String, Object>> championList = championDataParsingService.championsMapping();
int listSize = championList.size();
int beforeTableSize = championRepository.findAll().size();
//when
championDataParsingService.championsInsertTable();
int afterTableSize = championRepository.findAll().size();
//then
assertThat(afterTableSize).isEqualTo(listSize);
assertThat(beforeTableSize).isEqualTo(afterTableSize - championList.size());
}
}
개선 및 학습 필요
생각하는 부분은, 위에서도 그렇지만 공통적으로 then부분이라고 생각하는데 단순히 전 후 상황을 비교하여 insert됐는지만 파악하기 위해서 위와 같이 작성을 했는데 이게 맞는 테스트 중의 하나인지, 아니면 then에서 검증하는 정형화된 로직이 있는지는 더 공부해나가야 할 부분이라고 생각한다.
2. Controller test
Controller를 마지막으로 테스트 하려했다.
package com.example.lolchampionsinvestment.api.controller.champion;
import com.example.lolchampionsinvestment.api.ApiResponse;
import com.example.lolchampionsinvestment.api.service.champion.ChampionDataParsingService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.HttpClientErrorException;
@RestController
@RequiredArgsConstructor
public class ChampionDataParsingController {
private final ChampionDataParsingService championDataParsingService;
@PostMapping("/api/v1/champions/new")
public ApiResponse<String> addAllJsonChampionsData() {
try {
championDataParsingService.championsInsertTable();
return ApiResponse.ok("파싱 및 테이블 저장 완료");
} catch (HttpClientErrorException httpClientErrorException) {
return ApiResponse.of(HttpStatus.BAD_REQUEST, httpClientErrorException.getMessage(), null);
} catch (Exception e) {
return ApiResponse.of(HttpStatus.INTERNAL_SERVER_ERROR, "Error occurred while parsing and saving champion data", null);
}
}
}
컨트롤러에서는, service의 로직을 동작하게끔 하였으며, 과정에서 Response를 받아오기 위해 작성하였다.
Response를 받아오는 과정에서, 인강 수강 당시에 웹 응답 자체를 객체화하여 사용하는 것을 목격해서 사용해보고 싶어서 사용해보았다.
package com.example.lolchampionsinvestment.api;
import lombok.Getter;
import org.springframework.http.HttpStatus;
@Getter
public class ApiResponse<T> {
private int code;
private HttpStatus status;
private String message;
private T data;
public ApiResponse(HttpStatus status, String message, T data) {
this.code = status.value();
this.status = status;
this.message = message;
this.data = data;
}
public static <T> ApiResponse<T> of(HttpStatus httpStatus, String message, T data) {
return new ApiResponse<>(httpStatus, message, data);
}
public static <T> ApiResponse<T> of(HttpStatus httpStatus, T data) {
return of(httpStatus, httpStatus.name(), data);
}
public static <T> ApiResponse<T> ok(T data) {
return of(HttpStatus.OK, data);
}
}
package com.example.lolchampionsinvestment.api.controller.champion;
import com.example.lolchampionsinvestment.api.service.champion.ChampionDataParsingService;
import com.fasterxml.jackson.databind.ObjectMapper;
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.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Map;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(controllers = ChampionDataParsingController.class)
class ChampionDataParsingControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
ObjectMapper objectMapper;
@MockBean
private ChampionDataParsingService championDataParsingService;
@DisplayName("챔피언 데이터를 파싱하고 테이블에 등록한다.")
@Test
void testChampionDataParsingAndInsertion() throws Exception {
//when //then
mockMvc.perform(
post("/api/v1/champions/new")
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value("200"))
.andExpect(jsonPath("$.status").value("OK"))
.andExpect(jsonPath("$.message").value("OK"));
verify(championDataParsingService).championsInsertTable();
}
}
개선 및 학습 필요
red 상황에 대한 test를 하고 싶었는데, 400번대 에러에 대한 테스트를 수행하기 위해서 고민을 했고, 400번대 에러는 관리자가 잘못된 Champion객체를 가져왔을 때라고 생각했고, 이는 json 파싱이 제대로 이루어지지 않았을 때라고 판단했다.
그런데 400번대 에러를 내기 위해서 코드를 작성하려는데, 해당 API의 파라미터가 없는 형태기 때문에 어떻게 json 파싱 상황에서 예외를 발생시킬지에 대한 해결책을 구상하지 못했다. 아마 Service에서 경로를 필드선언 하지 않고 파라미터로 넘겨받게끔 해야하는 것 같은데, 내가 구상한 서비스와 맞지 않는 부분이라 헤매고 있다.
마무리
부족부분은 보완해서 재포스팅이 반드시 필요할 것 같다.
테스트를 잘 하는 개발자가 될 수 있도록 앞으로도 정진!
관련 포스팅
테스트 코드를 작성하는 이유 - TestCode (1)
JUnit 5를 사용한 Java 단위 테스트 - TestCode (3)
통합 테스트(Integration Test) - TestCode(6)
@AfterEach, @BeforeEach - TestCode(7)
Mock / Test Double - TestCode (8)
더 좋은 테스트 작성하기(1) - TestCode(9)
더 좋은 테스트 코드 작성하기 2 - Test Fixture 구성하기 / TestCode(10)
더 좋은 테스트 코드 작성하기 3 - Dynamic Test / TestCode(11)
2023.04 ~ 백엔드 개발자의 기록
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!