이전에 작성했던 추상화를 통해 외부 시스템의 결합도를 낮추고 테스트 가능성(Testability) 높이기 라는 글에서는 외부 시스템의 의존성을 주입하는 경로를 추상화하여 Mock을 사용하지 않는 Fake 객체를 주입하여 테스트 가능성을 높일 수 있다라고 소개드렸습니다.
저는 상황에 따라 다르지만 일반적으로 핵심 비즈니스 로직에 대한 단위 테스트를 작성할 때 여러 테스트 더블(Test Doubles) 중에서 대중적으로 사용하는 Mock이나 Stub을 활용하지 않고 Fake 객체를 통해 가짜 객체를 주입하여 테스트하는 것을 좋아하는데요. 추상화를 통해 추상화 수준이 올라간 비즈니스 계층에서 호출되는 메소드가 DB나 다른 시스템을 의존하고 있을 때 보다 효과적으로 외부 의존성을 격리한 채로 테스트할 수 있도록 도와주기 때문이에요.
그래서 이번 글에서는 비즈니스 계층의 핵심 로직들을 테스트하는데 Fake 객체를 활용하여 테스트 코드를 작성하는 방법을 간단히 소개해볼게요.
테스트 더블(Test Double)은 xUnit Test Patterns의 저자인 Gerard Meszaros라는 분이 만드신 용어인데요. 테스트 하기 어려운 상황에서 테스트할 수 있도록 도와주는 객체를 뜻합니다. 테스트 더블에 대한 자세한 내용은 여기서는 생략하고 Fake 객체가 어떻게 테스트를 도와주는지 간단하게 짚고 넘어가볼까요? xUnit Patterns에서는 Fake 객체에 대해서 아래와 같이 설명하고 있어요.
Fake 객체는 실제로 동작하는 구현을 대체하기 위해 사용하며, 실제 프로덕션 코드와 동일한 기능을 훨씬 간단한 방식으로 구현한다.
제가 이해한 테스트 더블에서의 Fake 객체는 테스트 대상이 되는 객체에서 필요로 하는 외부 객체들의 동작을 추상화를 통해 단순화하여 구현한 객체라고 이해했어요. 그리고 Fake 객체를 활용한 방식은 우리가 개발한 비즈니스 영역의 핵심 로직이 가지고 있는 모든 것들을 덜어내고 순수한 로직 본연의 것을 테스트하기에 좋다고 느껴졌어요.
이전 글에서 실제 프로덕션 코드의 추상화 수준에 맞추어 테스트 패키지에 Fake 객체를 구현함으로써 AWS S3나 MiniO와 같은 외부 시스템의 의존성을 격리하고 테스트할 수 있는 테스트 코드를 작성했었죠. 그래서 다양한 테스트 더블로 사용할 수 있는 방법들 중에서 Fake 객체를 선택한 이유를 간단히 정리해보면 다음과 같아요.
💡 Fake 객체를 통해 테스트를 수행하면?
- Mock이나 Stub과 같은 다른 테스트 더블 의존성을 줄이고 순수한 비즈니스 로직만을 테스트할 수 있다!
- 외부 의존성을 주입하는 중형(통합) 테스트보다 빠른 속도로 테스트를 수행할 수 있다!
- 테스트 클래스의 가독성을 한층 높여 동료와의 협업 생산성을 늘릴 수 있다!
이전 글에서도 소개했었지만 한번 더 코드를 살펴볼게요. 여기서 소개할 테스트 코드는 애플리케이션 비즈니스 로직을 통해 외부 시스템인 오브젝트 스토리지 AWS S3에 구축된 버킷에 이미지를 업로드하는 테스트 케이스를 기준으로 작성했어요.
테스트 대상 비즈니스 계층의 서비스 클래스는
BucketSetviceImpl
이며 AWS S3와의 통신을 담당하는 인터페이스는ObjectStorageClient
로 명명했어요. 코드 리딩에 참고하시길 바랄게요.
실제 프로덕션 패키지에 개발된 비즈니스 계층의 서비스 클래스 구조는 다음과 같아요. BucketServiceImpl
클래스에서 ObjectStorageClient
인터페이스를 의존하고 있답니다. uploadS3BucketWithGetUrls
메소드에서 주입된 오브젝트 스토리지에 따라 지정된 버킷으로 이미지를 업로드하도록 구현되어 있어요.
@Service
@RequiredArgsConstructor
public class BucketServiceImpl implements BucketService {
// 구현체가 아닌 인터페이스 의존
private final ObjectStorageClient objectStorageClient;
// 오브젝트 스토리지 버킷에 이미지 업로드
private List<String> uploadS3BucketWithGetUrls(List<MultipartFile> imageFiles) {
return imageFiles.stream()
.map(objectStorageClient::upload)
.filter(Objects::nonNull)
.collect(Collectors.toList());
}
}
서비스 클래스에서 ObjectStorageClient
라는 구현체가 아닌 인터페이스를 의존하고 있기 떄문에 테스트 패키지에서 ObjectStorageClient에
대한 실제 구현체를 Fake 객체로 만들어 줄 수 있어요. 실제 AWS S3와의 통신을 구현해야 하기 때문에 FakeAwsS3Uploader
라는 Fake 객체를 만들고 이미지 업로드 후 반환되는 URL을 반환받을 수 있도록 upload
메소드를 구현해주었어요.
public class FakeAwsS3Uploader implements ObjectStorageClient {
@Override
public String upload(MultipartFile file) {
return "https://saramara-storage.s3.ap-northeast-2.amazonaws.com/test.png";
}
}
이렇게 Fake 객체를 만들었으니 BucketServiceImpl
서비스 클래스에 대한 테스트 코드를 짜볼 수 있어요. BucketServiceImplUnitTest
테스트 클래스를 보면 setUp 부분에서 BucketServiceImpl
을 초기화할 때 실제 구현체인 FakeAwsS3Uploader
를 주입해주고 있어요.
class BucketServiceImplUnitTest {
private BucketService bucketService;
@BeforeEach
void setUp() {
// Fake 객체 주입
this.bucketService = new BucketServiceImpl(new FakeAwsS3Uploader());
}
@Nested
@DisplayName("이미지를 버킷에 업로드할 경우")
class 이미지를_버킷에_업로드할_경우 {
@Test
@DisplayName("[Green] 이미지 URL 경로가 담긴 목록을 응답받는다.")
void 이미지_URL_경로를_응답받는다() {
// given
List<MultipartFile> request = createImageFileList(1);
// when
BucketUploadResponse response = bucketService.uploadImages(request);
// then
assertThat(response.images())
.hasSize(1)
.allMatch(url -> url.contains("test.png"));
}
@Test
@DisplayName("[Green] 여러 장을 업로드하면 복수의 이미지 URL 경로가 담긴 목록을 응답받는다.")
void 여러_장을_업로드하면_복수의_이미지_URL_경로가_담긴_목록을_응답받는다() {
// given
List<MultipartFile> request = createImageFileList(3);
// when
BucketUploadResponse response = bucketService.uploadImages(request);
// then
assertThat(response.images()).hasSize(3)
.isEqualTo(List.of(
"https://saramara-storage.s3.ap-northeast-2.amazonaws.com/test.png",
"https://saramara-storage.s3.ap-northeast-2.amazonaws.com/test.png",
"https://saramara-storage.s3.ap-northeast-2.amazonaws.com/test.png"
));
}
@Test
@DisplayName("[Edge] 최소 1장의 이미지를 업로드해야 한다.")
void 최소_1장의_이미지를_업로드해야_한다() {
// given
List<MultipartFile> request = createImageFileList(1);
// when
BucketUploadResponse response = bucketService.uploadImages(request);
// then
assertThat(response.images()).hasSize(1);
}
@Test
@DisplayName("[Exception] 요청받은 이미지 목록이 비어있다면 예외가 발생한다.")
void 요청받은_이미지_목록이_비어있다면_예외가_발생한다() {
// given
List<MultipartFile> request = createImageFileList(0);
// when & then
assertThatThrownBy(() -> bucketService.uploadImages(request))
.isInstanceOf(BucketBusinessException.class)
.hasMessage(BUCKET_IMAGE_MIN_RANGE_OUT.getMessage());
}
@Test
@DisplayName("[Green] 최대 5장까지 업로드할 수 있다.")
void 최대_5장까지_업로드할_수_있다() {
// given
List<MultipartFile> request = createImageFileList(5);
// when
BucketUploadResponse response = bucketService.uploadImages(request);
// then
assertThat(response.images()).hasSize(5);
}
@Test
@DisplayName("[Exception] 요청받은 이미지가 5장을 초과한다면 예외가 발생한다.")
void 요청받은_이미지가_5장을_초과한다면_예외가_발생한다() {
// given
List<MultipartFile> request = createImageFileList(6);
// when & then
assertThatThrownBy(() -> bucketService.uploadImages(request))
.isInstanceOf(BucketBusinessException.class)
.hasMessage(BUCKET_IMAGE_MAX_RANGE_OUT.getMessage());
}
}
private static List<MultipartFile> createImageFileList(int size) {
return IntStream.range(0, size)
.mapToObj(i -> new MockMultipartFile("test", "test.png", "image/png", "test_file".getBytes(UTF_8)))
.collect(Collectors.toList());
}
}
위와 같이 Mock 의존도 없는 순수한 함수의 구현 여부를 검증하게 되니 테스트 수행 속도가 빠른 편입니다.
이번에는 Fake 객체가 아닌 다른 테스트 더블인 Mock이나 Stub을 사용했을 때와 어떤 차이가 있을지 직접 비교해 볼까요?
Mockito를 통해 애플리케이션에서 AWS S3와의 통신을 하기 위한 AmazonS3
클라이언트 객체를 Mock 객체로 만들어 주기 위해 테스트 패키지에 아래와 같은 Config 클래스를 만들어주었어요. 단위 테스트 수행 시 테스트 컨텍스트가 실행되면서 실제 프로덕션 환경의 AmazonS3
클라이언트 객체가 아닌 테스트를 위해 해당 클래스에서 정의해둔 대로 빈을 등록합니다.
@Configuration
@Profile("test")
public class AwsS3MockConfig extends AwsS3Config {
@Bean
@Primary
@Override
public AmazonS3 amazonS3() {
return Mockito.mock(AmazonS3.class);
}
@Bean
public S3Mock s3Mock() {
return new S3Mock.Builder().withPort(8001).withInMemoryBackend().build();
}
@Bean(name = "amazonS3Client", destroyMethod = "shutdown")
public AmazonS3Client amazonS3Client(){
AwsClientBuilder.EndpointConfiguration endpoint = new AwsClientBuilder.EndpointConfiguration("http://127.0.0.1:8001", Regions.AP_NORTHEAST_2.name());
return (AmazonS3Client) AmazonS3ClientBuilder.standard()
.withPathStyleAccessEnabled(true)
.withEndpointConfiguration(endpoint)
.withCredentials(new AWSStaticCredentialsProvider(new AnonymousAWSCredentials()))
.build();
}
}
그리고 setUp 부분에서 amazonS3 객체의 putObject 메소드나 getUrl 메소드 호출 시 미리 정해진 응답을 반환하도록 Stub을 추가해 두었어요.
@SpringBootTest
@ActiveProfiles("test")
class BucketServiceImplUnitTest {
@Autowired
private BucketService bucketService;
@Autowired
private AmazonS3 amazonS3;
@BeforeEach
void setUp() throws Exception {
// amazonS3 Stubbing
given(amazonS3.putObject(any(PutObjectRequest.class))).willReturn(new PutObjectResult());
given(amazonS3.getUrl(any(), any())).willReturn(new URL("https://saramara-storage.s3.ap-northeast-2.amazonaws.com/test.png"));
}
@Nested
@DisplayName("이미지를 버킷에 업로드할 경우")
class 이미지를_버킷에_업로드할_경우 {
@Test
@DisplayName("[Green] 이미지 URL 경로가 담긴 목록을 응답받는다.")
void 이미지_URL_경로를_응답받는다() {
// given
List<MultipartFile> request = createImageFileList(1);
// when
BucketUploadResponse response = bucketService.uploadImages(request);
// then
assertThat(response.images()).hasSize(1)
.isEqualTo(List.of(
"https://saramara-storage.s3.ap-northeast-2.amazonaws.com/test.png"
));
}
@Test
@DisplayName("[Green] 여러 장을 업로드하면 복수의 이미지 URL 경로가 담긴 목록을 응답받는다.")
void 여러_장을_업로드하면_복수의_이미지_URL_경로가_담긴_목록을_응답받는다() {
// given
List<MultipartFile> request = createImageFileList(3);
// when
BucketUploadResponse response = bucketService.uploadImages(request);
// then
assertThat(response.images()).hasSize(3)
.isEqualTo(List.of(
"https://saramara-storage.s3.ap-northeast-2.amazonaws.com/test.png",
"https://saramara-storage.s3.ap-northeast-2.amazonaws.com/test.png",
"https://saramara-storage.s3.ap-northeast-2.amazonaws.com/test.png"
));
}
@Test
@DisplayName("[Edge] 최소 1장의 이미지를 업로드해야 한다.")
void 최소_1장의_이미지를_업로드해야_한다() {
// given
List<MultipartFile> request = createImageFileList(1);
// when
BucketUploadResponse response = bucketService.uploadImages(request);
// then
assertThat(response.images()).hasSize(1);
}
@Test
@DisplayName("[Exception] 요청받은 이미지 목록이 비어있다면 예외가 발생한다.")
void 요청받은_이미지_목록이_비어있다면_예외가_발생한다() {
// given
List<MultipartFile> request = createImageFileList(0);
// when & then
assertThatThrownBy(() -> bucketService.uploadImages(request))
.isInstanceOf(BucketBusinessException.class)
.hasMessage(BUCKET_IMAGE_MIN_RANGE_OUT.getMessage());
}
@Test
@DisplayName("[Green] 최대 5장까지 업로드할 수 있다.")
void 최대_5장까지_업로드할_수_있다() {
// given
List<MultipartFile> request = createImageFileList(5);
// when
BucketUploadResponse response = bucketService.uploadImages(request);
// then
assertThat(response.images()).hasSize(5);
}
@Test
@DisplayName("[Exception] 요청받은 이미지가 5장을 초과한다면 예외가 발생한다.")
void 요청받은_이미지가_5장을_초과한다면_예외가_발생한다() {
// given
List<MultipartFile> request = createImageFileList(6);
// when & then
assertThatThrownBy(() -> bucketService.uploadImages(request))
.isInstanceOf(BucketBusinessException.class)
.hasMessage(BUCKET_IMAGE_MAX_RANGE_OUT.getMessage());
}
}
private static List<MultipartFile> createImageFileList(int size) {
return IntStream.range(0, size)
.mapToObj(i -> new MockMultipartFile("test", "test.png", "image/png", "test_file".getBytes(UTF_8)))
.collect(Collectors.toList());
}
}
Fake 객체를 주입하여 테스트한 BucketServiceImplUnitTest 테스트 클래스와 Mock 객체를 활용한 BucketServiceImplUnitTest 테스트 클래스와의 차이는 지금은 그리 커보이지 않을 수 있지만, 실무 프로덕션 레벨에 진입하게 되면 더욱 차이가 날 수도 있다고 생각됩니다.
여기까지 비즈니스 계층에서의 소형 테스트를 진행할 때 Fake 객체를 활용하는 방식을 소개해 봤는데요. 소형 테스트 수행시 Fake 객체를 사용하는게 반드시 정답만은 아니라는 점을 꼭 전하고 싶어요.
// Mock 테스트
@BeforeEach
void setUp() throws Exception {
// amazonS3 Stubbing
given(amazonS3.putObject(any(PutObjectRequest.class))).willReturn(new PutObjectResult());
given(amazonS3.getUrl(any(), any())).willReturn(new URL("https://saramara-storage.s3.ap-northeast-2.amazonaws.com/test.png"));
}
// Fake 테스트
@BeforeEach
void setUp() {
this.bucketService = new BucketServiceImpl(new FakeAwsS3Uploader());
}
Mock을 통해 작성한 테스트 코드의 setUp 구문과 앞으로 amazonS3의 여러 메소드가 비즈니스 로직에 침투될 때마다 given절이 확장될 여지가 있는 반면, Fake 객체를 통해 작성한 테스트 코드의 setUp 구문은 수정하지 않아도 됩니다.
또한 테스트 클래스가 검증하고자 하는 순수한 비즈니스 로직의 테스트 케이스만을 초점을 두어 개발할 수 있어 매번 setUp절을 신경써야 하는 Mock 테스트 코드보다는 테스트 생산성을 늘릴 수 있을 것 같아요.
실제 프로덕션 코드 동작을 그대로 대체할 Fake 객체를 일일이 구현해주어야 하는 작업은 때때로 정말 번거로울 수 있어요.
비즈니스 계층의 소형 테스트를 수행할 때는 당연히 팀 내 컨벤션을 먼저 따르는 것이 우선입니다. 팀 내에서 Mock 기반으로 소형 테스트를 작성하고 있을 수도 있고, 또 다른 테스트 더블을 통한 소형 테스트를 작성하고 있을 수도 있어요. 소형 테스트가 없을 수도 있구요. 그리고 대부분 현실적이거나 합리적인 이유로 저마다의 테스트 컨벤션이 유지되고 있을 겁니다. 프로젝트 상황에 따라 동료들과 합의하여 적절한 테스트 컨벤션을 도출하는 것이 우선순위가 되었으면 좋겠습니다.
개발하고 있는 애플리케이션의 비즈니스 관심사를 테스트 코드로 검증하는 것은 매우 중요한 일이지만, 그만큼 시간과 비용이 많이 들게 됩니다. 특히나 DB와의 무겁고 복잡한 조회 쿼리를 Fake 객체로 풀어내려면 중형(통합) 테스트나 Mock, Stub을 활용한 소형(단위) 테스트로 풀어내는 것도 또 다른 전략이 될 수도 있어요!
비즈니스 계층(Business Layer)의 외부 의존성을 느슨하게 결합해둔다면, Fake 객체와 같은 테스트 러닝커브를 줄여주는 테스트 더블을 활용하여 우아한 테스트 컨벤션 구조를 만들 수 있게 되어요.
⏳ 이번 글은 1일동안 3시간을 투자하여 작성했습니다.
Fake.. 번거롭지만 테스트 일관성을 지키기에는 이만한게 또 없는 것 같아요 ㅎ