Testing DynamoDB Interactions using LocalStack and Testcontainers
Write efficient integration tests to validate the applications interactions with AWS DynamoDB, by leveraging a disposable LocalStack container in Spring Boot.
- Spring Boot : is a popular framework for building Java-based, production-ready services with minimal configuration.
- AWS DynamoDB : is a fully managed NoSQL database service provided by AWS and we'll be using it as the datasource that our backend application connects to.
- LocalStack : is a cloud service emulator that enables development and testing of AWS services without connecting to a remote cloud provider. We'll be provisioning an in-memory DynamoDB table inside this emulator.
- Testcontainers : is a library that provides lightweight, throwaway instances of Docker containers for integration testing.
AmazonDynamoDB
and DynamoDBMapper
in a configuration class.AmazonDynamoDB
, that'll get injected into the application context depending on whether the application is running in test or non-test profiles.@ConfigurationProperties
to map the values defined in the active .yaml
or .properties
file to a POJO that'll be referenced by our configuration class. 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class AwsConfigurationProperties {
private String accessKey;
private String secretAccessKey;
private String region;
private String endpoint;
}
endpoint
field is optional and is needed to be defined only for testing and local development when using LocalStack. application.yaml
file where we've defined the required properties which will be automatically mapped to the above defined class, along with the current active profile that we'll be using in our bean configuration class. 1
2
3
4
5
6
7
8
9
10
11
spring:
profiles:
active: ${ACTIVE_PROFILE}
com:
behl:
aws:
access-key: ${AWS_ACCESS_KEY_ID}
secret-access-key: ${AWS_SECRET_ACCESS_KEY}
region: ${AWS_REGION}
endpoint: ${AWS_ENDPOINT_URL}
AwsConfiguration
class that defines the beans required for connecting to DynamoDB depending upon the active profile. 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class AwsConfiguration {
private final AwsConfigurationProperties awsConfigurationProperties;
public AmazonDynamoDB amazonDynamoDB() {
var credentials = constructCredentials();
var regionName = awsConfigurationProperties.getRegion();
return AmazonDynamoDBClientBuilder.standard()
.withRegion(Regions.fromName(regionName))
.withCredentials(credentials)
.build();
}
public AmazonDynamoDB testAmazonDynamoDb() {
var credentials = constructCredentials();
var regionName = awsConfigurationProperties.getRegion();
var endpointUri = awsConfigurationProperties.getEndpoint();
var endpointConfig = new EndpointConfiguration(endpointUri, regionName);
return AmazonDynamoDBClientBuilder.standard()
.withEndpointConfiguration(endpointConfig)
.withCredentials(credentials)
.build();
}
public DynamoDBMapper dynamoDBMapper(AmazonDynamoDB amazonDynamoDB) {
return new DynamoDBMapper(amazonDynamoDB);
}
private AWSStaticCredentialsProvider constructCredentials() {
var accessKey = awsConfigurationProperties.getAccessKey();
var secretAccessKey = awsConfigurationProperties.getSecretAccessKey();
var basicAwsCredentials = new BasicAWSCredentials(accessKey, secretAccessKey);
return new AWSStaticCredentialsProvider(basicAwsCredentials);
}
}
test
or local
. With this, we've successfully created the correct AWS DynamoDB configuration for our application.MedicalRecord
to represent the data stored in DynamoDB with the below defined attributes. We're leveraging lombok for generating getter and setter methods for the attribute to reduce boilerplate code.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class MedicalRecord {
private String id;
private String patientName;
private String diagnosis;
private String treatmentPlan;
private String attendingPhysicianName;
}
DynamoDBMapper
will be autowired into this class via Constructor Injection with Lombok. 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class MedicalRecordRepository {
private final DynamoDBMapper dynamoDBMapper;
public void save( { MedicalRecord medicalRecord)
dynamoDBMapper.save(medicalRecord);
}
public Optional<MedicalRecord> findById( { String medicalRecordId)
var medicalRecord = dynamoDBMapper.load(MedicalRecord.class, medicalRecordId);
return Optional.ofNullable(medicalRecord);
}
}
pom.xml
:1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>localstack</artifactId>
<scope>test</scope>
</dependency>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up JDK
uses: actions/setup-java@v3
with:
java-version: 21
distribution: adopt
cache: maven
- name: Run Integration Tests
run: mvn integration-test verify
init-dynamodb.sh
script in the src/test/resources
folder which we want to be executed when the LocalStack container is started. MedicalRecords
for which we've already created a model class in our application. 1
2
3
4
5
6
7
8
9
10
11
12
table_name="MedicalRecords"
hash_key="Id"
awslocal dynamodb create-table \
--table-name "$table_name" \
--key-schema AttributeName="$hash_key",KeyType=HASH \
--attribute-definitions AttributeName="$hash_key",AttributeType=S \
--billing-mode PAY_PER_REQUEST
echo "DynamoDB table '$table_name' created successfully with hash key '$hash_key'"
echo "Executed init-dynamodb.sh"
- Activate the
test
profile using@ActiveProfiles
- Copy our
init-dynamodb.sh
script into the container to ensure DynamoDB table creation. - Configure a Wait strategy to wait for the log
Executed init-dynamodb.sh
to be printed, as defined in our init script. - Dynamically configure the AWS configuration properties needed by the application in order to create the required connection beans using
@DynamicPropertySource
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class MedicalRecordRepositoryIT {
private MedicalRecordRepository medicalRecordRepository;
private static LocalStackContainer localStackContainer;
static {
localStackContainer = new LocalStackContainer(DockerImageName.parse("localstack/localstack:3.2"))
.withCopyFileToContainer(MountableFile.forClasspathResource("init-dynamodb.sh", 0744), "/etc/localstack/init/ready.d/init-dynamodb.sh")
.withServices(Service.DYNAMODB)
.waitingFor(Wait.forLogMessage(".*Executed init-dynamodb.sh.*", 1));
localStackContainer.start();
}
static void properties(DynamicPropertyRegistry registry) {
registry.add("com.behl.aws.access-key", localStackContainer::getAccessKey);
registry.add("com.behl.aws.secret-access-key", localStackContainer::getSecretKey);
registry.add("com.behl.aws.region", localStackContainer::getRegion);
registry.add("com.behl.aws.endpoint", localStackContainer::getEndpoint);
}
}
MedicalRecordRepository
class with appropriate test cases:1
2
3
4
5
6
7
8
9
10
11
void shouldReturnEmptyOptionalForNonExistingMedicalRecord() {
// generate random medical record Id
var medicalRecordId = RandomString.make();
// call method under test
Optional<MedicalRecord> medicalRecord = medicalRecordRepository.findById(medicalRecordId);
// assert response
assertThat(medicalRecord).isEmpty();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void shouldSaveMedicalRecordSuccessfully() {
// create medical record
var medicalRecord = new MedicalRecord();
var medicalRecordId = RandomString.make();
var patientName = RandomString.make();
var diagnosis = RandomString.make();
medicalRecord.setId(medicalRecordId);
medicalRecord.setPatientName(patientName);
medicalRecord.setDiagnosis(diagnosis);
// call method under test
medicalRecordRepository.save(medicalRecord);
// assert record existence in datasource
Optional<MedicalRecord> retrievedMedicalRecord = medicalRecordRepository.findById(medicalRecordId);
assertThat(retrievedMedicalRecord).isPresent().get().satisfies(record -> {
assertThat(record.getId()).isEqualTo(medicalRecordId);
assertThat(record.getPatientName()).isEqualTo(patientName);
assertThat(record.getDiagnosis()).isEqualTo(diagnosis);
});
}
DynamoDBInitializer
which implements the BeforeEachCallback
interface and performs the boilerplate functionality.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class DynamoDBInitializer implements BeforeAllCallback {
private static final DockerImageName LOCALSTACK_IMAGE = DockerImageName.parse("localstack/localstack:3");
private static final LocalStackContainer localStackContainer = new LocalStackContainer(LOCALSTACK_IMAGE)
.withCopyFileToContainer(MountableFile.forClasspathResource("init-dynamodb.sh", 0744), "/etc/localstack/init/ready.d/init-dynamodb.sh")
.withServices(Service.DYNAMODB)
.waitingFor(Wait.forLogMessage(".*Executed init-dynamodb.sh.*", 1));
public void beforeAll(final ExtensionContext context) {
log.info("Creating localstack container : {}", LOCALSTACK_IMAGE);
localStackContainer.start();
addConfigurationProperties();
log.info("Successfully started localstack container : {}", LOCALSTACK_IMAGE);
}
private void addConfigurationProperties() {
System.setProperty("com.behl.aws.access-key", localStackContainer.getAccessKey());
System.setProperty("com.behl.aws.secret-access-key", localStackContainer.getSecretKey());
System.setProperty("com.behl.aws.region", localStackContainer.getRegion());
System.setProperty("com.behl.aws.endpoint", localStackContainer.getEndpoint().toString());
}
}
@InitializeDynamoDB
that registers the above created Extension class using @ExtendWith
. 1
2
3
4
5
public InitializeDynamoDB {
}
@InitializeDynamoDB
.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class MedicalRecordRepositoryIT {
private MedicalRecordRepository medicalRecordRepository;
void shouldReturnEmptyOptionalForNonExistingMedicalRecord() {
// test case
}
void shouldSaveMedicalRecordSuccessfully(){
// test case
}
}
NOTE: To have a single shared container that is used by all Integration Test classes, we've implemented the BeforeAllCallback interface. This approach will reduce test execution time significantly and eliminate the need to start and stop containers for each test class.
For cases where this behaviour is not needed, BeforeEachCallback interface can be implemented instead.