단위 테스트 도입기
현재 팀 내에서 개발중인 프로젝트에 단위 테스트를 도입하기 위해 시도하였던 방법을 정리한다.
모든 코드는 실제 코드가 아닌 예시 코드로 작성하였다.
테스트 할 코드
@Service
@RequiredArgsConstructor
public class Service {
private final VaultService vaultService;
private final DataHandler dataHandler;
@Transactional
public void save(RequestBody requestBody) {
// 비즈니스 로직...
dataHandler.save(requestBody);
vaultService.save(requestBody);
}
}
기존의 테스트 코드
@SpringBootTest( classes = Application.class )
class Test {
@MockBean
private VaultService vaultService;
@SpyBean
private DataHandler dataHandler;
@Autowired
private Service service;
@Test
@Transactional
public void saveTest() {
// given
RequestBody requestBody = RequestBody.builder()
.id( "id" )
.name( "name" )
.build();
// when
service.save( requestBody );
// then
Entity savedData = dataHandler.findById( "id" );
assertThat( savedData.getName() ).isEqualTo( "name" );
}
}
위 테스트 코드의 문제점
- SpringBootTest를 사용해서 매우 느리다.
- 단위 테스트는 빨라야 함.
- SpringBootTest는 Spring과 결합되므로 단위 테스트라고 볼 수 없음.
- 실제 DB를 호출한다.
- VaultService의 경우 Mock을 사용하였지만 DataHandler의 경우 실제 값이 저장되었는지를 확인해야 하므로 Spy 객체(실제 객체)를 사용하였다.
- 또한 테스트 시 H2를 사용하지 않고 기존에 사용하던 DB 서버를 연결해서 테스트하였다.
- 테스트 코드에서 사용한 PK와 같은 PK의 데이터가 이미 존재한다면 실패하게 됨,
- 또한 DB에 문제가 생기면 바로 실패하게 됨.
- 비결정론적 테스트
- 만약 H2 DB를 사용해서 테스트 한다고 해도, 매번 DDL, 사전 준비 쿼리를 실행시켜야 하는 번거로움, 추가 시간이 필요하다.
리팩토링
우선 SpringBootTest 어노테이션을 제거하자
이 때, DB와 Vault에 대한 의존성(DataHandler, VaultService)를 Stub객체로 만들면 좋겠다고 생각했다.
따라서 DataHandler와 VaultService을 인터페이스로 추상화 하고, 실제 객체와 Mock 객체를 분리할 수 있도록 하였다. (의존성 역전)
public interface DataHandler {
void save(RequestBody requestBody);
~~}~~
public interface VaultService {
void save(RequestBody requestBody);
}
기존의 의존성 객체들은 모두 Impl이라는 이름을 붙여줬다.
@Component
@RequiredArgsConstructor
public class DataHandlerImpl implements DataHandler {
private final Repository repository;
@Override
public void save(RequestBody requestBody) {
// DTO -> Entity 변환 로직...
repository.save(entity);
}
~~}~~
@Component
@RequiredArgsConstructor
public class VaultServiceImpl implements VaultService {
private final VaultTemplateFactory vaultTemplateFactory;
@Override
public void save(RequestBody requestBody) {
vaultTemplateFactory.create().save(requestBody);
}
}
수정한 테스트 코드
// @SpringBootTest
class Test {
private VaultService vaultService;
**private DataHandler dataHandler;
private Service service;
public Test() {
this.dataHandler = new FakeDataHandler();
this.vaultService = new SpyVaultService();
this.service = new Service(dataHandler, vaultService);
}
@Test
public void saveTest() {
// given
RequestBody requestBody = RequestBody.builder()
.id( "id" )
.name( "name" )
.build();
// when
service.save( requestBody );
// then
Entity savedData = dataHandler.findById( "id" );
assertThat( savedData.getName() ).isEqualTo( "name" );
assertThat( vaultService.saveCount ).isEqualTo( 1 );
}
}
FakeDataHandler
public class FakeDataHandler {
private Map<String, Entity> entityRepository = new HashMap<>();
@Override
public void save(RequestBody requestBody) {
// DTO -> Entity 변환
Map.put(requestBody.getId(), entity);
}
public Entity findById(String id) {
return Map.get(id);
**}
}
Map으로 인메모리 데이터베이스를 구현
SpyVaultService
public class SpyVaultService{
public int saveCount;
@Override
public void save(RequestBody requestBody) {
this.saveCount++;
}
}
Vault로 요청이 보내졌는지만 체크하도록 구현
위 테스트용 객체 클래스의 이름(Spy, Fake 등)은 Gerald Meszaros의 5가지 Test double 종류를 따랐다.
총 5 종류의 Test double은 다음과 같다.
-
Dummy
객체는 전달되지만 실제로는 사용되지 않음. 일반적으로 매개변수 목록을 채우는 데 사용됨.
-
Fake
개체에는 실제로 작동하는 구현이 있지만 일반적으로 생산에 적합하지 않게 만드는 몇 가지 지름길을 사용함(인메모리 DB가 좋은 예).
-
Stub
테스트 중에 이루어진 호출에 대해 미리 준비된 답변을 제공하며 일반적으로 테스트를 위해 프로그래밍된 것 이외의 것에는 전혀 응답하지 않음.
-
Spy
호출된 방식에 따라 일부 정보를 기록하는 스텁. 이에 대한 한 가지 형태는 전송된 메시지 수를 기록하는 이메일 서비스일 수 있다.
-
Mock
수신할 것으로 예상되는 호출의 사양을 형성하는 기대로 미리 프로그래밍된 객체입니다.
하지만 보통은 테스트 더블의 상태를 확인해서 검증할 때에는 Stub, 메서드를 호출했는지 를 검증할 때에는 Mock 이 두 가지를 주로 사용하는 것 같다.
이렇게 해서 Spring이나 DB와 같은 여러 외부 종속성에 영향을 받지 않는 독립적인 단위 테스트를 수행할 수 있게 되었다.