There are a few ways to spin up a local version of DynamoDB -
1. AWS provides a DynamoDB local
2. Localstack provides a way to spin up a good number of AWS services locally
3. A docker version of DynamoDB Local
4. Dynalite, a node based implementation of DynamoDB
Now to be able to unit test an application, I need to be able to start up an embedded version of DynamoDB using one of these options right before a test runs and then shut it down after a test completes. There are three approaches that I have taken:
1. Using a JUnit 5 extension that internally brings up a AWS DynamoDB Local and spins it down after a test.
2. Using testcontainers to start up a docker version DynamoDB Local
3. Using testcontainers to start up DynaLite
JUnit5 extension
JUnit5 extension provides a convenient hook point to start up an embedded version of DynamoDB for tests. It works by pulling in a version of DynamoDB Local as a maven dependency:dependencies { ... testImplementation("com.amazonaws:DynamoDBLocal:1.11.119") ... }
A complication with this dependency is that there are native components (dll, .so etc) that the DynamoDB Local interacts with and to get these in the right place, I depend on a Gradle task:
task copyNativeDeps(type: Copy) { mkdir "build/native-libs" from(configurations.testCompileClasspath) { include '*.dll' include '*.dylib' include '*.so' } into 'build/native-libs' } test { dependsOn copyNativeDeps }
which puts the native libs in build/native-libs folder, and the extension internally sets this path as a system property:
System.setProperty("sqlite4java.library.path", libPath.toAbsolutePath().toString())
Here is the codebase to the JUnit5 extension with all these already hooked up - https://github.com/bijukunjummen/boot-with-dynamodb/blob/master/src/test/kotlin/sample/dyn/rules/LocalDynamoExtension.kt
A test using this extension looks like this:
class HotelRepoTest { companion object { @RegisterExtension @JvmField val localDynamoExtension = LocalDynamoExtension() @BeforeAll @JvmStatic fun beforeAll() { val dbMigrator = DbMigrator(localDynamoExtension.syncClient!!) dbMigrator.migrate() } } @Test fun saveHotel() { val hotelRepo = DynamoHotelRepo(localDynamoExtension.asyncClient!!) val hotel = Hotel(id = "1", name = "test hotel", address = "test address", state = "OR", zip = "zip") val resp = hotelRepo.saveHotel(hotel) StepVerifier.create(resp) .expectNext(hotel) .expectComplete() .verify() } }
The code can interact with a fully featured DynamoDB.
TestContainers with DynamoDB Local Docker
The JUnit5 extensions approach works well but it requires an additional dependency with native binaries to be pulled in. A cleaner approach may be to use the excellent Testcontainers to spin up a docker version of DynamoDB Local the following way:
class HotelRepoLocalDynamoTestContainerTest { @Test fun saveHotel() { val hotelRepo = DynamoHotelRepo(getAsyncClient(dynamoDB)) val hotel = Hotel(id = "1", name = "test hotel", address = "test address", state = "OR", zip = "zip") val resp = hotelRepo.saveHotel(hotel) StepVerifier.create(resp) .expectNext(hotel) .expectComplete() .verify() } companion object { val dynamoDB: KGenericContainer = KGenericContainer("amazon/dynamodb-local:1.11.119") .withExposedPorts(8000) @BeforeAll @JvmStatic fun beforeAll() { dynamoDB.start() } @AfterAll @JvmStatic fun afterAll() { dynamoDB.stop() } fun getAsyncClient(dynamoDB: KGenericContainer): DynamoDbAsyncClient { val endpointUri = "http://" + dynamoDB.getContainerIpAddress() + ":" + dynamoDB.getMappedPort(8000) val builder: DynamoDbAsyncClientBuilder = DynamoDbAsyncClient.builder() .endpointOverride(URI.create(endpointUri)) .region(Region.US_EAST_1) .credentialsProvider(StaticCredentialsProvider .create(AwsBasicCredentials .create("acc", "sec"))) return builder.build() } ... } }
This code starts up DynamoDB at a random unoccupied port and provides this information so that the client can be created using this information. There is a little Kotlin workaround that I had to do based on an issue reported here - https://github.com/testcontainers/testcontainers-java/issues/318
TestContainers with Dynalite
Dynalite is a javascript based implementation of DynamoDB and can be run for tests again using the TestContainer approach. This time however there is already a TestContainer module for Dynalite. I found that it does not support JUnit5 and sent a Pull request to provide this support, in the iterim the raw docker image can be used and this is how a test looks like:
class HotelRepoDynaliteTestContainerTest { @Test fun saveHotel() { val hotelRepo = DynamoHotelRepo(getAsyncClient(dynamoDB)) val hotel = Hotel(id = "1", name = "test hotel", address = "test address", state = "OR", zip = "zip") val resp = hotelRepo.saveHotel(hotel) StepVerifier.create(resp) .expectNext(hotel) .expectComplete() .verify() } companion object { val dynamoDB: KGenericContainer = KGenericContainer("quay.io/testcontainers/dynalite:v1.2.1-1") .withExposedPorts(4567) @BeforeAll @JvmStatic fun beforeAll() { dynamoDB.start() val dbMigrator = DbMigrator(getSyncClient(dynamoDB)) dbMigrator.migrate() } @AfterAll @JvmStatic fun afterAll() { dynamoDB.stop() } fun getAsyncClient(dynamoDB: KGenericContainer): DynamoDbAsyncClient { val endpointUri = "http://" + dynamoDB.getContainerIpAddress() + ":" + dynamoDB.getMappedPort(4567) val builder: DynamoDbAsyncClientBuilder = DynamoDbAsyncClient.builder() .endpointOverride(URI.create(endpointUri)) .region(Region.US_EAST_1) .credentialsProvider(StaticCredentialsProvider .create(AwsBasicCredentials .create("acc", "sec"))) return builder.build() } ... } }
hi, do you have an git project example of TestContainers & localstack?
ReplyDeleteHi Biju, I am getting this timeout message regardless of which image I use for Dynalite container. Would you happen to have run into this problem?
ReplyDeleteCaused by: org.testcontainers.containers.ContainerLaunchException: Timed out waiting for URL to be accessible (http://localhost:55911/ should return HTTP 200)
...