LocalStack을 이용한 AWS S3 테스트 작성 (feat. Testcontainers)
1. 개요
AWS 와 같은 외부 서비스를 사용하는 어플리케이션은 테스트를 구성하기 까다롭다는 장벽이 있다. 외부 의존성, 비용, 네트워크 등의 이슈로 인해 테스트의 멱등성, 일관성 등이 보장되지 않으며, 테스트 코드가 올바르게 작성 되었지만 외부 이슈로 인해 실패할 수 도 있다.
이를 해결하기 위해 로컬 환경에서 AWS 를 모방하여 시뮬레이션 할 수 있도록 하는 LocalStack 을 활용하여 실제 AWS 서비스에 연결하지 않고도 AWS 의 다양한 서비스들을 시뮬레이션 해 볼 수 있다. 이번 포스팅에선, S3 기능을 테스트 해 보겠다.
2. Gradle 의존성
다음과 같이 LocalStack 의존성을 추가해 주었다.
testImplementation 'org.testcontainers:localstack:1.20.6'
다른 버전이 필요하다면 여기서 확인하자.
https://mvnrepository.com/artifact/org.testcontainers/localstack
3. docker-compose.local.yml 파일 작성
기존에 작성해둔 docker-compose.yml 이 있기 때문에 테스트 전용 compose 파일을 따로 구성하였다.
version: "3.8"
services:
localstack:
image: localstack/localstack
ports:
- "4566:4566"
environment:
- SERVICES=s3
- DEBUG=1
위 yml 파일은 다음과 같다.
- localstack/localstack 이미지를 사용해서 컨테이너를 만든다
- 4566 port 를 연다
- SERVICES : 사용할 localstack 서비스
- DEBUG : LocalStack 내부 로그레벨
만약, 컨테이너가 종료되어도 업로드 된 파일이 유지되어야 한다면 따로 볼륨을 구성하자.
4. 테스트용 환경 변수 파일
localstack 은 AWS 처럼 인증 정보가 필요하지 않다. 그렇기 때문에 필요한 정보만 기입한 .env 파일을 새롭게 생성해 주면 된다.
AWS_S3_ACCESS_KEY=test
AWS_S3_SECRET_KEY=test
AWS_S3_REGION=ap-northeast-2
AWS_S3_BUCKET=test-bucket
AWS_S3_PRESIGNED_URL_EXPIRATION=60
5. S3Client, presignURL 생성 메서드 재정의
실제 코드의 getS3Client 와 generatePresignedUrl 은 실제 AWS 서비스에 접근하여 접속하고 작업을 실행하게 된다.
하지만, 지금은 테스트환경이며, 실제 AWS 서비스가 아닌 일종의 MOCK 서버로 시뮬레이션 테스트를 하는 것이기 때문에 @Override 로 해당 메서드 들을 재정의 해주어야 한다.
// 실제 getS3Client(){}
public S3Client getS3Client() {
AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey);
return S3Client.builder()
.region(Region.of(region))
.credentialsProvider(StaticCredentialsProvider.create(credentials))
.build();
}
// 재정의한 테스트용 getS3Client()
@Override
public S3Client getS3Client() {
return S3Client.builder()
.endpointOverride(URI.create("http://localhost:4566")) // LocalStack 주소
.region(Region.of(region))
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(accessKey, secretKey)))
.forcePathStyle(true)
.build();
}
endpointOverride를 통해 AWS 대신 LocalStack 으로 요청을 전송- 이때,
forcePathStyle()을 통해 명시적으로 경로를 입력해 준다는 것을 알려주어야 한다. StaticCredentialsProvider: 를 통해 자격 증명을 명시적으로 설정
// 실제 generatePresignedUrl()
public String generatePresignedUrl(String key, String contentType) {
try (S3Presigner presigner = S3Presigner.builder()
.region(Region.of(region))
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(accessKey, secretKey)))
.build()) {
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
.bucket(bucket)
.key(key)
.build();
GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
.signatureDuration(Duration.ofSeconds(expirationTime))
.getObjectRequest(getObjectRequest)
.build();
PresignedGetObjectRequest presignedGetObjectRequest = presigner.presignGetObject(
presignRequest);
return presignedGetObjectRequest.url().toString();
}
}
// 재정의한 테스트용 generatePresignedUrl()
@Override
public String generatePresignedUrl(String key, String contentType) {
try (S3Presigner presigner = S3Presigner.builder()
.endpointOverride(URI.create("http://localhost:4566")) // LocalStack 주소
.region(Region.of(region))
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(accessKey, secretKey)))
.build()) {
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
.bucket(bucket)
.key(key)
.build();
GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
.signatureDuration(Duration.ofSeconds(expirationTime))
.getObjectRequest(getObjectRequest)
.build();
PresignedGetObjectRequest presignedGetObjectRequest = presigner.presignGetObject(
presignRequest);
return presignedGetObjectRequest.url().toString();
}
}
또한, LocalStack 은 실제 S3 가 아니기 때문에, 테스트 전 임시 버킷을 생성하는 로직이 필요하다
storage.getS3Client().createBucket(CreateBucketRequest.builder().bucket(bucket).build());
6. 단점 및 대안
LocalStack 은 분명 강력한 도구이지만, 다음과 같은 단점이 있다
- 테스트마다 컨테이너 초기화가 되지 않는다.
- .env 혹은 compose 파일이 필요하다 -> 외부 설정 의존
- LocalStack 컨테이너가 떠있지 않을 경우 테스트가 실패한다
- CI 와 연동하려면 별도의 shell script 를 작성해야 한다
이 단점을 보완하고자, @TestContainers 를 활용한 방법도 작성해 보고자 한다.
Testcontainers + LocalStack은 테스트 코드상에서 java 코드로 직접 LocalStack 컨테이너를 자동으로 실행하고 제어하는 방식이다.
그렇기 때문에, 완전한 자동화, 테스트 격리성, .env 나 docker-compose 없이 실행 가능함이 보장된다.
이전의 LocalStack만 사용한 방식은 직접 컨테이너를 띄우고 종료하거나, shell script 를 통한 제어가 필수적이지만, Testcontainers 를 사용한 방식은 테스트마다 컨테이너가 실행되며, 테스트 종료시 자동 종료된다.
6.1 의존성
아까의 의존성에 더불어 다음 의존성을 추가하자
testImplementation 'org.testcontainers:junit-jupiter:1.20.6'
6.2 컨테이너 생성
이제 실행할 컨테이너를 명시해주어야 한다.
@Container
static LocalStackContainer localstack = new LocalStackContainer(
DockerImageName.parse("localstack/localstack")
).withServices(LocalStackContainer.Service.S3);
위와 같이 실행할 컨테이너 image name 과 사용할 AWS 서비스를 명시해 주면 된다.
6.3 AWS 자격 증명 부여
Testcontainers 를 사용하면, 컨테이너가 시작 될때, 자동으로 AWS credential 이 부여된다. 하지만 이 테스트 코드에선, S3Client 오버라이드 등, credential 이 필요하기 때문에, 다음과 같이 불러오면 된다.
String accessKey = localstack.getAccessKey();
String secretKey = localstack.getSecretKey();
URI endpoint = localstack.getEndpointOverride(LocalStackContainer.Service.S3);
이로써, URI 가 하드코딩되지 않아, 실제로 실행된 컨테이너의 주소를 동적으로 받아와 CI 이식성, 자동화에 매우 유리하다.
6.4 전체 테스트 코드
package com.sprint.mission.discodeit.storage;
import com.sprint.mission.discodeit.storage.s3.S3BinaryContentStorage;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.time.Duration;
import java.util.UUID;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.http.ResponseEntity;
import org.testcontainers.containers.localstack.LocalStackContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest;
@Testcontainers
public class S3BinaryContentStorageTest {
private static final String BUCKET = "test-bucket";
private static final Region REGION = Region.AP_NORTHEAST_2;
private static final int EXPIRATION = 60;
@Container
static LocalStackContainer localstack = new LocalStackContainer(
DockerImageName.parse("localstack/localstack")
).withServices(LocalStackContainer.Service.S3);
private static S3BinaryContentStorage storage;
private static UUID id = UUID.randomUUID();
private static byte[] content = "test content".getBytes();
@BeforeAll
static void init() throws Exception {
String accessKey = localstack.getAccessKey();
String secretKey = localstack.getSecretKey();
URI endpoint = localstack.getEndpointOverride(LocalStackContainer.Service.S3);
storage = new S3BinaryContentStorage(accessKey, secretKey, REGION.id(), BUCKET, EXPIRATION) {
@Override
public S3Client getS3Client() {
return S3Client.builder()
.endpointOverride(endpoint) // 동적 LocalStack 주소
.region(REGION)
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(accessKey, secretKey)))
.forcePathStyle(true)
.build();
}
@Override
public String generatePresignedUrl(String key, String contentType) {
try (S3Presigner presigner = S3Presigner.builder()
.endpointOverride(endpoint) // LocalStack 주소
.region(REGION)
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(accessKey, secretKey)))
.build()) {
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
.bucket(BUCKET)
.key(key)
.build();
GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
.signatureDuration(Duration.ofSeconds(EXPIRATION))
.getObjectRequest(getObjectRequest)
.build();
PresignedGetObjectRequest presignedGetObjectRequest = presigner.presignGetObject(
presignRequest);
return presignedGetObjectRequest.url().toString();
}
}
};
storage.getS3Client().createBucket(CreateBucketRequest.builder().bucket(BUCKET).build());
id = UUID.randomUUID();
content = "test content".getBytes();
}
@Test
void testPutAndGet() throws IOException {
storage.put(id, content);
InputStream stream = storage.get(id);
byte[] downloaded = stream.readAllBytes();
Assertions.assertThat(downloaded).isEqualTo(content);
}
@Test
void testDownloadPresignedUrl() throws Exception {
ResponseEntity<?> response = storage.download(id);
Assertions.assertThat(response.getStatusCode().is3xxRedirection()).isTrue();
String location = response.getHeaders().getLocation().toString();
Assertions.assertThat(location.contains(id.toString())).isTrue();
}
}
7. 마무리
지금까지 LocalStack 과 Testcontainers 를 통해 S3 기능을 로컬에서 시뮬레이션 테스트 하는 방법을 알아보았다.
외부 의존성에 의존하는 테스트를 작성하는것은 언제나 까다로운 일이지만, 이와 같이 적절한 방법을 통해 테스트를 작성할 경우, 어플리케이션의 신뢰성과 무결성을 보장할 수 있다.
이번 글에서는 S3 를 예시로 들었지만, LocalStack 이 제공하는 다양한 서비스를 통해 테스트의 범위를 확장시킬 수 있다.