프로젝트 개요 : 이것이 취업을 위한 백엔드 개발이다 클론코딩 - 상품관리 애플리케이션
프로젝트 환경 : IntelliJ, SpringBoot, MySQL
프로젝트 코드 : https://github.com/smkim9202/ProductManagement
리팩토링과 테스트 코드의 관계
- 리팩토링 : 동일한 입력에 대해 결과의 변경 없이 코드의 구조가 개선되는 것
- 테스트 코드 : 작성한 로직을 테스트하기 위한 코드
- 리팩토링 즉 동일한 입력에 대해 결과의 변경 없이 코드의 구조가 개선되는 것을 확인하는 방법이 테스트 코드
- 테스트코드는 버그가 없는 코드가 아니라 작성한 테스트 코드에 한해 기존과 동일하게 작동한다는 사실만 보장
// ProductDto의 생성자를 만들어 주지 않았기 때문에
// 아직 다음처럼 ProductDto를 생성할 수 없다. 생성자는 잠시 후 추가해 본다.
// 1. 저장을 위한 ProductDto 생성
ProductDto productDto = new ProductDto("연필", 300, 20);
// 2. 해당 ProductDto를 인자로 add메서드를 실행 - Repository에 Product 저장 -
// 저장된 Product의 ProductDto를 반환 후 savedProductDto에 넣어줌 - saveProductDto의 id만 가져옴
ProductDto savedProductDto = simpleProdcutService.add(ProductDto);
Long savedProductId = saveProductDto.getId();
// 3. findById메서드로 Repository에 저장된 상품을 id로 조회 - 조회된 ProductDto foundProductDto에 넣어줌
ProductDto foundProductDto = simpleProductService.findById(saveProductId);
// 4. 저장 시 반환받은 savedProductDto와 id로 조회한 foundProductDto의 각 필드를 비교하여 Boolean 값 출력
// 모두 true로 나와야 정상
System.out.println(savedProductDto.getId() == foundProductDto.getId());
System.out.println(savedProductDto.getName() == foundProductDto.getName());
System.out.println(savedProductDto.getPrice() == foundProductDto.getPrice());
System.out.println(savedProductDto.getAmount() == foundProductDto.getAmount());
테스트 코드
테스트코드 작성
테스트코드 파일 생성
- SimpleProductService 코드 우클릭 - Generate... - test...
- Create Test 창 기본 설정 후 OK
- SimpleProductServiceTest.java 파일 생성 확인
SimplePRoductServiceTest.java
package kr.co.api.product.management.application;
import kr.co.api.product.management.presentation.ProductDto;
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 static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
@ActiveProfiles("test")
class SimpleProductServiceTest {
@Autowired
SimpleProductService simpleProductService;
@Test
@DisplayName("상품을 추가한 후 id로 조회하면 해당 상품이 조회되어야한다.")
void productAddAndFindByIdTest () {
ProductDto productDto = new ProductDto("연필", 300, 20);
ProductDto savedProductDto = simpleProductService.add(productDto);
Long savedProductId = savedProductDto.getId();
ProductDto foundProductDto = simpleProductService.findByid(savedProductId);
System.out.println(savedProductDto.getId() == foundProductDto.getId());
System.out.println(savedProductDto.getName() == foundProductDto.getName());
System.out.println(savedProductDto.getPrice() == foundProductDto.getPrice());
System.out.println(savedProductDto.getAmount() == foundProductDto.getAmount());
}
}
@SpringBootTest
스프링 컨테이너가 뜨는 통합 테스트를 위해 사용하는 애너테이션
통합 테스트가 애플리케이션이 실제 실행되는 것과 마찬가지로 Bean을 스프링 프레임워크가 생성해 준 상태에서 진행된다.
단점은 실제 실행되는 것처럼 테스트해서 실행될 때까지 시간이 오래 걸린다.
@ActiveProfiles
테스트코드에서 사용할 Profile을 지정
SimpleProductService 의존성
테스트코드에서는 필드에 바로 주입해 줘도 무관하다.(애플리케이션에서는 생성자를 통한 주입)
@Test
해당 메서드 테스트 코드라는 것을 의미하고, 테스트 코드로 작동하게 한다.
@DisplayName
테스트코드의 이름 지정
테스트코드의 메서드명
productAddAndFindByIdTest처럼 영어로 지을 수 있고, 실무에서 테스트 코드 메서드명은 한글로 작성하는 경우도 많다.
테스트코드 실행
메서드 왼쪽 실행 아이콘 클릭 - Run '메서드명()...' 클릭
성공테스트
실패테스트
// 실패유도 위해 코드 변경
// SimpleProductService.java 비어 있는 ProductDto 반환하도록 findById 변경
public ProductDto findByid(Long id){
Product product = productRepository.findById(id);
ProductDto productDto = modelMapper.map(product, ProductDto.class);
//return productDto;
return new ProductDto();
}
성공/실패 여부 자동 체크
성공과 실패 여부를 로그로 직접 확인(대량의 테스트 진행시 확인하기 어려움) => 테스트 코드가 실패해야 되는 상황에서 알려주도록 변경
SimpleProductServiceTest.java : assert문 사용
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
@ActiveProfiles("test")
class SimpleProductServiceTest {
@Autowired
SimpleProductService simpleProductService;
@Test
@DisplayName("상품을 추가한 후 id로 조회하면 해당 상품이 조회되어야한다.")
void productAddAndFindByIdTest () {
ProductDto productDto = new ProductDto("연필", 300, 20);
ProductDto savedProductDto = simpleProductService.add(productDto);
Long savedProductId = savedProductDto.getId();
ProductDto foundProductDto = simpleProductService.findByid(savedProductId);
assertTrue(savedProductDto.getId() == foundProductDto.getId());
assertTrue(savedProductDto.getName() == foundProductDto.getName());
assertTrue(savedProductDto.getPrice() == foundProductDto.getPrice());
assertTrue(savedProductDto.getAmount() == foundProductDto.getAmount());
}
}
import static
클래스의 static 메서드들을 클래스 이름을 입력하지 않고도 사용하도록 만들어 준다.
Assertions.assertTrue라고 입력해 줘야 하는것을 static으로 import 해주면 assertTure만 입력해서 사용 가능하다.
assertTure()
테스트 코드는 true를 Expected(예상) 했지만, Actual(실제) 값은 false면 검증에 실패하고 에러가 발생한다.
예외 발생에 대한 테스트 코드 추가
예외 체크 테스트 코드
SimpleProductServiceTest.java
@Test
@DisplayName("존재하지 않는 상품 id로 조회하면 EntityNotFoundException이 발생해야한다.")
void findProductNotExistIdTest() {
Long notExistId = -1L;
assertThrows(EntityNotFoundException.class,
() -> {simpleProductService.findByid(notExistId);});
}
prod모드 : DatabaseProductRepository 예외처리 하지 않고 테스트
EntityNotFoundException을 Expected 했지만, Actural은 EmptyResultDataAccessException이 발생
DatabaseProductRepository.java
public Product findById(Long id){
SqlParameterSource namedParameter = new MapSqlParameterSource("id", id);
Product product = null;
try{
product = namedParameterJdbcTemplate.queryForObject(
"SELECT id, name, price, amount FROM products WHERE id=:id"
, namedParameter, new BeanPropertyRowMapper<>(Product.class));
} catch (EmptyResultDataAccessException e){
throw new EntityNotFoundException("Product를 찾지 못했습니다.");
}
return product;
}
try-catch
Product를 try 블록 내부에 선언하면 return 시켜 줄 수 없기 때문에 깔끔해 보이지 않는 코드가 된다.
동일성과 동등성
동일성 검사
SimpleProductServiceTest.java
@SpringBootTest
@ActiveProfiles("prod")
class SimpleProductServiceTest {
...
@Test
@DisplayName("상품을 추가한 후 id로 조회하면 해당 상품이 조회되어야한다.")
void productAddAndFindByIdTest() {
ProductDto productDto = new ProductDto("연필", 300, 20);
ProductDto savedProductDto = simpleProductService.add(productDto);
Long savedProductId = savedProductDto.getId();
ProductDto foundProductDto = simpleProductService.findByid(savedProductId);
assertTrue(savedProductDto.getId() == foundProductDto.getId());
assertTrue(savedProductDto.getName() == foundProductDto.getName());
assertTrue(savedProductDto.getPrice() == foundProductDto.getPrice());
assertTrue(savedProductDto.getAmount() == foundProductDto.getAmount());
}
}
DatabaseProductRepository를 사용할 때와 ListProductRepository를 사용할 때 결과가 다른 이유
DB에서는 name의 두 인스턴스가 값만 같고 서로 다른 인스턴스지만, List에서는 name의 두 인스턴스가 같은 인스턴스다.
DB에서는 findById해서 조회시 외부에 있는 존재인 데이터베이스에 저장된 상품 데이터를 조회해서 새로운 Product를 생성한다.
- new BeanPropertyRowMapper<>(Product.class)
List에서는 애플리케이션의 메모리상에 존재하는 리스트에서 저장되어 있던 Product 인스턴스를 꺼내 와서 반환한다.
- foundProduct = product;
id에 대한 비교는 동일성 비교시 통과한 이유
id는 동일성 비교를 해도 같은 Long 인스턴스를 반환한다.
자바에서 일정 범위의 Long, Integer 인스턴스를 캐시를 생성하여 재사용한다.
단, 기본 설정으로 실행할 때 -128~127값에 대해 캐시를 사용한다.
동등성 검사
SimpleProductServiceTest.java
@Test
@DisplayName("상품을 추가한 후 id로 조회하면 해당 상품이 조회되어야한다.")
void productAddAndFindByIdTest() {
ProductDto productDto = new ProductDto("연필", 300, 20);
ProductDto savedProductDto = simpleProductService.add(productDto);
Long savedProductId = savedProductDto.getId();
ProductDto foundProductDto = simpleProductService.findByid(savedProductId);
assertTrue(savedProductDto.getId().equals(foundProductDto.getId()));
assertTrue(savedProductDto.getName().equals(foundProductDto.getName()));
assertTrue(savedProductDto.getPrice().equals(foundProductDto.getPrice()));
assertTrue(savedProductDto.getAmount().equals(foundProductDto.getAmount()));
}
saveProductDto와 foundProductDto도 서로 다른 ProductDto 인스턴스인가?
서로 다른 인스턴스이고 확인 : assertTrue(savedProductDto == foundProductDto);
ProductDto에서 Product로 변환되는 과정과 그 반대 과정에서는 왜 String 인스턴스를 새로 생성하지 않는가?
효율성 때문으로, 변환 과정에서도 새로운 인스턴스 생성시 비용이 든다.
참조만 복사(얕은 복사)하여 필드를 사용한다.
prod Profile로 실행하면 테스트코드 실행시 Propduct가 하나씩 추가 되는 문제
트랜잭션을 활용하여 테스트코드가 DB에 추가되지 않게 한다.
@Transactional
테스트 실행시 DB 데이터도 실행되는 문제 => 테스트 메서드 위에 @Transactional 애너테이션 추가
SimpleProductServiceTest.java
@Transactional
@Test
@DisplayName("상품을 추가한 후 id로 조회하면 해당 상품이 조회되어야한다.")
@Transactional 애너테이션
트랜잭셔널한 처리를 지원하기 위해 사용
@Test 애너테이션이랑 함께 사용하면, 해당 테스트 코드는 실행 후 '커밋'이 아닌 자동으로 '롤백'된다.
ModelMapper 제거
ModelMapper 사용하던 코드 파악
SimpleProductService.java
Product와 ProductDto를 변환해주는 코드로 사용 중
- ProductDto -> Product : modelMapper.map(productDto, Product.class)
- Product -> ProductDto : modelMapper.map(product, ProductDto.class)
ProductDto 코드에서 Product 클래스를 의존하는 형태로 두 객체 변환 메서드로 변경
- Product는 도메인 계층으로, 다른 어떤 계층에도 의존하지 않아야 함
ModelMapper 동작을 하는 코드 추가
ProductDto.java
public static Product toEntity(ProductDto productDto) {
Product product = new Product();
product.setId(productDto.getId());
product.setName(productDto.getName());
product.setPrice(productDto.getPrice());
product.setAmount(productDto.getAmount());
return product;
}
public static ProductDto toDto(Product product) {
ProductDto productDto = new ProductDto(
product.getName(),
product.getPrice(),
product.getAmount()
);
productDto.setId(product.getId());
return productDto;
}
toEntity에는 id 포함했는데, toDto에는 id를 제외한 이유
ProductDto 세 가지 필드로 초기화할 수 있는 생성자가 있기 때문이다.
가능하면 생성자를 통해 완전한 인스턴스를 생성하는 것이 setter를 통해 초기화해서 실수로 값이 초기화되지 않은 인스턴스가 생기는것 보다 낫다.
ModelMapper 사용하던 코드 제거
SimpleProductService.java
private ProductRepository productRepository;
private ValidationService validationService;
@Autowired
SimpleProductService(ProductRepository productRepository,
ValidationService validationService){
this.productRepository = productRepository;
this.validationService = validationService;
}
Mapper관련 코드 변경
modelMapper.map(productDto, Product.class) -> ProductDto.toEntity(productDto)
modelMapper.map(product, ProductDto.class) -> ProductDto.toDto(product)
Product.java
public Product(Long id, String name, Integer price, Integer amount) {
this.id = id;
this.name = name;
this.price = price;
this.amount = amount;
}
ProductDto.java
public ProductDto(String name, Integer price, Integer amount) {
this.name = name;
this.price = price;
this.amount = amount;
}
public ProductDto(Long id, String name, Integer price, Integer amount) {
this.id = id;
this.name = name;
this.price = price;
this.amount = amount;
}
public static Product toEntity(ProductDto productDto) {
Product product = new Product(
productDto.getId(),
productDto.getName(),
productDto.getPrice(),
productDto.getAmount()
);
return product;
}
public static ProductDto toDto(Product product) {
ProductDto productDto = new ProductDto(
product.getId(),
product.getName(),
product.getPrice(),
product.getAmount()
);
return productDto;
}
Application.java
/* modelMapper 관련 코드 제거 혹은 주석 처리
@Bean
public ModelMapper modelMapper() {
ModelMapper modelMapper = new ModelMapper();
modelMapper.getConfiguration()
.setFieldAccessLevel(Configuration.AccessLevel.PRIVATE)
.setFieldMatchingEnabled(true);
return modelMapper;
}
*/
Pom.xml
<!-- ModelMapper 의존성 제거 혹은 주석처리 후 새로고침
<dependency>
<groupId>org.modelmapper</groupId>
<artifactId>modelmapper</artifactId>
<version>3.1.0</version>
</dependency>-->
prod 실행 test 실행으로 변경
SimpleProductServiceTest.java
@SpringBootTest
@ActiveProfiles("test")
class SimpleProductServiceTest {
//@Transactional
test로 실행시 @Transactional 어노테이션 주석처리
}
@Transacrional
트랜잭션 처리가 필요하지 않은 곳에서 사용시 예외가 발생
test Profile 적용되는 테스트에 붙이면 예외가 발생
'프로젝트 > clone coding' 카테고리의 다른 글
[백엔드 개발 : ProductManagement] 클래스 추상화 (0) | 2025.04.10 |
---|---|
[백엔드 개발 : ProductManagement] 상품 관리 애플리케이션 기능 구현(MySQL) (0) | 2025.04.05 |
[백엔드 개발 : ProductManagement] 상품 관리 애플리케이션 DB 연동(MySQL) (0) | 2025.04.04 |
[백엔드 개발 : ProductManagement] 전역 예외 핸들러 추가 (0) | 2025.03.27 |
[백엔드 개발 : ProductManagement] 유효성 검사 추가 (0) | 2025.03.20 |