From 5251188d41be76d599d5625fe5582914c1160ace Mon Sep 17 00:00:00 2001 From: Zhixuan Lai Date: Mon, 15 Feb 2021 20:52:45 -0800 Subject: [PATCH 1/2] Add tempest testing --- dependencies.gradle | 8 +- samples/guides/build.gradle | 2 - .../musiclibrary-testing}/build.gradle | 1 - .../musiclibrary-testing/gradle.properties | 4 + .../cash/tempest/musiclibrary/TestEntities.kt | 0 samples/musiclibrary/build.gradle | 2 - samples/musiclibrary2/build.gradle | 1 - samples/urlshortener/build.gradle | 2 - samples/urlshortener2/build.gradle | 1 - settings.gradle | 14 +- tempest-internal/build.gradle | 1 - tempest-testing-docker/build.gradle | 22 ++ tempest-testing-docker/gradle.properties | 4 + .../app/cash/tempest/testing/Containers.kt | 217 ++++++++++++++++++ .../tempest/testing/DockerDynamoDbServer.kt | 67 ++++++ .../app/cash/tempest/testing/ExampleTest.kt | 50 ++++ tempest-testing-internal/build.gradle | 13 ++ tempest-testing-internal/gradle.properties | 4 + .../testing/DefaultTestDynamoDbClient.kt | 54 +++++ .../kotlin/app/cash/tempest/testing/Logger.kt | 92 ++++++++ .../app/cash/tempest/testing/TestUtils.kt | 86 +++++++ tempest-testing-junit4/build.gradle | 19 ++ tempest-testing-junit4/gradle.properties | 4 + .../app/cash/tempest/testing/TestDynamoDb.kt | 76 ++++++ .../app/cash/tempest/testing/ExampleTest.kt | 49 ++++ tempest-testing-junit5/build.gradle | 16 ++ tempest-testing-junit5/gradle.properties | 4 + .../app/cash/tempest/testing/TestDynamoDb.kt | 78 +++++++ .../app/cash/tempest/testing/ExampleTest.kt | 50 ++++ tempest-testing-jvm/build.gradle | 13 ++ tempest-testing-jvm/gradle.properties | 4 + .../cash/tempest/testing/JvmDynamoDbServer.kt | 88 +++++++ tempest-testing/build.gradle | 13 ++ tempest-testing/gradle.properties | 4 + .../tempest/testing/TestDynamoDbClient.kt | 51 ++++ .../tempest/testing/TestDynamoDbServer.kt | 27 +++ .../app/cash/tempest/testing/TestTable.kt | 50 ++++ tempest/build.gradle | 8 +- .../tempest/interop/InteropTestModule.java | 52 ----- .../tempest/interop/InteropTestUtils.java | 31 +++ .../test/kotlin/app/cash/tempest/CodecTest.kt | 28 ++- .../app/cash/tempest/DynamoDBScannableTest.kt | 17 +- .../app/cash/tempest/DynamoDbQueryableTest.kt | 20 +- .../app/cash/tempest/DynamoDbViewTest.kt | 41 ++-- .../app/cash/tempest/LogicalDbBatchTest.kt | 17 +- .../cash/tempest/LogicalDbTransactionTest.kt | 37 ++- .../app/cash/tempest/WritingPagerTest.kt | 18 +- .../app/cash/tempest/internal/SchemaTest.kt | 15 +- .../cash/tempest/interop/JavaInteropTest.kt | 15 +- .../tempest/musiclibrary/MusicDbTestModule.kt | 63 ----- .../cash/tempest/musiclibrary/TestUtils.kt | 18 ++ tempest2-testing-docker/build.gradle | 22 ++ tempest2-testing-docker/gradle.properties | 4 + .../app/cash/tempest2/testing/Containers.kt | 217 ++++++++++++++++++ .../tempest2/testing/DockerDynamoDbServer.kt | 68 ++++++ .../app/cash/tempest2/testing/ExampleTest.kt | 50 ++++ tempest2-testing-internal/build.gradle | 13 ++ tempest2-testing-internal/gradle.properties | 4 + .../testing/DefaultTestDynamoDbClient.kt | 55 +++++ .../app/cash/tempest2/testing/Logger.kt | 92 ++++++++ .../app/cash/tempest2/testing/TestUtils.kt | 83 +++++++ tempest2-testing-junit4/build.gradle | 19 ++ tempest2-testing-junit4/gradle.properties | 4 + .../app/cash/tempest2/testing/TestDynamoDb.kt | 76 ++++++ .../app/cash/tempest2/testing/ExampleTest.kt | 49 ++++ tempest2-testing-junit5/build.gradle | 16 ++ tempest2-testing-junit5/gradle.properties | 4 + .../app/cash/tempest2/testing/TestDynamoDb.kt | 78 +++++++ .../app/cash/tempest2/testing/ExampleTest.kt | 50 ++++ tempest2-testing-jvm/build.gradle | 13 ++ tempest2-testing-jvm/gradle.properties | 4 + .../tempest2/testing/JvmDynamoDbServer.kt | 88 +++++++ tempest2-testing/build.gradle | 13 ++ tempest2-testing/gradle.properties | 4 + .../tempest2/testing/TestDynamoDbClient.kt | 58 +++++ .../tempest2/testing/TestDynamoDbServer.kt | 27 +++ .../app/cash/tempest2/testing/TestTable.kt | 54 +++++ tempest2/build.gradle | 8 +- .../tempest2/interop/InteropTestModule.java | 54 ----- .../tempest2/interop/InteropTestUtils.java | 31 +++ .../kotlin/app/cash/tempest2/CodecTest.kt | 16 +- .../cash/tempest2/DynamoDbQueryableTest.kt | 16 +- .../cash/tempest2/DynamoDbScannableTest.kt | 17 +- .../app/cash/tempest2/DynamoDbViewTest.kt | 16 +- .../app/cash/tempest2/LogicalDbBatchTest.kt | 17 +- .../cash/tempest2/LogicalDbTransactionTest.kt | 34 +-- .../app/cash/tempest2/WritingPagerTest.kt | 18 +- .../app/cash/tempest2/internal/SchemaTest.kt | 15 +- .../cash/tempest2/interop/JavaInteropTest.kt | 15 +- .../musiclibrary/MusicDbTestModule.kt | 90 -------- .../cash/tempest2/musiclibrary/TestUtils.kt | 54 +++++ test-utils/gradle.properties | 4 - 92 files changed, 2649 insertions(+), 492 deletions(-) rename {test-utils => samples/musiclibrary-testing}/build.gradle (88%) create mode 100644 samples/musiclibrary-testing/gradle.properties rename {test-utils => samples/musiclibrary-testing}/src/main/kotlin/app/cash/tempest/musiclibrary/TestEntities.kt (100%) create mode 100644 tempest-testing-docker/build.gradle create mode 100644 tempest-testing-docker/gradle.properties create mode 100644 tempest-testing-docker/src/main/kotlin/app/cash/tempest/testing/Containers.kt create mode 100644 tempest-testing-docker/src/main/kotlin/app/cash/tempest/testing/DockerDynamoDbServer.kt create mode 100644 tempest-testing-docker/src/test/kotlin/app/cash/tempest/testing/ExampleTest.kt create mode 100644 tempest-testing-internal/build.gradle create mode 100644 tempest-testing-internal/gradle.properties create mode 100644 tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/DefaultTestDynamoDbClient.kt create mode 100644 tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/Logger.kt create mode 100644 tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/TestUtils.kt create mode 100644 tempest-testing-junit4/build.gradle create mode 100644 tempest-testing-junit4/gradle.properties create mode 100644 tempest-testing-junit4/src/main/kotlin/app/cash/tempest/testing/TestDynamoDb.kt create mode 100644 tempest-testing-junit4/src/test/kotlin/app/cash/tempest/testing/ExampleTest.kt create mode 100644 tempest-testing-junit5/build.gradle create mode 100644 tempest-testing-junit5/gradle.properties create mode 100644 tempest-testing-junit5/src/main/kotlin/app/cash/tempest/testing/TestDynamoDb.kt create mode 100644 tempest-testing-junit5/src/test/kotlin/app/cash/tempest/testing/ExampleTest.kt create mode 100644 tempest-testing-jvm/build.gradle create mode 100644 tempest-testing-jvm/gradle.properties create mode 100644 tempest-testing-jvm/src/main/kotlin/app/cash/tempest/testing/JvmDynamoDbServer.kt create mode 100644 tempest-testing/build.gradle create mode 100644 tempest-testing/gradle.properties create mode 100644 tempest-testing/src/main/kotlin/app/cash/tempest/testing/TestDynamoDbClient.kt create mode 100644 tempest-testing/src/main/kotlin/app/cash/tempest/testing/TestDynamoDbServer.kt create mode 100644 tempest-testing/src/main/kotlin/app/cash/tempest/testing/TestTable.kt delete mode 100644 tempest/src/test/java/app/cash/tempest/interop/InteropTestModule.java create mode 100644 tempest/src/test/java/app/cash/tempest/interop/InteropTestUtils.java delete mode 100644 tempest/src/test/kotlin/app/cash/tempest/musiclibrary/MusicDbTestModule.kt create mode 100644 tempest2-testing-docker/build.gradle create mode 100644 tempest2-testing-docker/gradle.properties create mode 100644 tempest2-testing-docker/src/main/kotlin/app/cash/tempest2/testing/Containers.kt create mode 100644 tempest2-testing-docker/src/main/kotlin/app/cash/tempest2/testing/DockerDynamoDbServer.kt create mode 100644 tempest2-testing-docker/src/test/kotlin/app/cash/tempest2/testing/ExampleTest.kt create mode 100644 tempest2-testing-internal/build.gradle create mode 100644 tempest2-testing-internal/gradle.properties create mode 100644 tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/DefaultTestDynamoDbClient.kt create mode 100644 tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/Logger.kt create mode 100644 tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/TestUtils.kt create mode 100644 tempest2-testing-junit4/build.gradle create mode 100644 tempest2-testing-junit4/gradle.properties create mode 100644 tempest2-testing-junit4/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDb.kt create mode 100644 tempest2-testing-junit4/src/test/kotlin/app/cash/tempest2/testing/ExampleTest.kt create mode 100644 tempest2-testing-junit5/build.gradle create mode 100644 tempest2-testing-junit5/gradle.properties create mode 100644 tempest2-testing-junit5/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDb.kt create mode 100644 tempest2-testing-junit5/src/test/kotlin/app/cash/tempest2/testing/ExampleTest.kt create mode 100644 tempest2-testing-jvm/build.gradle create mode 100644 tempest2-testing-jvm/gradle.properties create mode 100644 tempest2-testing-jvm/src/main/kotlin/app/cash/tempest2/testing/JvmDynamoDbServer.kt create mode 100644 tempest2-testing/build.gradle create mode 100644 tempest2-testing/gradle.properties create mode 100644 tempest2-testing/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDbClient.kt create mode 100644 tempest2-testing/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDbServer.kt create mode 100644 tempest2-testing/src/main/kotlin/app/cash/tempest2/testing/TestTable.kt delete mode 100644 tempest2/src/test/java/app/cash/tempest2/interop/InteropTestModule.java create mode 100644 tempest2/src/test/java/app/cash/tempest2/interop/InteropTestUtils.java delete mode 100644 tempest2/src/test/kotlin/app/cash/tempest2/musiclibrary/MusicDbTestModule.kt delete mode 100644 test-utils/gradle.properties diff --git a/dependencies.gradle b/dependencies.gradle index 012903532..ad90fead7 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -1,14 +1,18 @@ // Auto-generated from polyrepo's master-dependencies.json. Update via polyrepo dep-add and polyrepo dep-upgrade. ext.dep = [ "assertj": "org.assertj:assertj-core:3.18.1", + "awsDynamodbLocal": "com.amazonaws:DynamoDBLocal:1.13.5", "aws2Dynamodb": "software.amazon.awssdk:dynamodb:2.15.66", "aws2DynamodbEnhanced": "software.amazon.awssdk:dynamodb-enhanced:2.15.66", "awsDynamodb": "com.amazonaws:aws-java-sdk-dynamodb:1.11.774", "clikt": "com.github.ajalt:clikt:2.8.0", + "docker": "com.github.docker-java:docker-java:3.2.1", "findbugsJsr305": "com.google.code.findbugs:jsr305:3.0.2", "grpcBom": "io.grpc:grpc-bom:1.21.0", + "guava": "com.google.guava:guava:30.0-jre", "guice": "com.google.inject:guice:4.2.3", "jCommander": "com.beust:jcommander:1.72", + "junit4Api": "junit:junit:4.13", "junitApi": "org.junit.jupiter:junit-jupiter-api:5.7.0", "junitEngine": "org.junit.jupiter:junit-jupiter-engine:5.7.0", "junitGradlePlugin": "org.junit.platform:junit-platform-gradle-plugin:1.2.0", @@ -20,9 +24,6 @@ ext.dep = [ "ktlintVersion": "0.40.0", "loggingApi": "io.github.microutils:kotlin-logging:1.7.9", "mavenPublishGradlePlugin": "com.vanniktech:gradle-maven-publish-plugin:0.12.0", - "miskAws2DynamodbTesting": "com.squareup.misk:misk-aws2-dynamodb-testing:0.17.0-20210210.0438-e43f047", - "miskAwsDynamodbTesting": "com.squareup.misk:misk-aws-dynamodb-testing:0.17.0-20210210.0438-e43f047", - "miskTesting": "com.squareup.misk:misk-testing:0.17.0-20210210.0438-e43f047", "moshiCore": "com.squareup.moshi:moshi:1.11.0", "nettyBom": "io.netty:netty-bom:4.1.34.Final", "okHttp": "com.squareup.okhttp3:okhttp:4.10.0-RC1", @@ -31,6 +32,7 @@ ext.dep = [ "retrofit": "com.squareup.retrofit2:retrofit:2.9.0", "retrofitWire": "com.squareup.retrofit2:converter-wire:2.9.0", "shadowJarPlugin": "com.github.jengelman.gradle.plugins:shadow:6.1.0", + "log4jCore": "org.apache.logging.log4j:log4j-core:2.14.0", "spotlessPlugin": "com.diffplug.spotless:spotless-plugin-gradle:4.5.1", "wireGradlePlugin": "com.squareup.wire:wire-gradle-plugin:3.6.0", "wireGrpcClient": "com.squareup.wire:wire-grpc-client:3.6.0", diff --git a/samples/guides/build.gradle b/samples/guides/build.gradle index 8782c0e94..0d02d8b56 100644 --- a/samples/guides/build.gradle +++ b/samples/guides/build.gradle @@ -7,9 +7,7 @@ dependencies { implementation dep.kotlinStdLib implementation dep.kotlinxCoroutines - testImplementation dep.miskAwsDynamodbTesting testImplementation dep.assertj - testImplementation dep.miskTesting testImplementation dep.junitApi testImplementation dep.junitEngine } diff --git a/test-utils/build.gradle b/samples/musiclibrary-testing/build.gradle similarity index 88% rename from test-utils/build.gradle rename to samples/musiclibrary-testing/build.gradle index cd25e15b3..4c6cf93be 100644 --- a/test-utils/build.gradle +++ b/samples/musiclibrary-testing/build.gradle @@ -7,7 +7,6 @@ dependencies { implementation dep.kotlinStdLib testImplementation dep.assertj - testImplementation dep.miskTesting testImplementation dep.junitApi testImplementation dep.junitEngine } diff --git a/samples/musiclibrary-testing/gradle.properties b/samples/musiclibrary-testing/gradle.properties new file mode 100644 index 000000000..a3b771e52 --- /dev/null +++ b/samples/musiclibrary-testing/gradle.properties @@ -0,0 +1,4 @@ +POM_ARTIFACT_ID=musiclibrary-testing +POM_NAME=musiclibrary-testing +POM_DESCRIPTION=musiclibrary-testing +POM_PACKAGING=jar diff --git a/test-utils/src/main/kotlin/app/cash/tempest/musiclibrary/TestEntities.kt b/samples/musiclibrary-testing/src/main/kotlin/app/cash/tempest/musiclibrary/TestEntities.kt similarity index 100% rename from test-utils/src/main/kotlin/app/cash/tempest/musiclibrary/TestEntities.kt rename to samples/musiclibrary-testing/src/main/kotlin/app/cash/tempest/musiclibrary/TestEntities.kt diff --git a/samples/musiclibrary/build.gradle b/samples/musiclibrary/build.gradle index 963c98421..c092540bf 100644 --- a/samples/musiclibrary/build.gradle +++ b/samples/musiclibrary/build.gradle @@ -4,9 +4,7 @@ dependencies { implementation project(":tempest") implementation dep.kotlinStdLib - testImplementation dep.miskAwsDynamodbTesting testImplementation dep.assertj - testImplementation dep.miskTesting testImplementation dep.junitApi testImplementation dep.junitEngine } diff --git a/samples/musiclibrary2/build.gradle b/samples/musiclibrary2/build.gradle index 2a1d965e0..f26ef4559 100644 --- a/samples/musiclibrary2/build.gradle +++ b/samples/musiclibrary2/build.gradle @@ -5,7 +5,6 @@ dependencies { implementation dep.kotlinStdLib testImplementation dep.assertj - testImplementation dep.miskTesting testImplementation dep.junitApi testImplementation dep.junitEngine } diff --git a/samples/urlshortener/build.gradle b/samples/urlshortener/build.gradle index a7f83fd99..8d572ad28 100644 --- a/samples/urlshortener/build.gradle +++ b/samples/urlshortener/build.gradle @@ -5,9 +5,7 @@ dependencies { implementation dep.kotlinStdLib implementation dep.clikt - testImplementation dep.miskAwsDynamodbTesting testImplementation dep.assertj - testImplementation dep.miskTesting testImplementation dep.junitApi testImplementation dep.junitEngine } diff --git a/samples/urlshortener2/build.gradle b/samples/urlshortener2/build.gradle index c224d64e9..403ce3b5a 100644 --- a/samples/urlshortener2/build.gradle +++ b/samples/urlshortener2/build.gradle @@ -6,7 +6,6 @@ dependencies { implementation dep.clikt testImplementation dep.assertj - testImplementation dep.miskTesting testImplementation dep.junitApi testImplementation dep.junitEngine } diff --git a/settings.gradle b/settings.gradle index 56466697c..601de2a04 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,9 +1,21 @@ include 'tempest-internal' include 'tempest' +include 'tempest-testing' +include 'tempest-testing-internal' +include 'tempest-testing-docker' +include 'tempest-testing-jvm' +include 'tempest-testing-junit4' +include 'tempest-testing-junit5' include 'tempest2' +include 'tempest2-testing' +include 'tempest2-testing-internal' +include 'tempest2-testing-docker' +include 'tempest2-testing-jvm' +include 'tempest2-testing-junit4' +include 'tempest2-testing-junit5' include ':samples:guides' include ':samples:musiclibrary' include ':samples:musiclibrary2' +include ':samples:musiclibrary-testing' include ':samples:urlshortener' include ':samples:urlshortener2' -include 'test-utils' diff --git a/tempest-internal/build.gradle b/tempest-internal/build.gradle index 271a3d83a..9c1452f52 100644 --- a/tempest-internal/build.gradle +++ b/tempest-internal/build.gradle @@ -8,7 +8,6 @@ dependencies { implementation dep.kotlinStdLib testImplementation dep.assertj - testImplementation dep.miskTesting testImplementation dep.junitApi testImplementation dep.junitEngine } diff --git a/tempest-testing-docker/build.gradle b/tempest-testing-docker/build.gradle new file mode 100644 index 000000000..945d27574 --- /dev/null +++ b/tempest-testing-docker/build.gradle @@ -0,0 +1,22 @@ +apply plugin: 'java-library' +apply plugin: 'kotlin' + +dependencies { + api project(":tempest-testing") + implementation project(":tempest-testing-internal") + implementation dep.kotlinStdLib + // The docker-java we use in tests depends on an old version of junixsocket that depends on + // log4j. We force it up a minor version in packages that use it. + implementation('com.kohlschutter.junixsocket:junixsocket-native-common:2.3.2') { + force = true + } + implementation('com.kohlschutter.junixsocket:junixsocket-common:2.3.2') { + force = true + } + implementation dep.docker + + testImplementation dep.assertj + testImplementation dep.junitEngine + testImplementation project(":samples:urlshortener") + testImplementation project(":tempest-testing-junit5") +} diff --git a/tempest-testing-docker/gradle.properties b/tempest-testing-docker/gradle.properties new file mode 100644 index 000000000..2ad7829b7 --- /dev/null +++ b/tempest-testing-docker/gradle.properties @@ -0,0 +1,4 @@ +POM_ARTIFACT_ID=tempest-testing-docker +POM_NAME=tempest-testing-docker +POM_DESCRIPTION=tempest-testing-docker +POM_PACKAGING=jar diff --git a/tempest-testing-docker/src/main/kotlin/app/cash/tempest/testing/Containers.kt b/tempest-testing-docker/src/main/kotlin/app/cash/tempest/testing/Containers.kt new file mode 100644 index 000000000..27c526df7 --- /dev/null +++ b/tempest-testing-docker/src/main/kotlin/app/cash/tempest/testing/Containers.kt @@ -0,0 +1,217 @@ +package app.cash.tempest.testing + +import com.github.dockerjava.api.DockerClient +import com.github.dockerjava.api.command.CreateContainerCmd +import com.github.dockerjava.api.exception.NotFoundException +import com.github.dockerjava.api.model.Frame +import com.github.dockerjava.core.DockerClientBuilder +import com.github.dockerjava.core.async.ResultCallbackTemplate +import com.github.dockerjava.core.command.PullImageResultCallback +import com.github.dockerjava.core.command.WaitContainerResultCallback +import com.github.dockerjava.netty.NettyDockerCmdExecFactory +import java.util.concurrent.atomic.AtomicBoolean + +/** + * A [Container] creates a Docker container for testing. + * + * Tests provide a lambda to build a [CreateContainerCmd]. The [createCmd] lambda must set + * [CreateContainerCmd.withName] and [CreateContainerCmd.withImage]. All other fields are + * optional. The [Composer] takes care of setting up the network. + * + * There may be a need to configure your container between the creation and start steps. + * [beforeStartHook] provides you with an id to your container allowing you to + * manipulate as necessary before the command/entrypoint is invoked. + * + * See [Composer] for an example. + */ +internal data class Container( + val createCmd: CreateContainerCmd.() -> Unit, + val beforeStartHook: (docker: DockerClient, id: String) -> Unit +) { + constructor(createCmd: CreateContainerCmd.() -> Unit) : this(createCmd, { _, _ -> }) +} + +/** + * [Composer] composes many [Container]s together to use in a unit test. + * + * The [Container]s are networked using a dedicated Docker network. Tests need to expose ports + * in order for the test to communicate with the containers over 127.0.0.1. + * + * The following example composes Kafka and Zookeeper containers for testing. Kafka is exposed + * to the jUnit test via 127.0.0.1:9102. In this example, Zookeeper is not exposed to the test. + * + * ``` + * val zkContainer = Container { + * this + * .withImage("confluentinc/cp-zookeeper") + * .withName("zookeeper") + * .withEnv("ZOOKEEPER_CLIENT_PORT=2181") + * } + * val kafka = Container { + * this + * .withImage("confluentinc/cp-kafka" + * .withName("kafka") + * .withExposedPorts(ExposedPort.tcp(port)) + * .withPortBindings(Ports().apply { + * bind(ExposedPort.tcp(9102), Ports.Binding.bindPort(9102)) + * }) + * .withEnv( + * "KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181", + * "KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://localhost:9102") + * } + * val composer = Composer("e-kafka", zkContainer, kafka) + * composer.start() + * ``` + */ +internal class Composer(private val name: String, private vararg val containers: Container) { + + private val network = DockerNetwork( + "$name-net", + docker + ) + private val containerIds = mutableMapOf() + private val running = AtomicBoolean(false) + + fun start() { + if (!running.compareAndSet(false, true)) return + Runtime.getRuntime().addShutdownHook(Thread { stop() }) + + network.start() + + for (container in containers) { + val name = container.name() + val create = docker.createContainerCmd("todo").apply(container.createCmd) + require(create.image != "todo") { + "must provide an image for container ${create.name}" + } + + docker.listContainersCmd() + .withShowAll(true) + .withLabelFilter(mapOf("name" to name)) + .exec() + .forEach { + log.info { "removing previous $name container with id ${it.id}" } + docker.removeContainerCmd(it.id).exec() + } + + log.info { "pulling ${create.image} for $name container" } + + val imageParts = create.image!!.split(":") + docker.pullImageCmd(imageParts[0]) + .withTag(imageParts.getOrElse(1) { "latest" }) + .exec(PullImageResultCallback()).awaitCompletion() + + log.info { "starting $name container" } + + val id = create + .withNetworkMode(network.id()) + .withLabels(mapOf("name" to name)) + .withTty(true) + .exec() + .id + containerIds[name] = id + + container.beforeStartHook(docker, id) + + docker.startContainerCmd(id).exec() + docker.logContainerCmd(id) + .withStdErr(true) + .withStdOut(true) + .withFollowStream(true) + .withSince(0) + .exec(LogContainerResultCallback()) + .awaitStarted() + + log.info { "started $name; container id=$id" } + } + } + + private fun Container.name(): String { + val create = docker.createContainerCmd("todo").apply(createCmd) + require(!create.name.isNullOrBlank()) { + "must provide a name for the container" + } + return "$name/${create.name}" + } + + fun stop() { + if (!running.compareAndSet(true, false)) return + + for (container in containers) { + val name = container.name() + val id = containerIds[name]!! + log.info { "killing $name with container id $id" } + docker.removeContainerCmd(id).withForce(true).exec() + + try { + log.info { "waiting for $name to terminate" } + docker.waitContainerCmd(id).exec( + GracefulWaitContainerResultCallback() + ).awaitCompletion() + } catch (th: Throwable) { + log.error(th) { "could not kill $name with container id $id" } + } + + log.info { "killed $name with container id $id" } + } + + network.stop() + } + + private class LogContainerResultCallback : ResultCallbackTemplate() { + override fun onNext(item: Frame) { + String(item.payload).trim().split('\r', '\n').filter { it.isNotBlank() }.forEach { + log.info(it) + } + } + } + + private class GracefulWaitContainerResultCallback : WaitContainerResultCallback() { + override fun onError(throwable: Throwable?) { + // this is ok, just meant that the container already terminated before we tried to wait + if (throwable is NotFoundException) { + return + } + super.onError(throwable) + } + } + + private companion object { + private val log = getLogger() + private val docker: DockerClient = DockerClientBuilder.getInstance() + .withDockerCmdExecFactory(NettyDockerCmdExecFactory()) + .build() + } +} + +private class DockerNetwork(private val name: String, private val docker: DockerClient) { + + private lateinit var networkId: String + + fun id(): String { + return networkId + } + + fun start() { + log.info { "creating $name network" } + + docker.listNetworksCmd().withNameFilter(name).exec().forEach { + log.info { "removing previous $name network with id ${it.id}" } + docker.removeNetworkCmd(it.id).exec() + } + networkId = docker.createNetworkCmd() + .withName(name) + .withCheckDuplicate(true) + .exec() + .id + } + + fun stop() { + log.info { "removing $name network with id $networkId" } + docker.removeNetworkCmd(networkId).exec() + } + + companion object { + private val log = getLogger() + } +} diff --git a/tempest-testing-docker/src/main/kotlin/app/cash/tempest/testing/DockerDynamoDbServer.kt b/tempest-testing-docker/src/main/kotlin/app/cash/tempest/testing/DockerDynamoDbServer.kt new file mode 100644 index 000000000..9b9ef3ea3 --- /dev/null +++ b/tempest-testing-docker/src/main/kotlin/app/cash/tempest/testing/DockerDynamoDbServer.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.cash.tempest.testing + +import com.amazonaws.services.dynamodbv2.model.AmazonDynamoDBException +import com.github.dockerjava.api.model.ExposedPort +import com.github.dockerjava.api.model.Ports +import com.google.common.util.concurrent.AbstractIdleService + +object DockerDynamoDbServer : AbstractIdleService(), TestDynamoDbServer { + + private val pid = ProcessHandle.current().pid() + override val id = "docker-dynamodb-local-$pid" + + override val port = TestUtils.port + + override fun startUp() { + composer.start() + + // Temporary client to block until the container is running + val client = TestUtils.connect() + while (true) { + try { + client.deleteTable("not a table") + } catch (e: Exception) { + if (e is AmazonDynamoDBException) { + break + } + Thread.sleep(100) + } + } + client.shutdown() + } + + override fun shutDown() { + composer.stop() + } + + private val composer = Composer( + "e-$id", + Container { + // DynamoDB Local listens on port 8000 by default. + val exposedClientPort = ExposedPort.tcp(8000) + val portBindings = Ports() + portBindings.bind(exposedClientPort, Ports.Binding.bindPort(TestUtils.port)) + withImage("amazon/dynamodb-local") + .withName(id) + .withExposedPorts(exposedClientPort) + .withCmd("-jar", "DynamoDBLocal.jar", "-sharedDb") + .withPortBindings(portBindings) + } + ) +} diff --git a/tempest-testing-docker/src/test/kotlin/app/cash/tempest/testing/ExampleTest.kt b/tempest-testing-docker/src/test/kotlin/app/cash/tempest/testing/ExampleTest.kt new file mode 100644 index 000000000..761271479 --- /dev/null +++ b/tempest-testing-docker/src/test/kotlin/app/cash/tempest/testing/ExampleTest.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.cash.tempest.testing + +import app.cash.tempest.urlshortener.Alias +import app.cash.tempest.urlshortener.AliasDb +import app.cash.tempest.urlshortener.AliasItem +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +class ExampleTest { + + @RegisterExtension + @JvmField + val db = testDb() + + private val aliasTable by lazy { db.logicalDb().aliasTable } + + @Test + fun test() { + val alias = Alias( + "SquareCLA", + "https://docs.google.com/forms/d/e/1FAIpQLSeRVQ35-gq2vdSxD1kdh7CJwRdjmUA0EZ9gRXaWYoUeKPZEQQ/viewform?formkey=dDViT2xzUHAwRkI3X3k5Z0lQM091OGc6MQ&ndplr=1" + ) + aliasTable.aliases.save(alias) + val loadedAlias = aliasTable.aliases.load(alias.key) + assertThat(loadedAlias).isNotNull() + assertThat(loadedAlias!!.short_url).isEqualTo(alias.short_url) + assertThat(loadedAlias.destination_url).isEqualTo(alias.destination_url) + } +} + +fun testDb() = TestDynamoDb.Builder(DockerDynamoDbServer) + .addTable(TestTable.create()) + .build() diff --git a/tempest-testing-internal/build.gradle b/tempest-testing-internal/build.gradle new file mode 100644 index 000000000..e91fc76e1 --- /dev/null +++ b/tempest-testing-internal/build.gradle @@ -0,0 +1,13 @@ +apply plugin: 'java-library' +apply plugin: 'kotlin' + +dependencies { + api project(":tempest-testing") + api dep.loggingApi + implementation dep.log4jCore + implementation dep.kotlinStdLib + + testImplementation dep.assertj + testImplementation dep.junitApi + testImplementation dep.junitEngine +} diff --git a/tempest-testing-internal/gradle.properties b/tempest-testing-internal/gradle.properties new file mode 100644 index 000000000..4e952f07c --- /dev/null +++ b/tempest-testing-internal/gradle.properties @@ -0,0 +1,4 @@ +POM_ARTIFACT_ID=tempest-testing-internal +POM_NAME=tempest-testing-internal +POM_DESCRIPTION=tempest-testing-internal +POM_PACKAGING=jar diff --git a/tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/DefaultTestDynamoDbClient.kt b/tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/DefaultTestDynamoDbClient.kt new file mode 100644 index 000000000..328eb3fe8 --- /dev/null +++ b/tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/DefaultTestDynamoDbClient.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.cash.tempest.testing + +import com.amazonaws.services.dynamodbv2.AmazonDynamoDB +import com.amazonaws.services.dynamodbv2.AmazonDynamoDBStreams +import com.google.common.util.concurrent.AbstractIdleService + +class DefaultTestDynamoDbClient( + override val tables: List, +) : AbstractIdleService(), TestDynamoDbClient { + + override val dynamoDb: AmazonDynamoDB + get() = requireNotNull(_dynamoDb) { "`dynamoDb` is only usable while the service is running" } + override val dynamoDbStreams: AmazonDynamoDBStreams + get() = requireNotNull(_dynamoDbStreams) { "`dynamoDbStreams` is only usable while the service is running" } + + private var _dynamoDb: AmazonDynamoDB? = null + private var _dynamoDbStreams: AmazonDynamoDBStreams? = null + + override fun startUp() { + _dynamoDb = TestUtils.connect() + _dynamoDbStreams = TestUtils.connectToStreams() + + // Cleans up the tables before each run. + for (tableName in dynamoDb.listTables().tableNames) { + dynamoDb.deleteTable(tableName) + } + for (table in tables) { + dynamoDb.createTable(table) + } + } + + override fun shutDown() { + dynamoDb.shutdown() + _dynamoDb = null + dynamoDbStreams.shutdown() + _dynamoDbStreams = null + } +} diff --git a/tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/Logger.kt b/tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/Logger.kt new file mode 100644 index 000000000..03c7965b0 --- /dev/null +++ b/tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/Logger.kt @@ -0,0 +1,92 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.cash.tempest.testing + +import mu.KLogger +import mu.KotlinLogging +import org.slf4j.MDC +import org.slf4j.event.Level + +typealias Tag = Pair + +inline fun getLogger(): KLogger { + return KotlinLogging.logger(T::class.qualifiedName!!) +} + +fun KLogger.info(vararg tags: Tag, message: () -> Any?) = + log(Level.INFO, tags = tags, message = message) + +fun KLogger.warn(vararg tags: Tag, message: () -> Any?) = + log(Level.WARN, tags = tags, message = message) + +fun KLogger.error(vararg tags: Tag, message: () -> Any?) = + log(Level.ERROR, tags = tags, message = message) + +fun KLogger.debug(vararg tags: Tag, message: () -> Any?) = + log(Level.DEBUG, tags = tags, message = message) + +fun KLogger.info(th: Throwable, vararg tags: Tag, message: () -> Any?) = + log(Level.INFO, th, tags = tags, message = message) + +fun KLogger.warn(th: Throwable, vararg tags: Tag, message: () -> Any?) = + log(Level.WARN, th, tags = tags, message = message) + +fun KLogger.error(th: Throwable, vararg tags: Tag, message: () -> Any?) = + log(Level.ERROR, th, tags = tags, message = message) + +fun KLogger.debug(th: Throwable, vararg tags: Tag, message: () -> Any?) = + log(Level.DEBUG, th, tags = tags, message = message) + +fun KLogger.log(level: Level, vararg tags: Tag, message: () -> Any?) { + withTags(*tags) { + when (level) { + Level.ERROR -> error(message) + Level.WARN -> warn(message) + Level.INFO -> info(message) + Level.DEBUG -> debug(message) + Level.TRACE -> trace(message) + } + } +} + +fun KLogger.log(level: Level, th: Throwable, vararg tags: Tag, message: () -> Any?) { + withTags(*tags) { + when (level) { + Level.ERROR -> error(th, message) + Level.INFO -> info(th, message) + Level.WARN -> warn(th, message) + Level.DEBUG -> debug(th, message) + Level.TRACE -> trace(th, message) + } + } +} + +private fun withTags(vararg tags: Tag, f: () -> Unit) { + // Establish MDC, saving prior MDC + val priorMDC = tags.map { (k, v) -> + val priorValue = MDC.get(k) + MDC.put(k, v.toString()) + k to priorValue + } + + try { + f() + } finally { + // Restore or clear prior MDC + priorMDC.forEach { (k, v) -> if (v == null) MDC.remove(k) else MDC.put(k, v) } + } +} diff --git a/tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/TestUtils.kt b/tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/TestUtils.kt new file mode 100644 index 000000000..5503ebf9d --- /dev/null +++ b/tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/TestUtils.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.cash.tempest.testing + +import com.amazonaws.auth.AWSCredentialsProvider +import com.amazonaws.auth.AWSStaticCredentialsProvider +import com.amazonaws.auth.BasicAWSCredentials +import com.amazonaws.client.builder.AwsClientBuilder +import com.amazonaws.regions.Regions +import com.amazonaws.services.dynamodbv2.AmazonDynamoDB +import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder +import com.amazonaws.services.dynamodbv2.AmazonDynamoDBStreams +import com.amazonaws.services.dynamodbv2.AmazonDynamoDBStreamsClientBuilder +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMapper +import com.amazonaws.services.dynamodbv2.document.DynamoDB +import com.amazonaws.services.dynamodbv2.model.ProvisionedThroughput + +object TestUtils { + val port: Int = pickPort() + + private val url = "http://localhost:$port" + + private val awsCredentialsProvider: AWSCredentialsProvider = AWSStaticCredentialsProvider( + BasicAWSCredentials("key", "secret") + ) + + private val endpointConfiguration = AwsClientBuilder.EndpointConfiguration( + url, + Regions.US_WEST_2.toString() + ) + + fun connect(): AmazonDynamoDB { + return AmazonDynamoDBClientBuilder.standard() + // The values that you supply for the AWS access key and the Region are only used to name + // the database file. + .withCredentials(awsCredentialsProvider) + .withEndpointConfiguration(endpointConfiguration) + .build() + } + + fun connectToStreams(): AmazonDynamoDBStreams { + return AmazonDynamoDBStreamsClientBuilder.standard() + .withCredentials(awsCredentialsProvider) + .withEndpointConfiguration(endpointConfiguration) + .build() + } + + private fun pickPort(): Int { + // There is a tolerable chance of flaky tests caused by port collision. + return 58000 + (ProcessHandle.current().pid() % 1000).toInt() + } +} + +fun AmazonDynamoDB.createTable( + table: TestTable +) { + var tableRequest = DynamoDBMapper(this) + .generateCreateTableRequest(table.tableClass.java) + // Provisioned throughput needs to be specified when creating the table. However, + // DynamoDB Local ignores your provisioned throughput settings. The values that you specify + // when you call CreateTable and UpdateTable have no effect. In addition, DynamoDB Local + // does not throttle read or write activity. + .withProvisionedThroughput(ProvisionedThroughput(1L, 1L)) + val globalSecondaryIndexes = tableRequest.globalSecondaryIndexes ?: emptyList() + for (globalSecondaryIndex in globalSecondaryIndexes) { + // Provisioned throughput needs to be specified when creating the table. + globalSecondaryIndex.provisionedThroughput = ProvisionedThroughput(1L, 1L) + } + tableRequest = table.configureTable(tableRequest) + + DynamoDB(this).createTable(tableRequest).waitForActive() +} diff --git a/tempest-testing-junit4/build.gradle b/tempest-testing-junit4/build.gradle new file mode 100644 index 000000000..a317e21cc --- /dev/null +++ b/tempest-testing-junit4/build.gradle @@ -0,0 +1,19 @@ +apply plugin: 'java-library' +apply plugin: 'kotlin' + +dependencies { + api project(":tempest-testing") + api dep.junit4Api + implementation project(":tempest-testing-internal") + implementation dep.kotlinStdLib + implementation dep.guava + implementation dep.kotlinReflection + + testImplementation project(":samples:urlshortener") + testImplementation project(":tempest-testing-jvm") + testImplementation dep.assertj +} + +test { + useJUnit() +} diff --git a/tempest-testing-junit4/gradle.properties b/tempest-testing-junit4/gradle.properties new file mode 100644 index 000000000..78c70d3d2 --- /dev/null +++ b/tempest-testing-junit4/gradle.properties @@ -0,0 +1,4 @@ +POM_ARTIFACT_ID=tempest-testing-junit4 +POM_NAME=tempest-testing-junit4 +POM_DESCRIPTION=tempest-testing-junit4 +POM_PACKAGING=jar diff --git a/tempest-testing-junit4/src/main/kotlin/app/cash/tempest/testing/TestDynamoDb.kt b/tempest-testing-junit4/src/main/kotlin/app/cash/tempest/testing/TestDynamoDb.kt new file mode 100644 index 000000000..6c36a89b4 --- /dev/null +++ b/tempest-testing-junit4/src/main/kotlin/app/cash/tempest/testing/TestDynamoDb.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.cash.tempest.testing + +import org.junit.rules.ExternalResource +import java.util.concurrent.ConcurrentHashMap + +class TestDynamoDb private constructor( + private val client: TestDynamoDbClient, + private val server: TestDynamoDbServer +) : TestDynamoDbClient by client, ExternalResource() { + + override fun before() { + server.startIfNeeded() + client.startAsync() + client.awaitRunning() + } + + override fun after() { + client.stopAsync() + client.awaitTerminated() + } + + private fun TestDynamoDbServer.startIfNeeded() { + if (runningServers.contains(id)) { + log.info { "$id already running, not starting anything" } + return + } + log.info { "starting $id" } + startAsync() + awaitRunning() + Runtime.getRuntime().addShutdownHook( + Thread { + log.info { "stopping $id" } + stopAsync() + awaitTerminated() + } + ) + runningServers.add(id) + } + + class Builder( + private val server: TestDynamoDbServer + ) { + private val tables = mutableListOf() + + fun addTable(table: TestTable) = apply { + tables.add(table) + } + + fun addTables(tables: List) = apply { + this.tables.addAll(tables) + } + + fun build() = TestDynamoDb(DefaultTestDynamoDbClient(tables), server) + } + + companion object { + private val runningServers = ConcurrentHashMap.newKeySet() + private val log = getLogger() + } +} diff --git a/tempest-testing-junit4/src/test/kotlin/app/cash/tempest/testing/ExampleTest.kt b/tempest-testing-junit4/src/test/kotlin/app/cash/tempest/testing/ExampleTest.kt new file mode 100644 index 000000000..a457bb7dd --- /dev/null +++ b/tempest-testing-junit4/src/test/kotlin/app/cash/tempest/testing/ExampleTest.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.cash.tempest.testing + +import app.cash.tempest.urlshortener.Alias +import app.cash.tempest.urlshortener.AliasDb +import app.cash.tempest.urlshortener.AliasItem +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.junit.Test + +class ExampleTest { + + @get:Rule + val db = testDb() + + private val aliasTable by lazy { db.logicalDb().aliasTable } + + @Test + fun test() { + val alias = Alias( + "SquareCLA", + "https://docs.google.com/forms/d/e/1FAIpQLSeRVQ35-gq2vdSxD1kdh7CJwRdjmUA0EZ9gRXaWYoUeKPZEQQ/viewform?formkey=dDViT2xzUHAwRkI3X3k5Z0lQM091OGc6MQ&ndplr=1" + ) + aliasTable.aliases.save(alias) + val loadedAlias = aliasTable.aliases.load(alias.key) + assertThat(loadedAlias).isNotNull() + assertThat(loadedAlias!!.short_url).isEqualTo(alias.short_url) + assertThat(loadedAlias.destination_url).isEqualTo(alias.destination_url) + } +} + +fun testDb() = TestDynamoDb.Builder(JvmDynamoDbServer) + .addTable(TestTable.create()) + .build() diff --git a/tempest-testing-junit5/build.gradle b/tempest-testing-junit5/build.gradle new file mode 100644 index 000000000..601579556 --- /dev/null +++ b/tempest-testing-junit5/build.gradle @@ -0,0 +1,16 @@ +apply plugin: 'java-library' +apply plugin: 'kotlin' + +dependencies { + api project(":tempest-testing") + api dep.junitApi + implementation project(":tempest-testing-internal") + implementation dep.kotlinStdLib + implementation dep.guava + implementation dep.kotlinReflection + + testImplementation project(":samples:urlshortener") + testImplementation project(":tempest-testing-jvm") + testImplementation dep.assertj + testImplementation dep.junitEngine +} diff --git a/tempest-testing-junit5/gradle.properties b/tempest-testing-junit5/gradle.properties new file mode 100644 index 000000000..a91495d5f --- /dev/null +++ b/tempest-testing-junit5/gradle.properties @@ -0,0 +1,4 @@ +POM_ARTIFACT_ID=tempest-testing-junit5 +POM_NAME=tempest-testing-junit5 +POM_DESCRIPTION=tempest-testing-junit5 +POM_PACKAGING=jar diff --git a/tempest-testing-junit5/src/main/kotlin/app/cash/tempest/testing/TestDynamoDb.kt b/tempest-testing-junit5/src/main/kotlin/app/cash/tempest/testing/TestDynamoDb.kt new file mode 100644 index 000000000..d259e617c --- /dev/null +++ b/tempest-testing-junit5/src/main/kotlin/app/cash/tempest/testing/TestDynamoDb.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.cash.tempest.testing + +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext +import java.util.concurrent.ConcurrentHashMap + +class TestDynamoDb private constructor( + private val client: TestDynamoDbClient, + private val server: TestDynamoDbServer, +) : TestDynamoDbClient by client, BeforeEachCallback, AfterEachCallback { + + override fun beforeEach(context: ExtensionContext) { + server.startIfNeeded() + client.startAsync() + client.awaitRunning() + } + + override fun afterEach(context: ExtensionContext?) { + client.stopAsync() + client.awaitTerminated() + } + + private fun TestDynamoDbServer.startIfNeeded() { + if (runningServers.contains(id)) { + log.info { "$id already running, not starting anything" } + return + } + log.info { "starting $id" } + startAsync() + awaitRunning() + Runtime.getRuntime().addShutdownHook( + Thread { + log.info { "stopping $id" } + stopAsync() + awaitTerminated() + } + ) + runningServers.add(id) + } + + class Builder( + private val server: TestDynamoDbServer + ) { + private val tables = mutableListOf() + + fun addTable(table: TestTable) = apply { + tables.add(table) + } + + fun addTables(tables: List) = apply { + this.tables.addAll(tables) + } + + fun build() = TestDynamoDb(DefaultTestDynamoDbClient(tables), server) + } + + companion object { + private val runningServers = ConcurrentHashMap.newKeySet() + private val log = getLogger() + } +} diff --git a/tempest-testing-junit5/src/test/kotlin/app/cash/tempest/testing/ExampleTest.kt b/tempest-testing-junit5/src/test/kotlin/app/cash/tempest/testing/ExampleTest.kt new file mode 100644 index 000000000..6d2a9cf70 --- /dev/null +++ b/tempest-testing-junit5/src/test/kotlin/app/cash/tempest/testing/ExampleTest.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.cash.tempest.testing + +import app.cash.tempest.urlshortener.Alias +import app.cash.tempest.urlshortener.AliasDb +import app.cash.tempest.urlshortener.AliasItem +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +class ExampleTest { + + @RegisterExtension + @JvmField + val db = testDb() + + private val aliasTable by lazy { db.logicalDb().aliasTable } + + @Test + fun test() { + val alias = Alias( + "SquareCLA", + "https://docs.google.com/forms/d/e/1FAIpQLSeRVQ35-gq2vdSxD1kdh7CJwRdjmUA0EZ9gRXaWYoUeKPZEQQ/viewform?formkey=dDViT2xzUHAwRkI3X3k5Z0lQM091OGc6MQ&ndplr=1" + ) + aliasTable.aliases.save(alias) + val loadedAlias = aliasTable.aliases.load(alias.key) + assertThat(loadedAlias).isNotNull() + assertThat(loadedAlias!!.short_url).isEqualTo(alias.short_url) + assertThat(loadedAlias.destination_url).isEqualTo(alias.destination_url) + } +} + +fun testDb() = TestDynamoDb.Builder(JvmDynamoDbServer) + .addTable(TestTable.create()) + .build() diff --git a/tempest-testing-jvm/build.gradle b/tempest-testing-jvm/build.gradle new file mode 100644 index 000000000..0b5d7ce93 --- /dev/null +++ b/tempest-testing-jvm/build.gradle @@ -0,0 +1,13 @@ +apply plugin: 'java-library' +apply plugin: 'kotlin' + +dependencies { + api project(":tempest-testing") + implementation project(":tempest-testing-internal") + implementation dep.awsDynamodbLocal + implementation dep.kotlinStdLib + + testImplementation dep.assertj + testImplementation dep.junitApi + testImplementation dep.junitEngine +} diff --git a/tempest-testing-jvm/gradle.properties b/tempest-testing-jvm/gradle.properties new file mode 100644 index 000000000..6419ba914 --- /dev/null +++ b/tempest-testing-jvm/gradle.properties @@ -0,0 +1,4 @@ +POM_ARTIFACT_ID=tempest-testing-jvm +POM_NAME=tempest-testing-jvm +POM_DESCRIPTION=tempest-testing-jvm +POM_PACKAGING=jar diff --git a/tempest-testing-jvm/src/main/kotlin/app/cash/tempest/testing/JvmDynamoDbServer.kt b/tempest-testing-jvm/src/main/kotlin/app/cash/tempest/testing/JvmDynamoDbServer.kt new file mode 100644 index 000000000..01015db8a --- /dev/null +++ b/tempest-testing-jvm/src/main/kotlin/app/cash/tempest/testing/JvmDynamoDbServer.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.cash.tempest.testing + +import com.amazonaws.services.dynamodbv2.local.main.ServerRunner +import com.amazonaws.services.dynamodbv2.local.server.DynamoDBProxyServer +import com.google.common.util.concurrent.AbstractIdleService +import java.io.File + +object JvmDynamoDbServer : AbstractIdleService(), TestDynamoDbServer { + + private val pid = ProcessHandle.current().pid() + override val id = "jvm-dynamodb-local-$pid" + + override val port = TestUtils.port + + private lateinit var server: DynamoDBProxyServer + + override fun startUp() { + val libraryFile = libsqlite4javaNativeLibrary() + System.setProperty("sqlite4java.library.path", libraryFile.parent) + + server = ServerRunner.createServerFromCommandLineArgs( + arrayOf("-inMemory", "-port", port.toString()) + ) + server.start() + } + + private fun libsqlite4javaNativeLibrary(): File { + val prefix = libsqlite4javaPrefix() + val classpath = System.getProperty("java.class.path") + val classpathElements = classpath.split(File.pathSeparator) + for (element in classpathElements) { + val file = File(element) + if (file.name.startsWith(prefix)) { + return file + } + } + throw IllegalArgumentException("couldn't find native library for $prefix") + } + + /** + * Returns the prefix of the sqlite4java native library for the current platform. + * + * Observed values of os.arch include: + * * x86_64 + * * amd64 + * + * Observed values of os.name include: + * * Linux + * * Mac OS X + * + * Available native versions of sqlite4java are: + * * libsqlite4java-linux-amd64-1.0.392.so + * * libsqlite4java-linux-i386-1.0.392.so + * * libsqlite4java-osx-1.0.392.dylib + * * sqlite4java-win32-x64-1.0.392.dll + * * sqlite4java-win32-x86-1.0.392.dll + */ + private fun libsqlite4javaPrefix(): String { + val osArch = System.getProperty("os.arch") + val osName = System.getProperty("os.name") + + return when { + osName == "Linux" && osArch == "amd64" -> "libsqlite4java-linux-amd64-" + osName == "Mac OS X" && osArch == "x86_64" -> "libsqlite4java-osx-" + else -> throw IllegalStateException("unexpected platform: os.name=$osName os.arch=$osArch") + } + } + + override fun shutDown() { + server.stop() + } +} diff --git a/tempest-testing/build.gradle b/tempest-testing/build.gradle new file mode 100644 index 000000000..e6388c94d --- /dev/null +++ b/tempest-testing/build.gradle @@ -0,0 +1,13 @@ +apply plugin: 'java-library' +apply plugin: 'kotlin' + +dependencies { + api project(":tempest") + api dep.findbugsJsr305 + api dep.guava + implementation dep.kotlinStdLib + + testImplementation dep.assertj + testImplementation dep.junitApi + testImplementation dep.junitEngine +} diff --git a/tempest-testing/gradle.properties b/tempest-testing/gradle.properties new file mode 100644 index 000000000..bf8e5c6d8 --- /dev/null +++ b/tempest-testing/gradle.properties @@ -0,0 +1,4 @@ +POM_ARTIFACT_ID=tempest-testing +POM_NAME=tempest-testing +POM_DESCRIPTION=tempest-testing +POM_PACKAGING=jar diff --git a/tempest-testing/src/main/kotlin/app/cash/tempest/testing/TestDynamoDbClient.kt b/tempest-testing/src/main/kotlin/app/cash/tempest/testing/TestDynamoDbClient.kt new file mode 100644 index 000000000..4a5c1cf11 --- /dev/null +++ b/tempest-testing/src/main/kotlin/app/cash/tempest/testing/TestDynamoDbClient.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.cash.tempest.testing + +import app.cash.tempest.LogicalDb +import com.amazonaws.services.dynamodbv2.AmazonDynamoDB +import com.amazonaws.services.dynamodbv2.AmazonDynamoDBStreams +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMapper +import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMapperConfig +import com.google.common.util.concurrent.Service +import kotlin.reflect.KClass + +interface TestDynamoDbClient : Service { + val tables: List + + /** A DynamoDB instance that is usable while this service is running. */ + val dynamoDb: AmazonDynamoDB + + /** A DynamoDB streams instance that is usable while this service is running. */ + val dynamoDbStreams: AmazonDynamoDBStreams + + fun logicalDb(type: KClass): DB { + return logicalDb(type, DynamoDBMapperConfig.DEFAULT) + } + + fun logicalDb(type: KClass, mapperConfig: DynamoDBMapperConfig): DB { + return LogicalDb.create(type, DynamoDBMapper(dynamoDb, mapperConfig)) + } +} + +inline fun TestDynamoDbClient.logicalDb(): DB { + return logicalDb(DB::class) +} + +inline fun TestDynamoDbClient.logicalDb(mapperConfig: DynamoDBMapperConfig): DB { + return logicalDb(DB::class, mapperConfig) +} diff --git a/tempest-testing/src/main/kotlin/app/cash/tempest/testing/TestDynamoDbServer.kt b/tempest-testing/src/main/kotlin/app/cash/tempest/testing/TestDynamoDbServer.kt new file mode 100644 index 000000000..9e80ea6e7 --- /dev/null +++ b/tempest-testing/src/main/kotlin/app/cash/tempest/testing/TestDynamoDbServer.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.cash.tempest.testing + +import com.google.common.util.concurrent.Service + +/** + * A DynamoDB test server running in-process or in a local Docker container. + */ +interface TestDynamoDbServer : Service { + val id: String + val port: Int +} diff --git a/tempest-testing/src/main/kotlin/app/cash/tempest/testing/TestTable.kt b/tempest-testing/src/main/kotlin/app/cash/tempest/testing/TestTable.kt new file mode 100644 index 000000000..cc6a1f8d3 --- /dev/null +++ b/tempest-testing/src/main/kotlin/app/cash/tempest/testing/TestTable.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.cash.tempest.testing + +import com.amazonaws.services.dynamodbv2.model.CreateTableRequest +import kotlin.reflect.KClass + +/** + * Use this with [TestDynamoDbClient] to configure your DynamoDB + * tables for each test execution. + * + * Use [configureTable] to customize the table creation request for testing, such as to configure + * the secondary indexes required by `ProjectionType.ALL`. + */ +data class TestTable internal constructor( + val tableClass: KClass<*>, + val configureTable: (CreateTableRequest) -> CreateTableRequest = { it } +) { + companion object { + inline fun create( + noinline configureTable: (CreateTableRequest) -> CreateTableRequest = { it } + ) = create(T::class, configureTable) + + fun create( + tableClass: KClass<*>, + configureTable: (CreateTableRequest) -> CreateTableRequest = { it } + ) = TestTable(tableClass, configureTable) + + @JvmStatic + @JvmOverloads + fun create( + tableClass: Class<*>, + configureTable: (CreateTableRequest) -> CreateTableRequest = { it } + ) = create(tableClass.kotlin, configureTable) + } +} diff --git a/tempest/build.gradle b/tempest/build.gradle index 67f040811..ce7e5f2c3 100644 --- a/tempest/build.gradle +++ b/tempest/build.gradle @@ -11,14 +11,12 @@ dependencies { implementation dep.okio testImplementation project(":samples:musiclibrary") + testImplementation project(":samples:musiclibrary-testing") testImplementation project(":samples:urlshortener") - testImplementation project(":test-utils") + testImplementation project(":tempest-testing-jvm") + testImplementation project(":tempest-testing-junit5") testImplementation dep.assertj - testImplementation dep.junitApi testImplementation dep.junitEngine - testImplementation dep.kotlinTest - testImplementation dep.miskAwsDynamodbTesting - testImplementation dep.miskTesting } apply from: "$rootDir/gradle-mvn-publish.gradle" diff --git a/tempest/src/test/java/app/cash/tempest/interop/InteropTestModule.java b/tempest/src/test/java/app/cash/tempest/interop/InteropTestModule.java deleted file mode 100644 index 5e53bd1b9..000000000 --- a/tempest/src/test/java/app/cash/tempest/interop/InteropTestModule.java +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright 2021 Square Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package app.cash.tempest.interop; - -import app.cash.tempest.LogicalDb; -import app.cash.tempest.urlshortener.java.AliasDb; -import app.cash.tempest.urlshortener.java.AliasItem; -import com.amazonaws.services.dynamodbv2.AmazonDynamoDB; -import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMapper; -import com.google.inject.AbstractModule; -import com.google.inject.Provides; -import com.google.inject.Singleton; -import kotlin.jvm.internal.Reflection; -import misk.MiskTestingServiceModule; -import misk.aws.dynamodb.testing.DynamoDbTable; -import misk.aws.dynamodb.testing.InProcessDynamoDbModule; - -public class InteropTestModule extends AbstractModule { - - @Override protected void configure() { - install(new MiskTestingServiceModule()); - install( - new InProcessDynamoDbModule( - new DynamoDbTable( - Reflection.createKotlinClass(AliasItem.class), - (createTableRequest) -> createTableRequest - ) - ) - ); - } - - @Provides - @Singleton - AliasDb provideJAliasDb(AmazonDynamoDB amazonDynamoDB) { - var dynamoDbMapper = new DynamoDBMapper(amazonDynamoDB); - return LogicalDb.create(AliasDb.class, dynamoDbMapper); - } -} diff --git a/tempest/src/test/java/app/cash/tempest/interop/InteropTestUtils.java b/tempest/src/test/java/app/cash/tempest/interop/InteropTestUtils.java new file mode 100644 index 000000000..bd9c9967e --- /dev/null +++ b/tempest/src/test/java/app/cash/tempest/interop/InteropTestUtils.java @@ -0,0 +1,31 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.cash.tempest.interop; + +import app.cash.tempest.testing.TestDynamoDb; +import app.cash.tempest.testing.JvmDynamoDbServer; +import app.cash.tempest.testing.TestTable; +import app.cash.tempest.urlshortener.java.AliasItem; + +public class InteropTestUtils { + + public static TestDynamoDb testDb() { + return new TestDynamoDb.Builder(JvmDynamoDbServer.INSTANCE) + .addTable(TestTable.create(AliasItem.class)) + .build(); + } +} diff --git a/tempest/src/test/kotlin/app/cash/tempest/CodecTest.kt b/tempest/src/test/kotlin/app/cash/tempest/CodecTest.kt index 686ced2e9..2bcc0d6fb 100644 --- a/tempest/src/test/kotlin/app/cash/tempest/CodecTest.kt +++ b/tempest/src/test/kotlin/app/cash/tempest/CodecTest.kt @@ -18,28 +18,26 @@ package app.cash.tempest import app.cash.tempest.musiclibrary.AlbumInfo import app.cash.tempest.musiclibrary.AlbumTrack import app.cash.tempest.musiclibrary.MusicDb -import app.cash.tempest.musiclibrary.MusicDbTestModule import app.cash.tempest.musiclibrary.MusicItem -import misk.testing.MiskTest -import misk.testing.MiskTestModule +import app.cash.tempest.musiclibrary.testDb +import app.cash.tempest.testing.logicalDb import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatIllegalArgumentException import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension import java.time.LocalDate -import javax.inject.Inject -@MiskTest(startService = true) class CodecTest { - @MiskTestModule - val module = MusicDbTestModule() + @RegisterExtension + @JvmField + val db = testDb() - @Inject - lateinit var musicDb: MusicDb + private val musicTable by lazy { db.logicalDb().music } @Test internal fun itemCodecToDb() { - val albumInfoCodec = musicDb.music.codec(AlbumInfo::class) + val albumInfoCodec = musicTable.codec(AlbumInfo::class) val albumInfo = AlbumInfo( "ALBUM_1", @@ -64,7 +62,7 @@ class CodecTest { @Test internal fun itemCodecToApp() { - val albumInfoCodec = musicDb.music.codec(AlbumInfo::class) + val albumInfoCodec = musicTable.codec(AlbumInfo::class) val musicItem = MusicItem().apply { partition_key = "ALBUM_1" @@ -89,7 +87,7 @@ class CodecTest { @Test internal fun keyCodecToDb() { - val albumKeyCodec = musicDb.music.codec(AlbumTrack.Key::class) + val albumKeyCodec = musicTable.codec(AlbumTrack.Key::class) val albumTrackKey = AlbumTrack.Key( album_token = "ALBUM_1", @@ -113,7 +111,7 @@ class CodecTest { sort_key = "TRACK_0000000000000001" } - val albumKeyCodec = musicDb.music.codec(AlbumTrack.Key::class) + val albumKeyCodec = musicTable.codec(AlbumTrack.Key::class) val albumTrackKey = albumKeyCodec.toApp(musicItem) assertThat(albumTrackKey).isEqualTo( @@ -127,11 +125,11 @@ class CodecTest { @Test internal fun unexpectedType() { assertThatIllegalArgumentException().isThrownBy { - musicDb.music.codec(AlbumColor::class) + musicTable.codec(AlbumColor::class) }.withMessageContaining("unexpected type") assertThatIllegalArgumentException().isThrownBy { - musicDb.music.codec(AlbumColor.Key::class) + musicTable.codec(AlbumColor.Key::class) }.withMessageContaining("unexpected type") } } diff --git a/tempest/src/test/kotlin/app/cash/tempest/DynamoDBScannableTest.kt b/tempest/src/test/kotlin/app/cash/tempest/DynamoDBScannableTest.kt index e452a7e4c..b03d1049f 100644 --- a/tempest/src/test/kotlin/app/cash/tempest/DynamoDBScannableTest.kt +++ b/tempest/src/test/kotlin/app/cash/tempest/DynamoDBScannableTest.kt @@ -19,30 +19,27 @@ package app.cash.tempest import app.cash.tempest.musiclibrary.AFTER_HOURS_EP import app.cash.tempest.musiclibrary.LOCKDOWN_SINGLE import app.cash.tempest.musiclibrary.MusicDb -import app.cash.tempest.musiclibrary.MusicDbTestModule import app.cash.tempest.musiclibrary.THE_DARK_SIDE_OF_THE_MOON import app.cash.tempest.musiclibrary.THE_WALL import app.cash.tempest.musiclibrary.WHAT_YOU_DO_TO_ME_SINGLE import app.cash.tempest.musiclibrary.albumTitles import app.cash.tempest.musiclibrary.givenAlbums +import app.cash.tempest.musiclibrary.testDb import app.cash.tempest.musiclibrary.trackTitles +import app.cash.tempest.testing.logicalDb import com.amazonaws.services.dynamodbv2.model.AttributeValue -import misk.testing.MiskTest -import misk.testing.MiskTestModule import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension import java.time.Duration -import javax.inject.Inject -@MiskTest(startService = true) class DynamoDBScannableTest { - @MiskTestModule - val module = MusicDbTestModule() + @RegisterExtension + @JvmField + val db = testDb() - @Inject lateinit var musicDb: MusicDb - - private val musicTable get() = musicDb.music + private val musicTable by lazy { db.logicalDb().music } @Test fun primaryIndex() { diff --git a/tempest/src/test/kotlin/app/cash/tempest/DynamoDbQueryableTest.kt b/tempest/src/test/kotlin/app/cash/tempest/DynamoDbQueryableTest.kt index a6b1ed837..bf9649d65 100644 --- a/tempest/src/test/kotlin/app/cash/tempest/DynamoDbQueryableTest.kt +++ b/tempest/src/test/kotlin/app/cash/tempest/DynamoDbQueryableTest.kt @@ -21,33 +21,29 @@ import app.cash.tempest.musiclibrary.AlbumInfo import app.cash.tempest.musiclibrary.AlbumTrack import app.cash.tempest.musiclibrary.LOCKDOWN_SINGLE import app.cash.tempest.musiclibrary.MusicDb -import app.cash.tempest.musiclibrary.MusicDbTestModule import app.cash.tempest.musiclibrary.THE_DARK_SIDE_OF_THE_MOON import app.cash.tempest.musiclibrary.THE_WALL import app.cash.tempest.musiclibrary.WHAT_YOU_DO_TO_ME_SINGLE import app.cash.tempest.musiclibrary.albumTitles import app.cash.tempest.musiclibrary.givenAlbums +import app.cash.tempest.musiclibrary.testDb import app.cash.tempest.musiclibrary.trackTitles import app.cash.tempest.reservedwords.ReservedWordObject import app.cash.tempest.reservedwords.ReservedWordsDb +import app.cash.tempest.testing.logicalDb import com.amazonaws.services.dynamodbv2.model.AttributeValue -import misk.testing.MiskTest -import misk.testing.MiskTestModule import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension import java.time.Duration -import javax.inject.Inject -@MiskTest(startService = true) class DynamoDbQueryableTest { - @MiskTestModule - val module = MusicDbTestModule() + @RegisterExtension + @JvmField + val db = testDb() - @Inject lateinit var musicDb: MusicDb - @Inject lateinit var reservedWordsDb: ReservedWordsDb - - private val musicTable get() = musicDb.music - private val reservedWordsTable get() = reservedWordsDb.table + private val musicTable by lazy { db.logicalDb().music } + private val reservedWordsTable by lazy { db.logicalDb().table } @Test fun primaryIndexBetween() { diff --git a/tempest/src/test/kotlin/app/cash/tempest/DynamoDbViewTest.kt b/tempest/src/test/kotlin/app/cash/tempest/DynamoDbViewTest.kt index 16197a1ed..961c32b0a 100644 --- a/tempest/src/test/kotlin/app/cash/tempest/DynamoDbViewTest.kt +++ b/tempest/src/test/kotlin/app/cash/tempest/DynamoDbViewTest.kt @@ -19,30 +19,27 @@ package app.cash.tempest import app.cash.tempest.musiclibrary.AlbumInfo import app.cash.tempest.musiclibrary.AlbumTrack import app.cash.tempest.musiclibrary.MusicDb -import app.cash.tempest.musiclibrary.MusicDbTestModule import app.cash.tempest.musiclibrary.PlaylistInfo -import com.amazonaws.AmazonServiceException +import app.cash.tempest.musiclibrary.testDb +import app.cash.tempest.testing.logicalDb import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBSaveExpression import com.amazonaws.services.dynamodbv2.model.AttributeValue import com.amazonaws.services.dynamodbv2.model.ComparisonOperator import com.amazonaws.services.dynamodbv2.model.ConditionalCheckFailedException import com.amazonaws.services.dynamodbv2.model.ExpectedAttributeValue -import misk.testing.MiskTest -import misk.testing.MiskTestModule import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatExceptionOfType import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.RegisterExtension import java.time.LocalDate -import javax.inject.Inject -@MiskTest(startService = true) class DynamoDbViewTest { - @MiskTestModule - val module = MusicDbTestModule() - @Inject lateinit var musicDb: MusicDb + @RegisterExtension + @JvmField + val db = testDb() - private val musicTable get() = musicDb.music + private val musicTable by lazy { db.logicalDb().music } @Test fun loadAfterSave() { @@ -76,10 +73,10 @@ class DynamoDbViewTest { musicTable.albumInfo.save(albumInfo, ifNotExist()) // This fails because the album info already exists. - val exception = assertThrows { - musicTable.albumInfo.save(albumInfo, ifNotExist()) - } - assertThat(exception.errorCode).isEqualTo(ConditionalCheckFailedException::class.simpleName) + assertThatExceptionOfType(ConditionalCheckFailedException::class.java) + .isThrownBy { + musicTable.albumInfo.save(albumInfo, ifNotExist()) + } } @Test @@ -108,13 +105,13 @@ class DynamoDbViewTest { assertThat(actualPlaylistInfoV2).isEqualTo(playlistInfoV2) // This fails because playlist_size is already 1. - val exception = assertThrows { - musicTable.playlistInfo.save( - playlistInfoV2, - ifPlaylistVersionIs(playlistInfoV1.playlist_version) - ) - } - assertThat(exception.errorCode).isEqualTo(ConditionalCheckFailedException::class.simpleName) + assertThatExceptionOfType(ConditionalCheckFailedException::class.java) + .isThrownBy { + musicTable.playlistInfo.save( + playlistInfoV2, + ifPlaylistVersionIs(playlistInfoV1.playlist_version) + ) + } } @Test diff --git a/tempest/src/test/kotlin/app/cash/tempest/LogicalDbBatchTest.kt b/tempest/src/test/kotlin/app/cash/tempest/LogicalDbBatchTest.kt index 341603a0f..ad91be086 100644 --- a/tempest/src/test/kotlin/app/cash/tempest/LogicalDbBatchTest.kt +++ b/tempest/src/test/kotlin/app/cash/tempest/LogicalDbBatchTest.kt @@ -18,23 +18,22 @@ package app.cash.tempest import app.cash.tempest.musiclibrary.AlbumTrack import app.cash.tempest.musiclibrary.MusicDb -import app.cash.tempest.musiclibrary.MusicDbTestModule import app.cash.tempest.musiclibrary.PlaylistInfo -import misk.testing.MiskTest -import misk.testing.MiskTestModule +import app.cash.tempest.musiclibrary.testDb +import app.cash.tempest.testing.logicalDb import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension import java.time.Duration -import javax.inject.Inject -@MiskTest(startService = true) class LogicalDbBatchTest { - @MiskTestModule - val module = MusicDbTestModule() - @Inject lateinit var musicDb: MusicDb + @RegisterExtension + @JvmField + val db = testDb() - private val musicTable get() = musicDb.music + private val musicDb by lazy { db.logicalDb() } + private val musicTable by lazy { musicDb.music } @Test fun batchLoad() { diff --git a/tempest/src/test/kotlin/app/cash/tempest/LogicalDbTransactionTest.kt b/tempest/src/test/kotlin/app/cash/tempest/LogicalDbTransactionTest.kt index c51160ed1..34cec4393 100644 --- a/tempest/src/test/kotlin/app/cash/tempest/LogicalDbTransactionTest.kt +++ b/tempest/src/test/kotlin/app/cash/tempest/LogicalDbTransactionTest.kt @@ -18,29 +18,26 @@ package app.cash.tempest import app.cash.tempest.musiclibrary.AlbumTrack import app.cash.tempest.musiclibrary.MusicDb -import app.cash.tempest.musiclibrary.MusicDbTestModule import app.cash.tempest.musiclibrary.PlaylistInfo -import com.amazonaws.AmazonServiceException +import app.cash.tempest.musiclibrary.testDb +import app.cash.tempest.testing.logicalDb import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTransactionWriteExpression import com.amazonaws.services.dynamodbv2.model.AttributeValue import com.amazonaws.services.dynamodbv2.model.TransactionCanceledException -import misk.testing.MiskTest -import misk.testing.MiskTestModule import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatExceptionOfType import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.RegisterExtension import java.time.Duration -import javax.inject.Inject -@MiskTest(startService = true) class LogicalDbTransactionTest { - @MiskTestModule - val module = MusicDbTestModule() + @RegisterExtension + @JvmField + val db = testDb() - @Inject lateinit var musicDb: MusicDb - - private val musicTable get() = musicDb.music + private val musicDb by lazy { db.logicalDb() } + private val musicTable by lazy { musicDb.music } @Test fun transactionLoad() { @@ -191,10 +188,10 @@ class LogicalDbTransactionTest { // Introduce a race condition. musicTable.playlistInfo.save(playlistInfoV2) - val exception = assertThrows { - musicDb.transactionWrite(writeTransaction) - } - assertThat(exception.errorCode).isEqualTo(TransactionCanceledException::class.simpleName) + assertThatExceptionOfType(TransactionCanceledException::class.java) + .isThrownBy { + musicDb.transactionWrite(writeTransaction) + } } @Test @@ -253,10 +250,10 @@ class LogicalDbTransactionTest { ) .build() - val exception = assertThrows { - musicDb.transactionWrite(writeTransaction) - } - assertThat(exception.errorCode).isEqualTo(TransactionCanceledException::class.simpleName) + assertThatExceptionOfType(TransactionCanceledException::class.java) + .isThrownBy { + musicDb.transactionWrite(writeTransaction) + } } private fun ifPlaylistVersionIs(playlist_version: Long): DynamoDBTransactionWriteExpression { diff --git a/tempest/src/test/kotlin/app/cash/tempest/WritingPagerTest.kt b/tempest/src/test/kotlin/app/cash/tempest/WritingPagerTest.kt index 34dc8b854..4315c1f44 100644 --- a/tempest/src/test/kotlin/app/cash/tempest/WritingPagerTest.kt +++ b/tempest/src/test/kotlin/app/cash/tempest/WritingPagerTest.kt @@ -18,28 +18,26 @@ package app.cash.tempest import app.cash.tempest.musiclibrary.AlbumTrack import app.cash.tempest.musiclibrary.MusicDb -import app.cash.tempest.musiclibrary.MusicDbTestModule import app.cash.tempest.musiclibrary.MusicTable import app.cash.tempest.musiclibrary.PlaylistInfo import app.cash.tempest.musiclibrary.THE_WALL import app.cash.tempest.musiclibrary.givenAlbums +import app.cash.tempest.musiclibrary.testDb +import app.cash.tempest.testing.logicalDb import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTransactionWriteExpression import com.amazonaws.services.dynamodbv2.model.AttributeValue -import misk.testing.MiskTest -import misk.testing.MiskTestModule import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test -import javax.inject.Inject +import org.junit.jupiter.api.extension.RegisterExtension -@MiskTest(startService = true) class WritingPagerTest { - @MiskTestModule - val module = MusicDbTestModule() + @RegisterExtension + @JvmField + val db = testDb() - @Inject lateinit var musicDb: MusicDb - - private val musicTable get() = musicDb.music + private val musicDb by lazy { db.logicalDb() } + private val musicTable by lazy { musicDb.music } @Test fun write() { diff --git a/tempest/src/test/kotlin/app/cash/tempest/internal/SchemaTest.kt b/tempest/src/test/kotlin/app/cash/tempest/internal/SchemaTest.kt index 6aebd9b65..e161f324b 100644 --- a/tempest/src/test/kotlin/app/cash/tempest/internal/SchemaTest.kt +++ b/tempest/src/test/kotlin/app/cash/tempest/internal/SchemaTest.kt @@ -20,21 +20,20 @@ import app.cash.tempest.Attribute import app.cash.tempest.ForIndex import app.cash.tempest.musiclibrary.AlbumInfo import app.cash.tempest.musiclibrary.MusicDb -import app.cash.tempest.musiclibrary.MusicDbTestModule -import misk.testing.MiskTest -import misk.testing.MiskTestModule +import app.cash.tempest.musiclibrary.testDb +import app.cash.tempest.testing.logicalDb import org.assertj.core.api.Assertions.assertThatIllegalArgumentException import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension import java.time.LocalDate -import javax.inject.Inject -@MiskTest(startService = true) class SchemaTest { - @MiskTestModule - val module = MusicDbTestModule() + @RegisterExtension + @JvmField + val db = testDb() - @Inject lateinit var musicDb: MusicDb + private val musicDb by lazy { db.logicalDb() } @Test fun badKeyType() { diff --git a/tempest/src/test/kotlin/app/cash/tempest/interop/JavaInteropTest.kt b/tempest/src/test/kotlin/app/cash/tempest/interop/JavaInteropTest.kt index 4ff4a23d3..43b75ffc1 100644 --- a/tempest/src/test/kotlin/app/cash/tempest/interop/JavaInteropTest.kt +++ b/tempest/src/test/kotlin/app/cash/tempest/interop/JavaInteropTest.kt @@ -16,23 +16,20 @@ package app.cash.tempest.interop +import app.cash.tempest.testing.logicalDb import app.cash.tempest.urlshortener.java.Alias import app.cash.tempest.urlshortener.java.AliasDb -import app.cash.tempest.urlshortener.java.AliasTable -import misk.testing.MiskTest -import misk.testing.MiskTestModule import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test -import javax.inject.Inject +import org.junit.jupiter.api.extension.RegisterExtension -@MiskTest(startService = true) class JavaInteropTest { - @MiskTestModule - val module = InteropTestModule() + @RegisterExtension + @JvmField + val db = InteropTestUtils.testDb() - @Inject lateinit var aliasDb: AliasDb - val aliasTable: AliasTable get() = aliasDb.aliasTable() + private val aliasTable by lazy { db.logicalDb().aliasTable() } @Test fun javaLogicalTypeJavaItemType() { diff --git a/tempest/src/test/kotlin/app/cash/tempest/musiclibrary/MusicDbTestModule.kt b/tempest/src/test/kotlin/app/cash/tempest/musiclibrary/MusicDbTestModule.kt deleted file mode 100644 index 314c68097..000000000 --- a/tempest/src/test/kotlin/app/cash/tempest/musiclibrary/MusicDbTestModule.kt +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright 2021 Square Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package app.cash.tempest.musiclibrary - -import app.cash.tempest.LogicalDb -import app.cash.tempest.reservedwords.ReservedWordsDb -import app.cash.tempest.reservedwords.ReservedWordsItem -import com.amazonaws.services.dynamodbv2.AmazonDynamoDB -import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMapper -import com.amazonaws.services.dynamodbv2.model.Projection -import com.amazonaws.services.dynamodbv2.model.ProjectionType -import com.google.inject.Provides -import com.google.inject.Singleton -import misk.MiskTestingServiceModule -import misk.aws.dynamodb.testing.DynamoDbTable -import misk.aws.dynamodb.testing.InProcessDynamoDbModule -import misk.inject.KAbstractModule - -class MusicDbTestModule : KAbstractModule() { - override fun configure() { - install(MiskTestingServiceModule()) - install( - InProcessDynamoDbModule( - DynamoDbTable(MusicItem::class) { - it.apply { - for (gsi in globalSecondaryIndexes) { - gsi.withProjection(Projection().withProjectionType(ProjectionType.ALL)) - } - } - }, - DynamoDbTable(ReservedWordsItem::class) - ) - ) - } - - @Provides - @Singleton - fun provideTestMusicDb(amazonDynamoDB: AmazonDynamoDB): MusicDb { - val dynamoDbMapper = DynamoDBMapper(amazonDynamoDB) - return LogicalDb(dynamoDbMapper) - } - - @Provides - @Singleton - fun provideTestReservedWordsDb(amazonDynamoDB: AmazonDynamoDB): ReservedWordsDb { - val dynamoDbMapper = DynamoDBMapper(amazonDynamoDB) - return LogicalDb(dynamoDbMapper) - } -} diff --git a/tempest/src/test/kotlin/app/cash/tempest/musiclibrary/TestUtils.kt b/tempest/src/test/kotlin/app/cash/tempest/musiclibrary/TestUtils.kt index 2d5eff7e8..562060381 100644 --- a/tempest/src/test/kotlin/app/cash/tempest/musiclibrary/TestUtils.kt +++ b/tempest/src/test/kotlin/app/cash/tempest/musiclibrary/TestUtils.kt @@ -17,6 +17,24 @@ package app.cash.tempest.musiclibrary import app.cash.tempest.Page +import app.cash.tempest.reservedwords.ReservedWordsItem +import app.cash.tempest.testing.JvmDynamoDbServer +import app.cash.tempest.testing.TestDynamoDb +import app.cash.tempest.testing.TestTable +import com.amazonaws.services.dynamodbv2.model.Projection +import com.amazonaws.services.dynamodbv2.model.ProjectionType + +fun testDb() = TestDynamoDb.Builder(JvmDynamoDbServer) + .addTable( + TestTable.create { + for (gsi in it.globalSecondaryIndexes) { + gsi.withProjection(Projection().withProjectionType(ProjectionType.ALL)) + } + it + } + ) + .addTable(TestTable.create()) + .build() val Page<*, AlbumTrack>.trackTitles: List get() = contents.map { it.track_title } diff --git a/tempest2-testing-docker/build.gradle b/tempest2-testing-docker/build.gradle new file mode 100644 index 000000000..17e4abadb --- /dev/null +++ b/tempest2-testing-docker/build.gradle @@ -0,0 +1,22 @@ +apply plugin: 'java-library' +apply plugin: 'kotlin' + +dependencies { + api project(":tempest2-testing") + implementation project(":tempest2-testing-internal") + implementation dep.kotlinStdLib + // The docker-java we use in tests depends on an old version of junixsocket that depends on + // log4j. We force it up a minor version in packages that use it. + implementation('com.kohlschutter.junixsocket:junixsocket-native-common:2.3.2') { + force = true + } + implementation('com.kohlschutter.junixsocket:junixsocket-common:2.3.2') { + force = true + } + implementation dep.docker + + testImplementation dep.assertj + testImplementation dep.junitEngine + testImplementation project(":samples:urlshortener2") + testImplementation project(":tempest2-testing-junit5") +} diff --git a/tempest2-testing-docker/gradle.properties b/tempest2-testing-docker/gradle.properties new file mode 100644 index 000000000..2ad7829b7 --- /dev/null +++ b/tempest2-testing-docker/gradle.properties @@ -0,0 +1,4 @@ +POM_ARTIFACT_ID=tempest-testing-docker +POM_NAME=tempest-testing-docker +POM_DESCRIPTION=tempest-testing-docker +POM_PACKAGING=jar diff --git a/tempest2-testing-docker/src/main/kotlin/app/cash/tempest2/testing/Containers.kt b/tempest2-testing-docker/src/main/kotlin/app/cash/tempest2/testing/Containers.kt new file mode 100644 index 000000000..3217b0ba1 --- /dev/null +++ b/tempest2-testing-docker/src/main/kotlin/app/cash/tempest2/testing/Containers.kt @@ -0,0 +1,217 @@ +package app.cash.tempest2.testing + +import com.github.dockerjava.api.DockerClient +import com.github.dockerjava.api.command.CreateContainerCmd +import com.github.dockerjava.api.exception.NotFoundException +import com.github.dockerjava.api.model.Frame +import com.github.dockerjava.core.DockerClientBuilder +import com.github.dockerjava.core.async.ResultCallbackTemplate +import com.github.dockerjava.core.command.PullImageResultCallback +import com.github.dockerjava.core.command.WaitContainerResultCallback +import com.github.dockerjava.netty.NettyDockerCmdExecFactory +import java.util.concurrent.atomic.AtomicBoolean + +/** + * A [Container] creates a Docker container for testing. + * + * Tests provide a lambda to build a [CreateContainerCmd]. The [createCmd] lambda must set + * [CreateContainerCmd.withName] and [CreateContainerCmd.withImage]. All other fields are + * optional. The [Composer] takes care of setting up the network. + * + * There may be a need to configure your container between the creation and start steps. + * [beforeStartHook] provides you with an id to your container allowing you to + * manipulate as necessary before the command/entrypoint is invoked. + * + * See [Composer] for an example. + */ +internal data class Container( + val createCmd: CreateContainerCmd.() -> Unit, + val beforeStartHook: (docker: DockerClient, id: String) -> Unit +) { + constructor(createCmd: CreateContainerCmd.() -> Unit) : this(createCmd, { _, _ -> }) +} + +/** + * [Composer] composes many [Container]s together to use in a unit test. + * + * The [Container]s are networked using a dedicated Docker network. Tests need to expose ports + * in order for the test to communicate with the containers over 127.0.0.1. + * + * The following example composes Kafka and Zookeeper containers for testing. Kafka is exposed + * to the jUnit test via 127.0.0.1:9102. In this example, Zookeeper is not exposed to the test. + * + * ``` + * val zkContainer = Container { + * this + * .withImage("confluentinc/cp-zookeeper") + * .withName("zookeeper") + * .withEnv("ZOOKEEPER_CLIENT_PORT=2181") + * } + * val kafka = Container { + * this + * .withImage("confluentinc/cp-kafka" + * .withName("kafka") + * .withExposedPorts(ExposedPort.tcp(port)) + * .withPortBindings(Ports().apply { + * bind(ExposedPort.tcp(9102), Ports.Binding.bindPort(9102)) + * }) + * .withEnv( + * "KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181", + * "KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://localhost:9102") + * } + * val composer = Composer("e-kafka", zkContainer, kafka) + * composer.start() + * ``` + */ +internal class Composer(private val name: String, private vararg val containers: Container) { + + private val network = DockerNetwork( + "$name-net", + docker + ) + private val containerIds = mutableMapOf() + private val running = AtomicBoolean(false) + + fun start() { + if (!running.compareAndSet(false, true)) return + Runtime.getRuntime().addShutdownHook(Thread { stop() }) + + network.start() + + for (container in containers) { + val name = container.name() + val create = docker.createContainerCmd("todo").apply(container.createCmd) + require(create.image != "todo") { + "must provide an image for container ${create.name}" + } + + docker.listContainersCmd() + .withShowAll(true) + .withLabelFilter(mapOf("name" to name)) + .exec() + .forEach { + log.info { "removing previous $name container with id ${it.id}" } + docker.removeContainerCmd(it.id).exec() + } + + log.info { "pulling ${create.image} for $name container" } + + val imageParts = create.image!!.split(":") + docker.pullImageCmd(imageParts[0]) + .withTag(imageParts.getOrElse(1) { "latest" }) + .exec(PullImageResultCallback()).awaitCompletion() + + log.info { "starting $name container" } + + val id = create + .withNetworkMode(network.id()) + .withLabels(mapOf("name" to name)) + .withTty(true) + .exec() + .id + containerIds[name] = id + + container.beforeStartHook(docker, id) + + docker.startContainerCmd(id).exec() + docker.logContainerCmd(id) + .withStdErr(true) + .withStdOut(true) + .withFollowStream(true) + .withSince(0) + .exec(LogContainerResultCallback()) + .awaitStarted() + + log.info { "started $name; container id=$id" } + } + } + + private fun Container.name(): String { + val create = docker.createContainerCmd("todo").apply(createCmd) + require(!create.name.isNullOrBlank()) { + "must provide a name for the container" + } + return "$name/${create.name}" + } + + fun stop() { + if (!running.compareAndSet(true, false)) return + + for (container in containers) { + val name = container.name() + val id = containerIds[name]!! + log.info { "killing $name with container id $id" } + docker.removeContainerCmd(id).withForce(true).exec() + + try { + log.info { "waiting for $name to terminate" } + docker.waitContainerCmd(id).exec( + GracefulWaitContainerResultCallback() + ).awaitCompletion() + } catch (th: Throwable) { + log.error(th) { "could not kill $name with container id $id" } + } + + log.info { "killed $name with container id $id" } + } + + network.stop() + } + + private class LogContainerResultCallback : ResultCallbackTemplate() { + override fun onNext(item: Frame) { + String(item.payload).trim().split('\r', '\n').filter { it.isNotBlank() }.forEach { + log.info(it) + } + } + } + + private class GracefulWaitContainerResultCallback : WaitContainerResultCallback() { + override fun onError(throwable: Throwable?) { + // this is ok, just meant that the container already terminated before we tried to wait + if (throwable is NotFoundException) { + return + } + super.onError(throwable) + } + } + + private companion object { + private val log = getLogger() + private val docker: DockerClient = DockerClientBuilder.getInstance() + .withDockerCmdExecFactory(NettyDockerCmdExecFactory()) + .build() + } +} + +private class DockerNetwork(private val name: String, private val docker: DockerClient) { + + private lateinit var networkId: String + + fun id(): String { + return networkId + } + + fun start() { + log.info { "creating $name network" } + + docker.listNetworksCmd().withNameFilter(name).exec().forEach { + log.info { "removing previous $name network with id ${it.id}" } + docker.removeNetworkCmd(it.id).exec() + } + networkId = docker.createNetworkCmd() + .withName(name) + .withCheckDuplicate(true) + .exec() + .id + } + + fun stop() { + log.info { "removing $name network with id $networkId" } + docker.removeNetworkCmd(networkId).exec() + } + + companion object { + private val log = getLogger() + } +} diff --git a/tempest2-testing-docker/src/main/kotlin/app/cash/tempest2/testing/DockerDynamoDbServer.kt b/tempest2-testing-docker/src/main/kotlin/app/cash/tempest2/testing/DockerDynamoDbServer.kt new file mode 100644 index 000000000..1e90a4700 --- /dev/null +++ b/tempest2-testing-docker/src/main/kotlin/app/cash/tempest2/testing/DockerDynamoDbServer.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.cash.tempest2.testing + +import com.github.dockerjava.api.model.ExposedPort +import com.github.dockerjava.api.model.Ports +import com.google.common.util.concurrent.AbstractIdleService +import software.amazon.awssdk.services.dynamodb.model.DeleteTableRequest +import software.amazon.awssdk.services.dynamodb.model.DynamoDbException + +object DockerDynamoDbServer : AbstractIdleService(), TestDynamoDbServer { + + private val pid = ProcessHandle.current().pid() + override val id = "tempest2-docker-dynamodb-local-$pid" + + override val port = TestUtils.port + + override fun startUp() { + composer.start() + + // Temporary client to block until the container is running + val client = TestUtils.connect() + while (true) { + try { + client.deleteTable(DeleteTableRequest.builder().tableName("not a table").build()) + } catch (e: Exception) { + if (e is DynamoDbException) { + break + } + Thread.sleep(100) + } + } + client.close() + } + + override fun shutDown() { + composer.stop() + } + + private val composer = Composer( + "e-$id", + Container { + // DynamoDB Local listens on port 8000 by default. + val exposedClientPort = ExposedPort.tcp(8000) + val portBindings = Ports() + portBindings.bind(exposedClientPort, Ports.Binding.bindPort(TestUtils.port)) + withImage("amazon/dynamodb-local") + .withName(id) + .withExposedPorts(exposedClientPort) + .withCmd("-jar", "DynamoDBLocal.jar", "-sharedDb") + .withPortBindings(portBindings) + } + ) +} diff --git a/tempest2-testing-docker/src/test/kotlin/app/cash/tempest2/testing/ExampleTest.kt b/tempest2-testing-docker/src/test/kotlin/app/cash/tempest2/testing/ExampleTest.kt new file mode 100644 index 000000000..853a7f9a3 --- /dev/null +++ b/tempest2-testing-docker/src/test/kotlin/app/cash/tempest2/testing/ExampleTest.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.cash.tempest2.testing + +import app.cash.tempest2.urlshortener.Alias +import app.cash.tempest2.urlshortener.AliasDb +import app.cash.tempest2.urlshortener.AliasItem +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +class ExampleTest { + + @RegisterExtension + @JvmField + val db = testDb() + + private val aliasTable by lazy { db.logicalDb().aliasTable } + + @Test + fun test() { + val alias = Alias( + "SquareCLA", + "https://docs.google.com/forms/d/e/1FAIpQLSeRVQ35-gq2vdSxD1kdh7CJwRdjmUA0EZ9gRXaWYoUeKPZEQQ/viewform?formkey=dDViT2xzUHAwRkI3X3k5Z0lQM091OGc6MQ&ndplr=1" + ) + aliasTable.aliases.save(alias) + val loadedAlias = aliasTable.aliases.load(alias.key) + assertThat(loadedAlias).isNotNull() + assertThat(loadedAlias!!.short_url).isEqualTo(alias.short_url) + assertThat(loadedAlias.destination_url).isEqualTo(alias.destination_url) + } +} + +fun testDb() = TestDynamoDb.Builder(DockerDynamoDbServer) + .addTable(TestTable.create("alias_items")) + .build() diff --git a/tempest2-testing-internal/build.gradle b/tempest2-testing-internal/build.gradle new file mode 100644 index 000000000..e32cd7d45 --- /dev/null +++ b/tempest2-testing-internal/build.gradle @@ -0,0 +1,13 @@ +apply plugin: 'java-library' +apply plugin: 'kotlin' + +dependencies { + api project(":tempest2-testing") + api dep.loggingApi + implementation dep.log4jCore + implementation dep.kotlinStdLib + + testImplementation dep.assertj + testImplementation dep.junitApi + testImplementation dep.junitEngine +} diff --git a/tempest2-testing-internal/gradle.properties b/tempest2-testing-internal/gradle.properties new file mode 100644 index 000000000..4e952f07c --- /dev/null +++ b/tempest2-testing-internal/gradle.properties @@ -0,0 +1,4 @@ +POM_ARTIFACT_ID=tempest-testing-internal +POM_NAME=tempest-testing-internal +POM_DESCRIPTION=tempest-testing-internal +POM_PACKAGING=jar diff --git a/tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/DefaultTestDynamoDbClient.kt b/tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/DefaultTestDynamoDbClient.kt new file mode 100644 index 000000000..591bb86bd --- /dev/null +++ b/tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/DefaultTestDynamoDbClient.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.cash.tempest2.testing + +import com.google.common.util.concurrent.AbstractIdleService +import software.amazon.awssdk.services.dynamodb.DynamoDbClient +import software.amazon.awssdk.services.dynamodb.model.DeleteTableRequest +import software.amazon.awssdk.services.dynamodb.streams.DynamoDbStreamsClient + +class DefaultTestDynamoDbClient( + override val tables: List, +) : AbstractIdleService(), TestDynamoDbClient { + + override val dynamoDb: DynamoDbClient + get() = requireNotNull(_dynamoDb) { "`dynamoDb` is only usable while the service is running" } + override val dynamoDbStreams: DynamoDbStreamsClient + get() = requireNotNull(_dynamoDbStreams) { "`dynamoDbStreams` is only usable while the service is running" } + + private var _dynamoDb: DynamoDbClient? = null + private var _dynamoDbStreams: DynamoDbStreamsClient? = null + + override fun startUp() { + _dynamoDb = TestUtils.connect() + _dynamoDbStreams = TestUtils.connectToStreams() + + // Cleans up the tables before each run. + for (tableName in dynamoDb.listTables().tableNames()) { + dynamoDb.deleteTable(DeleteTableRequest.builder().tableName(tableName).build()) + } + for (table in tables) { + dynamoDb.createTable(table) + } + } + + override fun shutDown() { + dynamoDb.close() + _dynamoDb = null + dynamoDbStreams.close() + _dynamoDbStreams = null + } +} diff --git a/tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/Logger.kt b/tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/Logger.kt new file mode 100644 index 000000000..26a9daf64 --- /dev/null +++ b/tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/Logger.kt @@ -0,0 +1,92 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.cash.tempest2.testing + +import mu.KLogger +import mu.KotlinLogging +import org.slf4j.MDC +import org.slf4j.event.Level + +typealias Tag = Pair + +inline fun getLogger(): KLogger { + return KotlinLogging.logger(T::class.qualifiedName!!) +} + +fun KLogger.info(vararg tags: Tag, message: () -> Any?) = + log(Level.INFO, tags = tags, message = message) + +fun KLogger.warn(vararg tags: Tag, message: () -> Any?) = + log(Level.WARN, tags = tags, message = message) + +fun KLogger.error(vararg tags: Tag, message: () -> Any?) = + log(Level.ERROR, tags = tags, message = message) + +fun KLogger.debug(vararg tags: Tag, message: () -> Any?) = + log(Level.DEBUG, tags = tags, message = message) + +fun KLogger.info(th: Throwable, vararg tags: Tag, message: () -> Any?) = + log(Level.INFO, th, tags = tags, message = message) + +fun KLogger.warn(th: Throwable, vararg tags: Tag, message: () -> Any?) = + log(Level.WARN, th, tags = tags, message = message) + +fun KLogger.error(th: Throwable, vararg tags: Tag, message: () -> Any?) = + log(Level.ERROR, th, tags = tags, message = message) + +fun KLogger.debug(th: Throwable, vararg tags: Tag, message: () -> Any?) = + log(Level.DEBUG, th, tags = tags, message = message) + +fun KLogger.log(level: Level, vararg tags: Tag, message: () -> Any?) { + withTags(*tags) { + when (level) { + Level.ERROR -> error(message) + Level.WARN -> warn(message) + Level.INFO -> info(message) + Level.DEBUG -> debug(message) + Level.TRACE -> trace(message) + } + } +} + +fun KLogger.log(level: Level, th: Throwable, vararg tags: Tag, message: () -> Any?) { + withTags(*tags) { + when (level) { + Level.ERROR -> error(th, message) + Level.INFO -> info(th, message) + Level.WARN -> warn(th, message) + Level.DEBUG -> debug(th, message) + Level.TRACE -> trace(th, message) + } + } +} + +private fun withTags(vararg tags: Tag, f: () -> Unit) { + // Establish MDC, saving prior MDC + val priorMDC = tags.map { (k, v) -> + val priorValue = MDC.get(k) + MDC.put(k, v.toString()) + k to priorValue + } + + try { + f() + } finally { + // Restore or clear prior MDC + priorMDC.forEach { (k, v) -> if (v == null) MDC.remove(k) else MDC.put(k, v) } + } +} diff --git a/tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/TestUtils.kt b/tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/TestUtils.kt new file mode 100644 index 000000000..ec973b190 --- /dev/null +++ b/tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/TestUtils.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.cash.tempest2.testing + +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient +import software.amazon.awssdk.enhanced.dynamodb.TableSchema +import software.amazon.awssdk.enhanced.dynamodb.model.CreateTableEnhancedRequest +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.dynamodb.DynamoDbClient +import software.amazon.awssdk.services.dynamodb.model.ProvisionedThroughput +import software.amazon.awssdk.services.dynamodb.streams.DynamoDbStreamsClient +import java.net.URI + +object TestUtils { + val port: Int = pickPort() + + private val url = "http://localhost:$port" + + private val awsCredentialsProvider: StaticCredentialsProvider = StaticCredentialsProvider.create( + AwsBasicCredentials.create("key", "secret") + ) + + fun connect(): DynamoDbClient { + return DynamoDbClient.builder() + // The values that you supply for the AWS access key and the Region are only used to name + // the database file. + .credentialsProvider(awsCredentialsProvider) + .region(Region.US_WEST_2) + .endpointOverride(URI.create(url)) + .build() + } + + fun connectToStreams(): DynamoDbStreamsClient { + return DynamoDbStreamsClient.builder() + // The values that you supply for the AWS access key and the Region are only used to name + // the database file. + .credentialsProvider(awsCredentialsProvider) + .region(Region.US_WEST_2) + .endpointOverride(URI.create(url)) + .build() + } + + private fun pickPort(): Int { + // There is a tolerable chance of flaky tests caused by port collision. + return 58000 + (ProcessHandle.current().pid() % 1000).toInt() + } +} + +fun DynamoDbClient.createTable( + table: TestTable +) { + val enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(this) + .build() + var tableRequest = CreateTableEnhancedRequest.builder() + // Provisioned throughput needs to be specified when creating the table. However, + // DynamoDB Local ignores your provisioned throughput settings. The values that you specify + // when you call CreateTable and UpdateTable have no effect. In addition, DynamoDB Local + // does not throttle read or write activity. + .provisionedThroughput( + ProvisionedThroughput.builder().readCapacityUnits(1L).writeCapacityUnits(1L).build() + ) + .build() + tableRequest = table.configureTable(tableRequest) + enhancedClient.table(table.tableName, TableSchema.fromClass(table.tableClass.java)) + .createTable(tableRequest) +} diff --git a/tempest2-testing-junit4/build.gradle b/tempest2-testing-junit4/build.gradle new file mode 100644 index 000000000..253b79f6b --- /dev/null +++ b/tempest2-testing-junit4/build.gradle @@ -0,0 +1,19 @@ +apply plugin: 'java-library' +apply plugin: 'kotlin' + +dependencies { + api project(":tempest2-testing") + api dep.junit4Api + implementation project(":tempest2-testing-internal") + implementation dep.kotlinStdLib + implementation dep.guava + implementation dep.kotlinReflection + + testImplementation project(":samples:urlshortener2") + testImplementation project(":tempest2-testing-jvm") + testImplementation dep.assertj +} + +test { + useJUnit() +} diff --git a/tempest2-testing-junit4/gradle.properties b/tempest2-testing-junit4/gradle.properties new file mode 100644 index 000000000..78c70d3d2 --- /dev/null +++ b/tempest2-testing-junit4/gradle.properties @@ -0,0 +1,4 @@ +POM_ARTIFACT_ID=tempest-testing-junit4 +POM_NAME=tempest-testing-junit4 +POM_DESCRIPTION=tempest-testing-junit4 +POM_PACKAGING=jar diff --git a/tempest2-testing-junit4/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDb.kt b/tempest2-testing-junit4/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDb.kt new file mode 100644 index 000000000..0c6bc6025 --- /dev/null +++ b/tempest2-testing-junit4/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDb.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.cash.tempest2.testing + +import org.junit.rules.ExternalResource +import java.util.concurrent.ConcurrentHashMap + +class TestDynamoDb private constructor( + private val client: TestDynamoDbClient, + private val server: TestDynamoDbServer +) : TestDynamoDbClient by client, ExternalResource() { + + override fun before() { + server.startIfNeeded() + client.startAsync() + client.awaitRunning() + } + + override fun after() { + client.stopAsync() + client.awaitTerminated() + } + + private fun TestDynamoDbServer.startIfNeeded() { + if (runningServers.contains(id)) { + log.info { "$id already running, not starting anything" } + return + } + log.info { "starting $id" } + startAsync() + awaitRunning() + Runtime.getRuntime().addShutdownHook( + Thread { + log.info { "stopping $id" } + stopAsync() + awaitTerminated() + } + ) + runningServers.add(id) + } + + class Builder( + private val server: TestDynamoDbServer + ) { + private val tables = mutableListOf() + + fun addTable(table: TestTable) = apply { + tables.add(table) + } + + fun addTables(tables: List) = apply { + this.tables.addAll(tables) + } + + fun build() = TestDynamoDb(DefaultTestDynamoDbClient(tables), server) + } + + companion object { + private val runningServers = ConcurrentHashMap.newKeySet() + private val log = getLogger() + } +} diff --git a/tempest2-testing-junit4/src/test/kotlin/app/cash/tempest2/testing/ExampleTest.kt b/tempest2-testing-junit4/src/test/kotlin/app/cash/tempest2/testing/ExampleTest.kt new file mode 100644 index 000000000..0849e2d9f --- /dev/null +++ b/tempest2-testing-junit4/src/test/kotlin/app/cash/tempest2/testing/ExampleTest.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.cash.tempest2.testing + +import app.cash.tempest2.urlshortener.Alias +import app.cash.tempest2.urlshortener.AliasDb +import app.cash.tempest2.urlshortener.AliasItem +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.junit.Test + +class ExampleTest { + + @get:Rule + val db = testDb() + + private val aliasTable by lazy { db.logicalDb().aliasTable } + + @Test + fun test() { + val alias = Alias( + "SquareCLA", + "https://docs.google.com/forms/d/e/1FAIpQLSeRVQ35-gq2vdSxD1kdh7CJwRdjmUA0EZ9gRXaWYoUeKPZEQQ/viewform?formkey=dDViT2xzUHAwRkI3X3k5Z0lQM091OGc6MQ&ndplr=1" + ) + aliasTable.aliases.save(alias) + val loadedAlias = aliasTable.aliases.load(alias.key) + assertThat(loadedAlias).isNotNull() + assertThat(loadedAlias!!.short_url).isEqualTo(alias.short_url) + assertThat(loadedAlias.destination_url).isEqualTo(alias.destination_url) + } +} + +fun testDb() = TestDynamoDb.Builder(JvmDynamoDbServer) + .addTable(TestTable.create("alias_items")) + .build() diff --git a/tempest2-testing-junit5/build.gradle b/tempest2-testing-junit5/build.gradle new file mode 100644 index 000000000..5f5062621 --- /dev/null +++ b/tempest2-testing-junit5/build.gradle @@ -0,0 +1,16 @@ +apply plugin: 'java-library' +apply plugin: 'kotlin' + +dependencies { + api project(":tempest2-testing") + api dep.junitApi + implementation project(":tempest2-testing-internal") + implementation dep.kotlinStdLib + implementation dep.guava + implementation dep.kotlinReflection + + testImplementation project(":samples:urlshortener2") + testImplementation project(":tempest2-testing-jvm") + testImplementation dep.assertj + testImplementation dep.junitEngine +} diff --git a/tempest2-testing-junit5/gradle.properties b/tempest2-testing-junit5/gradle.properties new file mode 100644 index 000000000..a91495d5f --- /dev/null +++ b/tempest2-testing-junit5/gradle.properties @@ -0,0 +1,4 @@ +POM_ARTIFACT_ID=tempest-testing-junit5 +POM_NAME=tempest-testing-junit5 +POM_DESCRIPTION=tempest-testing-junit5 +POM_PACKAGING=jar diff --git a/tempest2-testing-junit5/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDb.kt b/tempest2-testing-junit5/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDb.kt new file mode 100644 index 000000000..a14942680 --- /dev/null +++ b/tempest2-testing-junit5/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDb.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.cash.tempest2.testing + +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext +import java.util.concurrent.ConcurrentHashMap + +class TestDynamoDb private constructor( + private val client: TestDynamoDbClient, + private val server: TestDynamoDbServer, +) : TestDynamoDbClient by client, BeforeEachCallback, AfterEachCallback { + + override fun beforeEach(context: ExtensionContext) { + server.startIfNeeded() + client.startAsync() + client.awaitRunning() + } + + override fun afterEach(context: ExtensionContext?) { + client.stopAsync() + client.awaitTerminated() + } + + private fun TestDynamoDbServer.startIfNeeded() { + if (runningServers.contains(id)) { + log.info { "$id already running, not starting anything" } + return + } + log.info { "starting $id" } + startAsync() + awaitRunning() + Runtime.getRuntime().addShutdownHook( + Thread { + log.info { "stopping $id" } + stopAsync() + awaitTerminated() + } + ) + runningServers.add(id) + } + + class Builder( + private val server: TestDynamoDbServer + ) { + private val tables = mutableListOf() + + fun addTable(table: TestTable) = apply { + tables.add(table) + } + + fun addTables(tables: List) = apply { + this.tables.addAll(tables) + } + + fun build() = TestDynamoDb(DefaultTestDynamoDbClient(tables), server) + } + + companion object { + private val runningServers = ConcurrentHashMap.newKeySet() + private val log = getLogger() + } +} diff --git a/tempest2-testing-junit5/src/test/kotlin/app/cash/tempest2/testing/ExampleTest.kt b/tempest2-testing-junit5/src/test/kotlin/app/cash/tempest2/testing/ExampleTest.kt new file mode 100644 index 000000000..10bdb230e --- /dev/null +++ b/tempest2-testing-junit5/src/test/kotlin/app/cash/tempest2/testing/ExampleTest.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.cash.tempest2.testing + +import app.cash.tempest2.urlshortener.Alias +import app.cash.tempest2.urlshortener.AliasDb +import app.cash.tempest2.urlshortener.AliasItem +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +class ExampleTest { + + @RegisterExtension + @JvmField + val db = testDb() + + private val aliasTable by lazy { db.logicalDb().aliasTable } + + @Test + fun test() { + val alias = Alias( + "SquareCLA", + "https://docs.google.com/forms/d/e/1FAIpQLSeRVQ35-gq2vdSxD1kdh7CJwRdjmUA0EZ9gRXaWYoUeKPZEQQ/viewform?formkey=dDViT2xzUHAwRkI3X3k5Z0lQM091OGc6MQ&ndplr=1" + ) + aliasTable.aliases.save(alias) + val loadedAlias = aliasTable.aliases.load(alias.key) + assertThat(loadedAlias).isNotNull() + assertThat(loadedAlias!!.short_url).isEqualTo(alias.short_url) + assertThat(loadedAlias.destination_url).isEqualTo(alias.destination_url) + } +} + +fun testDb() = TestDynamoDb.Builder(JvmDynamoDbServer) + .addTable(TestTable.create("alias_items")) + .build() diff --git a/tempest2-testing-jvm/build.gradle b/tempest2-testing-jvm/build.gradle new file mode 100644 index 000000000..af298c2e1 --- /dev/null +++ b/tempest2-testing-jvm/build.gradle @@ -0,0 +1,13 @@ +apply plugin: 'java-library' +apply plugin: 'kotlin' + +dependencies { + api project(":tempest2-testing") + implementation project(":tempest2-testing-internal") + implementation dep.awsDynamodbLocal + implementation dep.kotlinStdLib + + testImplementation dep.assertj + testImplementation dep.junitApi + testImplementation dep.junitEngine +} diff --git a/tempest2-testing-jvm/gradle.properties b/tempest2-testing-jvm/gradle.properties new file mode 100644 index 000000000..6419ba914 --- /dev/null +++ b/tempest2-testing-jvm/gradle.properties @@ -0,0 +1,4 @@ +POM_ARTIFACT_ID=tempest-testing-jvm +POM_NAME=tempest-testing-jvm +POM_DESCRIPTION=tempest-testing-jvm +POM_PACKAGING=jar diff --git a/tempest2-testing-jvm/src/main/kotlin/app/cash/tempest2/testing/JvmDynamoDbServer.kt b/tempest2-testing-jvm/src/main/kotlin/app/cash/tempest2/testing/JvmDynamoDbServer.kt new file mode 100644 index 000000000..cea095d80 --- /dev/null +++ b/tempest2-testing-jvm/src/main/kotlin/app/cash/tempest2/testing/JvmDynamoDbServer.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.cash.tempest2.testing + +import com.amazonaws.services.dynamodbv2.local.main.ServerRunner +import com.amazonaws.services.dynamodbv2.local.server.DynamoDBProxyServer +import com.google.common.util.concurrent.AbstractIdleService +import java.io.File + +object JvmDynamoDbServer : AbstractIdleService(), TestDynamoDbServer { + + private val pid = ProcessHandle.current().pid() + override val id = "tempest2-jvm-dynamodb-local-$pid" + + override val port = TestUtils.port + + private lateinit var server: DynamoDBProxyServer + + override fun startUp() { + val libraryFile = libsqlite4javaNativeLibrary() + System.setProperty("sqlite4java.library.path", libraryFile.parent) + + server = ServerRunner.createServerFromCommandLineArgs( + arrayOf("-inMemory", "-port", port.toString()) + ) + server.start() + } + + private fun libsqlite4javaNativeLibrary(): File { + val prefix = libsqlite4javaPrefix() + val classpath = System.getProperty("java.class.path") + val classpathElements = classpath.split(File.pathSeparator) + for (element in classpathElements) { + val file = File(element) + if (file.name.startsWith(prefix)) { + return file + } + } + throw IllegalArgumentException("couldn't find native library for $prefix") + } + + /** + * Returns the prefix of the sqlite4java native library for the current platform. + * + * Observed values of os.arch include: + * * x86_64 + * * amd64 + * + * Observed values of os.name include: + * * Linux + * * Mac OS X + * + * Available native versions of sqlite4java are: + * * libsqlite4java-linux-amd64-1.0.392.so + * * libsqlite4java-linux-i386-1.0.392.so + * * libsqlite4java-osx-1.0.392.dylib + * * sqlite4java-win32-x64-1.0.392.dll + * * sqlite4java-win32-x86-1.0.392.dll + */ + private fun libsqlite4javaPrefix(): String { + val osArch = System.getProperty("os.arch") + val osName = System.getProperty("os.name") + + return when { + osName == "Linux" && osArch == "amd64" -> "libsqlite4java-linux-amd64-" + osName == "Mac OS X" && osArch == "x86_64" -> "libsqlite4java-osx-" + else -> throw IllegalStateException("unexpected platform: os.name=$osName os.arch=$osArch") + } + } + + override fun shutDown() { + server.stop() + } +} diff --git a/tempest2-testing/build.gradle b/tempest2-testing/build.gradle new file mode 100644 index 000000000..718ca6c46 --- /dev/null +++ b/tempest2-testing/build.gradle @@ -0,0 +1,13 @@ +apply plugin: 'java-library' +apply plugin: 'kotlin' + +dependencies { + api project(":tempest2") + api dep.findbugsJsr305 + api dep.guava + implementation dep.kotlinStdLib + + testImplementation dep.assertj + testImplementation dep.junitApi + testImplementation dep.junitEngine +} diff --git a/tempest2-testing/gradle.properties b/tempest2-testing/gradle.properties new file mode 100644 index 000000000..bf8e5c6d8 --- /dev/null +++ b/tempest2-testing/gradle.properties @@ -0,0 +1,4 @@ +POM_ARTIFACT_ID=tempest-testing +POM_NAME=tempest-testing +POM_DESCRIPTION=tempest-testing +POM_PACKAGING=jar diff --git a/tempest2-testing/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDbClient.kt b/tempest2-testing/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDbClient.kt new file mode 100644 index 000000000..44ed24d59 --- /dev/null +++ b/tempest2-testing/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDbClient.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.cash.tempest2.testing + +import app.cash.tempest2.LogicalDb +import com.google.common.util.concurrent.Service +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClientExtension +import software.amazon.awssdk.services.dynamodb.DynamoDbClient +import software.amazon.awssdk.services.dynamodb.streams.DynamoDbStreamsClient +import kotlin.reflect.KClass + +interface TestDynamoDbClient : Service { + val tables: List + + /** A DynamoDB instance that is usable while this service is running. */ + val dynamoDb: DynamoDbClient + + /** A DynamoDB streams instance that is usable while this service is running. */ + val dynamoDbStreams: DynamoDbStreamsClient + + fun logicalDb(type: KClass): DB { + return logicalDb(type, emptyList()) + } + + fun logicalDb( + type: KClass, + extensions: List + ): DB { + val enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(dynamoDb) + .extensions(extensions) + .build() + return LogicalDb.create(type, enhancedClient) + } +} + +inline fun TestDynamoDbClient.logicalDb(): DB { + return logicalDb(DB::class) +} + +inline fun TestDynamoDbClient.logicalDb(extensions: List): DB { + return logicalDb(DB::class, extensions) +} diff --git a/tempest2-testing/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDbServer.kt b/tempest2-testing/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDbServer.kt new file mode 100644 index 000000000..0c27c9403 --- /dev/null +++ b/tempest2-testing/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDbServer.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.cash.tempest2.testing + +import com.google.common.util.concurrent.Service + +/** + * A DynamoDB test server running in-process or in a local Docker container. + */ +interface TestDynamoDbServer : Service { + val id: String + val port: Int +} diff --git a/tempest2-testing/src/main/kotlin/app/cash/tempest2/testing/TestTable.kt b/tempest2-testing/src/main/kotlin/app/cash/tempest2/testing/TestTable.kt new file mode 100644 index 000000000..8b9577fd1 --- /dev/null +++ b/tempest2-testing/src/main/kotlin/app/cash/tempest2/testing/TestTable.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.cash.tempest2.testing + +import software.amazon.awssdk.enhanced.dynamodb.model.CreateTableEnhancedRequest +import kotlin.reflect.KClass + +/** + * Use this with [TestDynamoDbClient] to configure your DynamoDB + * tables for each test execution. + * + * Use [configureTable] to customize the table creation request for testing, such as to configure + * the secondary indexes required by `ProjectionType.ALL`. + */ +data class TestTable internal constructor( + val tableName: String, + val tableClass: KClass<*>, + val configureTable: (CreateTableEnhancedRequest) -> CreateTableEnhancedRequest = { it } +) { + companion object { + inline fun create( + tableName: String, + noinline configureTable: (CreateTableEnhancedRequest) -> CreateTableEnhancedRequest = { it } + ) = create(tableName, T::class, configureTable) + + fun create( + tableName: String, + tableClass: KClass<*>, + configureTable: (CreateTableEnhancedRequest) -> CreateTableEnhancedRequest = { it } + ) = TestTable(tableName, tableClass, configureTable) + + @JvmStatic + @JvmOverloads + fun create( + tableName: String, + tableClass: Class<*>, + configureTable: (CreateTableEnhancedRequest) -> CreateTableEnhancedRequest = { it } + ) = create(tableName, tableClass.kotlin, configureTable) + } +} diff --git a/tempest2/build.gradle b/tempest2/build.gradle index afa343607..d4f3e564a 100644 --- a/tempest2/build.gradle +++ b/tempest2/build.gradle @@ -11,14 +11,12 @@ dependencies { implementation dep.kotlinStdLib testImplementation project(":samples:musiclibrary2") + testImplementation project(":samples:musiclibrary-testing") testImplementation project(":samples:urlshortener2") - testImplementation project(":test-utils") + testImplementation project(":tempest2-testing-jvm") + testImplementation project(":tempest2-testing-junit5") testImplementation dep.assertj - testImplementation dep.junitApi testImplementation dep.junitEngine - testImplementation dep.kotlinTest - testImplementation dep.miskAws2DynamodbTesting - testImplementation dep.miskTesting } apply from: "$rootDir/gradle-mvn-publish.gradle" diff --git a/tempest2/src/test/java/app/cash/tempest2/interop/InteropTestModule.java b/tempest2/src/test/java/app/cash/tempest2/interop/InteropTestModule.java deleted file mode 100644 index 0a0bc00d4..000000000 --- a/tempest2/src/test/java/app/cash/tempest2/interop/InteropTestModule.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2021 Square Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package app.cash.tempest2.interop; - -import app.cash.tempest2.LogicalDb; -import app.cash.tempest2.urlshortener.java.AliasDb; -import app.cash.tempest2.urlshortener.java.AliasItem; -import com.google.inject.AbstractModule; -import com.google.inject.Provides; -import javax.inject.Singleton; -import kotlin.jvm.internal.Reflection; -import misk.MiskTestingServiceModule; -import misk.aws2.dynamodb.testing.DynamoDbTable; -import misk.aws2.dynamodb.testing.InProcessDynamoDbModule; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; - -public class InteropTestModule extends AbstractModule { - - @Override protected void configure() { - install(new MiskTestingServiceModule()); - install( - new InProcessDynamoDbModule( - new DynamoDbTable( - "j_alias_items", - Reflection.createKotlinClass(AliasItem.class) - ) - ) - ); - } - - @Provides - @Singleton - AliasDb provideJAliasDb(DynamoDbClient dynamoDbClient) { - var dynamoDbEnhancedClient = DynamoDbEnhancedClient.builder() - .dynamoDbClient(dynamoDbClient) - .build(); - return LogicalDb.create(AliasDb.class, dynamoDbEnhancedClient); - } -} diff --git a/tempest2/src/test/java/app/cash/tempest2/interop/InteropTestUtils.java b/tempest2/src/test/java/app/cash/tempest2/interop/InteropTestUtils.java new file mode 100644 index 000000000..0453e526e --- /dev/null +++ b/tempest2/src/test/java/app/cash/tempest2/interop/InteropTestUtils.java @@ -0,0 +1,31 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.cash.tempest2.interop; + +import app.cash.tempest2.testing.JvmDynamoDbServer; +import app.cash.tempest2.testing.TestDynamoDb; +import app.cash.tempest2.testing.TestTable; +import app.cash.tempest2.urlshortener.java.AliasItem; + +public class InteropTestUtils { + + public static TestDynamoDb testDb() { + return new TestDynamoDb.Builder(JvmDynamoDbServer.INSTANCE) + .addTable(TestTable.create("j_alias_items", AliasItem.class)) + .build(); + } +} diff --git a/tempest2/src/test/kotlin/app/cash/tempest2/CodecTest.kt b/tempest2/src/test/kotlin/app/cash/tempest2/CodecTest.kt index 0a6db0467..c23258f78 100644 --- a/tempest2/src/test/kotlin/app/cash/tempest2/CodecTest.kt +++ b/tempest2/src/test/kotlin/app/cash/tempest2/CodecTest.kt @@ -18,24 +18,22 @@ package app.cash.tempest2 import app.cash.tempest2.musiclibrary.AlbumInfo import app.cash.tempest2.musiclibrary.AlbumTrack import app.cash.tempest2.musiclibrary.MusicDb -import app.cash.tempest2.musiclibrary.MusicDbTestModule import app.cash.tempest2.musiclibrary.MusicItem -import misk.testing.MiskTest -import misk.testing.MiskTestModule +import app.cash.tempest2.musiclibrary.testDb +import app.cash.tempest2.testing.logicalDb import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatIllegalArgumentException import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension import java.time.LocalDate -import javax.inject.Inject -@MiskTest(startService = true) class CodecTest { - @MiskTestModule - val module = MusicDbTestModule() + @RegisterExtension + @JvmField + val db = testDb() - @Inject - lateinit var musicDb: MusicDb + private val musicDb by lazy { db.logicalDb() } @Test internal fun itemCodecToDb() { diff --git a/tempest2/src/test/kotlin/app/cash/tempest2/DynamoDbQueryableTest.kt b/tempest2/src/test/kotlin/app/cash/tempest2/DynamoDbQueryableTest.kt index c8a8cad48..66207d1c9 100644 --- a/tempest2/src/test/kotlin/app/cash/tempest2/DynamoDbQueryableTest.kt +++ b/tempest2/src/test/kotlin/app/cash/tempest2/DynamoDbQueryableTest.kt @@ -24,27 +24,25 @@ import app.cash.tempest.musiclibrary.WHAT_YOU_DO_TO_ME_SINGLE import app.cash.tempest2.musiclibrary.AlbumInfo import app.cash.tempest2.musiclibrary.AlbumTrack import app.cash.tempest2.musiclibrary.MusicDb -import app.cash.tempest2.musiclibrary.MusicDbTestModule import app.cash.tempest2.musiclibrary.albumTitles import app.cash.tempest2.musiclibrary.givenAlbums +import app.cash.tempest2.musiclibrary.testDb import app.cash.tempest2.musiclibrary.trackTitles -import misk.testing.MiskTest -import misk.testing.MiskTestModule +import app.cash.tempest2.testing.logicalDb import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension import software.amazon.awssdk.enhanced.dynamodb.Expression import software.amazon.awssdk.services.dynamodb.model.AttributeValue import java.time.Duration -import javax.inject.Inject -@MiskTest(startService = true) class DynamoDbQueryableTest { - @MiskTestModule - val module = MusicDbTestModule() - @Inject lateinit var musicDb: MusicDb + @RegisterExtension + @JvmField + val db = testDb() - private val musicTable get() = musicDb.music + private val musicTable by lazy { db.logicalDb().music } @Test fun primaryIndexBetween() { diff --git a/tempest2/src/test/kotlin/app/cash/tempest2/DynamoDbScannableTest.kt b/tempest2/src/test/kotlin/app/cash/tempest2/DynamoDbScannableTest.kt index 1372ea651..e5bb6553b 100644 --- a/tempest2/src/test/kotlin/app/cash/tempest2/DynamoDbScannableTest.kt +++ b/tempest2/src/test/kotlin/app/cash/tempest2/DynamoDbScannableTest.kt @@ -22,28 +22,25 @@ import app.cash.tempest.musiclibrary.THE_DARK_SIDE_OF_THE_MOON import app.cash.tempest.musiclibrary.THE_WALL import app.cash.tempest.musiclibrary.WHAT_YOU_DO_TO_ME_SINGLE import app.cash.tempest2.musiclibrary.MusicDb -import app.cash.tempest2.musiclibrary.MusicDbTestModule import app.cash.tempest2.musiclibrary.albumTitles import app.cash.tempest2.musiclibrary.givenAlbums +import app.cash.tempest2.musiclibrary.testDb import app.cash.tempest2.musiclibrary.trackTitles -import misk.testing.MiskTest -import misk.testing.MiskTestModule +import app.cash.tempest2.testing.logicalDb import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension import software.amazon.awssdk.enhanced.dynamodb.Expression import software.amazon.awssdk.services.dynamodb.model.AttributeValue import java.time.Duration -import javax.inject.Inject -@MiskTest(startService = true) class DynamoDbScannableTest { - @MiskTestModule - val module = MusicDbTestModule() + @RegisterExtension + @JvmField + val db = testDb() - @Inject lateinit var musicDb: MusicDb - - private val musicTable get() = musicDb.music + private val musicTable by lazy { db.logicalDb().music } @Test fun primaryIndex() { diff --git a/tempest2/src/test/kotlin/app/cash/tempest2/DynamoDbViewTest.kt b/tempest2/src/test/kotlin/app/cash/tempest2/DynamoDbViewTest.kt index 8f62bc5de..a4cc1895b 100644 --- a/tempest2/src/test/kotlin/app/cash/tempest2/DynamoDbViewTest.kt +++ b/tempest2/src/test/kotlin/app/cash/tempest2/DynamoDbViewTest.kt @@ -19,27 +19,25 @@ package app.cash.tempest2 import app.cash.tempest2.musiclibrary.AlbumInfo import app.cash.tempest2.musiclibrary.AlbumTrack import app.cash.tempest2.musiclibrary.MusicDb -import app.cash.tempest2.musiclibrary.MusicDbTestModule import app.cash.tempest2.musiclibrary.PlaylistInfo -import misk.testing.MiskTest -import misk.testing.MiskTestModule +import app.cash.tempest2.musiclibrary.testDb +import app.cash.tempest2.testing.logicalDb import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatExceptionOfType import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension import software.amazon.awssdk.enhanced.dynamodb.Expression import software.amazon.awssdk.services.dynamodb.model.AttributeValue import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException import java.time.LocalDate -import javax.inject.Inject -@MiskTest(startService = true) class DynamoDbViewTest { - @MiskTestModule - val module = MusicDbTestModule() - @Inject lateinit var musicDb: MusicDb + @RegisterExtension + @JvmField + val db = testDb() - private val musicTable get() = musicDb.music + private val musicTable by lazy { db.logicalDb().music } @Test fun loadAfterSave() { diff --git a/tempest2/src/test/kotlin/app/cash/tempest2/LogicalDbBatchTest.kt b/tempest2/src/test/kotlin/app/cash/tempest2/LogicalDbBatchTest.kt index 7df63f3c6..df1b79eb1 100644 --- a/tempest2/src/test/kotlin/app/cash/tempest2/LogicalDbBatchTest.kt +++ b/tempest2/src/test/kotlin/app/cash/tempest2/LogicalDbBatchTest.kt @@ -18,23 +18,22 @@ package app.cash.tempest2 import app.cash.tempest2.musiclibrary.AlbumTrack import app.cash.tempest2.musiclibrary.MusicDb -import app.cash.tempest2.musiclibrary.MusicDbTestModule import app.cash.tempest2.musiclibrary.PlaylistInfo -import misk.testing.MiskTest -import misk.testing.MiskTestModule +import app.cash.tempest2.musiclibrary.testDb +import app.cash.tempest2.testing.logicalDb import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension import java.time.Duration -import javax.inject.Inject -@MiskTest(startService = true) class LogicalDbBatchTest { - @MiskTestModule - val module = MusicDbTestModule() - @Inject lateinit var musicDb: MusicDb + @RegisterExtension + @JvmField + val db = testDb() - private val musicTable get() = musicDb.music + private val musicDb by lazy { db.logicalDb() } + private val musicTable by lazy { musicDb.music } @Test fun batchLoad() { diff --git a/tempest2/src/test/kotlin/app/cash/tempest2/LogicalDbTransactionTest.kt b/tempest2/src/test/kotlin/app/cash/tempest2/LogicalDbTransactionTest.kt index d8abe42a1..a167c319d 100644 --- a/tempest2/src/test/kotlin/app/cash/tempest2/LogicalDbTransactionTest.kt +++ b/tempest2/src/test/kotlin/app/cash/tempest2/LogicalDbTransactionTest.kt @@ -18,28 +18,26 @@ package app.cash.tempest2 import app.cash.tempest2.musiclibrary.AlbumTrack import app.cash.tempest2.musiclibrary.MusicDb -import app.cash.tempest2.musiclibrary.MusicDbTestModule import app.cash.tempest2.musiclibrary.PlaylistInfo -import misk.testing.MiskTest -import misk.testing.MiskTestModule +import app.cash.tempest2.musiclibrary.testDb +import app.cash.tempest2.testing.logicalDb import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatExceptionOfType import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension import software.amazon.awssdk.enhanced.dynamodb.Expression import software.amazon.awssdk.services.dynamodb.model.AttributeValue import software.amazon.awssdk.services.dynamodb.model.TransactionCanceledException import java.time.Duration -import javax.inject.Inject -import kotlin.test.assertFailsWith -@MiskTest(startService = true) class LogicalDbTransactionTest { - @MiskTestModule - val module = MusicDbTestModule() + @RegisterExtension + @JvmField + val db = testDb() - @Inject lateinit var musicDb: MusicDb - - private val musicTable get() = musicDb.music + private val musicDb by lazy { db.logicalDb() } + private val musicTable by lazy { musicDb.music } @Test fun transactionLoad() { @@ -190,9 +188,10 @@ class LogicalDbTransactionTest { // Introduce a race condition. musicTable.playlistInfo.save(playlistInfoV2) - assertFailsWith { - musicDb.transactionWrite(writeTransaction) - } + assertThatExceptionOfType(TransactionCanceledException::class.java) + .isThrownBy { + musicDb.transactionWrite(writeTransaction) + } } @Test @@ -251,9 +250,10 @@ class LogicalDbTransactionTest { ) .build() - assertFailsWith { - musicDb.transactionWrite(writeTransaction) - } + assertThatExceptionOfType(TransactionCanceledException::class.java) + .isThrownBy { + musicDb.transactionWrite(writeTransaction) + } } private fun ifPlaylistVersionIs(playlist_version: Long): Expression { diff --git a/tempest2/src/test/kotlin/app/cash/tempest2/WritingPagerTest.kt b/tempest2/src/test/kotlin/app/cash/tempest2/WritingPagerTest.kt index 057d2087d..82aa22fd3 100644 --- a/tempest2/src/test/kotlin/app/cash/tempest2/WritingPagerTest.kt +++ b/tempest2/src/test/kotlin/app/cash/tempest2/WritingPagerTest.kt @@ -19,27 +19,25 @@ package app.cash.tempest2 import app.cash.tempest.musiclibrary.THE_WALL import app.cash.tempest2.musiclibrary.AlbumTrack import app.cash.tempest2.musiclibrary.MusicDb -import app.cash.tempest2.musiclibrary.MusicDbTestModule import app.cash.tempest2.musiclibrary.MusicTable import app.cash.tempest2.musiclibrary.PlaylistInfo import app.cash.tempest2.musiclibrary.givenAlbums -import misk.testing.MiskTest -import misk.testing.MiskTestModule +import app.cash.tempest2.musiclibrary.testDb +import app.cash.tempest2.testing.logicalDb import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension import software.amazon.awssdk.enhanced.dynamodb.Expression import software.amazon.awssdk.services.dynamodb.model.AttributeValue -import javax.inject.Inject -@MiskTest(startService = true) class WritingPagerTest { - @MiskTestModule - val module = MusicDbTestModule() + @RegisterExtension + @JvmField + val db = testDb() - @Inject lateinit var musicDb: MusicDb - - private val musicTable get() = musicDb.music + private val musicDb by lazy { db.logicalDb() } + private val musicTable by lazy { musicDb.music } @Test fun write() { diff --git a/tempest2/src/test/kotlin/app/cash/tempest2/internal/SchemaTest.kt b/tempest2/src/test/kotlin/app/cash/tempest2/internal/SchemaTest.kt index bfb8ebcfc..7529b36af 100644 --- a/tempest2/src/test/kotlin/app/cash/tempest2/internal/SchemaTest.kt +++ b/tempest2/src/test/kotlin/app/cash/tempest2/internal/SchemaTest.kt @@ -20,21 +20,20 @@ import app.cash.tempest2.Attribute import app.cash.tempest2.ForIndex import app.cash.tempest2.musiclibrary.AlbumInfo import app.cash.tempest2.musiclibrary.MusicDb -import app.cash.tempest2.musiclibrary.MusicDbTestModule -import misk.testing.MiskTest -import misk.testing.MiskTestModule +import app.cash.tempest2.musiclibrary.testDb +import app.cash.tempest2.testing.logicalDb import org.assertj.core.api.Assertions.assertThatIllegalArgumentException import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension import java.time.LocalDate -import javax.inject.Inject -@MiskTest(startService = true) class SchemaTest { - @MiskTestModule - val module = MusicDbTestModule() + @RegisterExtension + @JvmField + val db = testDb() - @Inject lateinit var musicDb: MusicDb + private val musicDb by lazy { db.logicalDb() } @Test fun badKeyType() { diff --git a/tempest2/src/test/kotlin/app/cash/tempest2/interop/JavaInteropTest.kt b/tempest2/src/test/kotlin/app/cash/tempest2/interop/JavaInteropTest.kt index b47d176a8..ad0953cc9 100644 --- a/tempest2/src/test/kotlin/app/cash/tempest2/interop/JavaInteropTest.kt +++ b/tempest2/src/test/kotlin/app/cash/tempest2/interop/JavaInteropTest.kt @@ -16,23 +16,20 @@ package app.cash.tempest2.interop +import app.cash.tempest2.testing.logicalDb import app.cash.tempest2.urlshortener.java.Alias import app.cash.tempest2.urlshortener.java.AliasDb -import app.cash.tempest2.urlshortener.java.AliasTable -import misk.testing.MiskTest -import misk.testing.MiskTestModule import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test -import javax.inject.Inject +import org.junit.jupiter.api.extension.RegisterExtension -@MiskTest(startService = true) class JavaInteropTest { - @MiskTestModule - val module = InteropTestModule() + @RegisterExtension + @JvmField + val db = InteropTestUtils.testDb() - @Inject lateinit var aliasDb: AliasDb - val aliasTable: AliasTable get() = aliasDb.aliasTable() + private val aliasTable by lazy { db.logicalDb().aliasTable() } @Test fun javaLogicalTypeJavaItemType() { diff --git a/tempest2/src/test/kotlin/app/cash/tempest2/musiclibrary/MusicDbTestModule.kt b/tempest2/src/test/kotlin/app/cash/tempest2/musiclibrary/MusicDbTestModule.kt deleted file mode 100644 index 240ff0bdb..000000000 --- a/tempest2/src/test/kotlin/app/cash/tempest2/musiclibrary/MusicDbTestModule.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright 2021 Square Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package app.cash.tempest2.musiclibrary - -import app.cash.tempest2.LogicalDb -import com.google.inject.Provides -import com.google.inject.Singleton -import misk.MiskTestingServiceModule -import misk.aws2.dynamodb.testing.DynamoDbTable -import misk.aws2.dynamodb.testing.InProcessDynamoDbModule -import misk.inject.KAbstractModule -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient -import software.amazon.awssdk.enhanced.dynamodb.model.EnhancedGlobalSecondaryIndex -import software.amazon.awssdk.enhanced.dynamodb.model.EnhancedLocalSecondaryIndex -import software.amazon.awssdk.services.dynamodb.DynamoDbClient -import software.amazon.awssdk.services.dynamodb.model.Projection -import software.amazon.awssdk.services.dynamodb.model.ProvisionedThroughput - -class MusicDbTestModule : KAbstractModule() { - override fun configure() { - install(MiskTestingServiceModule()) - install( - InProcessDynamoDbModule( - DynamoDbTable("music_items", MusicItem::class) { - it.globalSecondaryIndices( - EnhancedGlobalSecondaryIndex.builder() - .indexName("genre_album_index") - .projection( - Projection.builder() - .projectionType("ALL") - .build() - ) - .provisionedThroughput( - ProvisionedThroughput.builder() - .readCapacityUnits(1) - .writeCapacityUnits(1) - .build() - ) - .build(), - EnhancedGlobalSecondaryIndex.builder() - .indexName("artist_album_index") - .projection( - Projection.builder() - .projectionType("ALL") - .build() - ) - .provisionedThroughput( - ProvisionedThroughput.builder() - .readCapacityUnits(1) - .writeCapacityUnits(1) - .build() - ) - .build() - ) - .localSecondaryIndices( - EnhancedLocalSecondaryIndex.create( - "album_track_title_index", - Projection.builder() - .projectionType("ALL") - .build() - ) - ) - } - ) - ) - } - - @Provides - @Singleton - fun provideTestMusicDb(dynamoDbClient: DynamoDbClient): MusicDb { - val enhancedClient = DynamoDbEnhancedClient.builder() - .dynamoDbClient(dynamoDbClient) - .build() - return LogicalDb(enhancedClient) - } -} diff --git a/tempest2/src/test/kotlin/app/cash/tempest2/musiclibrary/TestUtils.kt b/tempest2/src/test/kotlin/app/cash/tempest2/musiclibrary/TestUtils.kt index da3aa9f16..20cbe600e 100644 --- a/tempest2/src/test/kotlin/app/cash/tempest2/musiclibrary/TestUtils.kt +++ b/tempest2/src/test/kotlin/app/cash/tempest2/musiclibrary/TestUtils.kt @@ -18,6 +18,60 @@ package app.cash.tempest2.musiclibrary import app.cash.tempest.musiclibrary.Album import app.cash.tempest2.Page +import app.cash.tempest2.testing.JvmDynamoDbServer +import app.cash.tempest2.testing.TestDynamoDb +import app.cash.tempest2.testing.TestTable +import software.amazon.awssdk.enhanced.dynamodb.model.EnhancedGlobalSecondaryIndex +import software.amazon.awssdk.enhanced.dynamodb.model.EnhancedLocalSecondaryIndex +import software.amazon.awssdk.services.dynamodb.model.Projection +import software.amazon.awssdk.services.dynamodb.model.ProvisionedThroughput + +fun testDb() = TestDynamoDb.Builder(JvmDynamoDbServer) + .addTable( + TestTable.create("music_items") { + it.toBuilder() + .globalSecondaryIndices( + EnhancedGlobalSecondaryIndex.builder() + .indexName("genre_album_index") + .projection( + Projection.builder() + .projectionType("ALL") + .build() + ) + .provisionedThroughput( + ProvisionedThroughput.builder() + .readCapacityUnits(1) + .writeCapacityUnits(1) + .build() + ) + .build(), + EnhancedGlobalSecondaryIndex.builder() + .indexName("artist_album_index") + .projection( + Projection.builder() + .projectionType("ALL") + .build() + ) + .provisionedThroughput( + ProvisionedThroughput.builder() + .readCapacityUnits(1) + .writeCapacityUnits(1) + .build() + ) + .build() + ) + .localSecondaryIndices( + EnhancedLocalSecondaryIndex.create( + "album_track_title_index", + Projection.builder() + .projectionType("ALL") + .build() + ) + ) + .build() + } + ) + .build() val Page<*, AlbumTrack>.trackTitles: List get() = contents.map { it.track_title } diff --git a/test-utils/gradle.properties b/test-utils/gradle.properties deleted file mode 100644 index c05e4873d..000000000 --- a/test-utils/gradle.properties +++ /dev/null @@ -1,4 +0,0 @@ -POM_ARTIFACT_ID=test-utils -POM_NAME=test-utils -POM_DESCRIPTION=test-utils -POM_PACKAGING=jar From cc2b87ec2605d9fd8bcc49e70b5475db4291ebc4 Mon Sep 17 00:00:00 2001 From: Zhixuan Lai Date: Sun, 7 Mar 2021 19:03:59 -0800 Subject: [PATCH 2/2] Address review comments --- .../app/cash/tempest/testing/Containers.kt | 1 + .../tempest/testing/DockerDynamoDbServer.kt | 18 +++--- .../app/cash/tempest/testing/ExampleTest.kt | 2 +- .../DefaultTestDynamoDbClient.kt | 9 ++- .../tempest/testing/{ => internal}/Logger.kt | 2 +- .../testing/{ => internal}/TestUtils.kt | 58 +++++++++---------- .../app/cash/tempest/testing/TestDynamoDb.kt | 15 ++++- .../app/cash/tempest/testing/ExampleTest.kt | 2 +- .../app/cash/tempest/testing/TestDynamoDb.kt | 15 ++++- .../app/cash/tempest/testing/ExampleTest.kt | 2 +- .../cash/tempest/testing/JvmDynamoDbServer.kt | 13 +++-- .../tempest/testing/TestDynamoDbServer.kt | 4 ++ .../tempest/interop/InteropTestUtils.java | 2 +- .../cash/tempest/musiclibrary/TestUtils.kt | 2 +- tempest2-testing-docker/gradle.properties | 6 +- .../app/cash/tempest2/testing/Containers.kt | 1 + .../tempest2/testing/DockerDynamoDbServer.kt | 18 +++--- .../app/cash/tempest2/testing/ExampleTest.kt | 2 +- tempest2-testing-internal/gradle.properties | 6 +- .../DefaultTestDynamoDbClient.kt | 9 ++- .../tempest2/testing/{ => internal}/Logger.kt | 2 +- .../testing/{ => internal}/TestUtils.kt | 57 +++++++++--------- tempest2-testing-junit4/gradle.properties | 6 +- .../app/cash/tempest2/testing/TestDynamoDb.kt | 15 ++++- .../app/cash/tempest2/testing/ExampleTest.kt | 2 +- tempest2-testing-junit5/gradle.properties | 6 +- .../app/cash/tempest2/testing/TestDynamoDb.kt | 15 ++++- .../app/cash/tempest2/testing/ExampleTest.kt | 2 +- tempest2-testing-jvm/gradle.properties | 6 +- .../tempest2/testing/JvmDynamoDbServer.kt | 13 +++-- tempest2-testing/gradle.properties | 6 +- .../tempest2/testing/TestDynamoDbClient.kt | 8 ++- .../tempest2/testing/TestDynamoDbServer.kt | 4 ++ .../tempest2/interop/InteropTestUtils.java | 2 +- .../cash/tempest2/musiclibrary/TestUtils.kt | 2 +- 35 files changed, 201 insertions(+), 132 deletions(-) rename tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/{ => internal}/DefaultTestDynamoDbClient.kt (87%) rename tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/{ => internal}/Logger.kt (98%) rename tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/{ => internal}/TestUtils.kt (66%) rename tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/{ => internal}/DefaultTestDynamoDbClient.kt (88%) rename tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/{ => internal}/Logger.kt (98%) rename tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/{ => internal}/TestUtils.kt (64%) diff --git a/tempest-testing-docker/src/main/kotlin/app/cash/tempest/testing/Containers.kt b/tempest-testing-docker/src/main/kotlin/app/cash/tempest/testing/Containers.kt index 27c526df7..1c45bb4a0 100644 --- a/tempest-testing-docker/src/main/kotlin/app/cash/tempest/testing/Containers.kt +++ b/tempest-testing-docker/src/main/kotlin/app/cash/tempest/testing/Containers.kt @@ -1,5 +1,6 @@ package app.cash.tempest.testing +import app.cash.tempest.testing.internal.getLogger import com.github.dockerjava.api.DockerClient import com.github.dockerjava.api.command.CreateContainerCmd import com.github.dockerjava.api.exception.NotFoundException diff --git a/tempest-testing-docker/src/main/kotlin/app/cash/tempest/testing/DockerDynamoDbServer.kt b/tempest-testing-docker/src/main/kotlin/app/cash/tempest/testing/DockerDynamoDbServer.kt index 9b9ef3ea3..f34bc33b8 100644 --- a/tempest-testing-docker/src/main/kotlin/app/cash/tempest/testing/DockerDynamoDbServer.kt +++ b/tempest-testing-docker/src/main/kotlin/app/cash/tempest/testing/DockerDynamoDbServer.kt @@ -16,23 +16,23 @@ package app.cash.tempest.testing +import app.cash.tempest.testing.internal.connect import com.amazonaws.services.dynamodbv2.model.AmazonDynamoDBException import com.github.dockerjava.api.model.ExposedPort import com.github.dockerjava.api.model.Ports import com.google.common.util.concurrent.AbstractIdleService -object DockerDynamoDbServer : AbstractIdleService(), TestDynamoDbServer { +class DockerDynamoDbServer private constructor( + override val port: Int +) : AbstractIdleService(), TestDynamoDbServer { - private val pid = ProcessHandle.current().pid() - override val id = "docker-dynamodb-local-$pid" - - override val port = TestUtils.port + override val id = "tempest-docker-dynamodb-local-$port" override fun startUp() { composer.start() // Temporary client to block until the container is running - val client = TestUtils.connect() + val client = connect(port) while (true) { try { client.deleteTable("not a table") @@ -56,7 +56,7 @@ object DockerDynamoDbServer : AbstractIdleService(), TestDynamoDbServer { // DynamoDB Local listens on port 8000 by default. val exposedClientPort = ExposedPort.tcp(8000) val portBindings = Ports() - portBindings.bind(exposedClientPort, Ports.Binding.bindPort(TestUtils.port)) + portBindings.bind(exposedClientPort, Ports.Binding.bindPort(port)) withImage("amazon/dynamodb-local") .withName(id) .withExposedPorts(exposedClientPort) @@ -64,4 +64,8 @@ object DockerDynamoDbServer : AbstractIdleService(), TestDynamoDbServer { .withPortBindings(portBindings) } ) + + object Factory : TestDynamoDbServer.Factory { + override fun create(port: Int) = DockerDynamoDbServer(port) + } } diff --git a/tempest-testing-docker/src/test/kotlin/app/cash/tempest/testing/ExampleTest.kt b/tempest-testing-docker/src/test/kotlin/app/cash/tempest/testing/ExampleTest.kt index 761271479..9742d7486 100644 --- a/tempest-testing-docker/src/test/kotlin/app/cash/tempest/testing/ExampleTest.kt +++ b/tempest-testing-docker/src/test/kotlin/app/cash/tempest/testing/ExampleTest.kt @@ -45,6 +45,6 @@ class ExampleTest { } } -fun testDb() = TestDynamoDb.Builder(DockerDynamoDbServer) +fun testDb() = TestDynamoDb.Builder(DockerDynamoDbServer.Factory) .addTable(TestTable.create()) .build() diff --git a/tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/DefaultTestDynamoDbClient.kt b/tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/internal/DefaultTestDynamoDbClient.kt similarity index 87% rename from tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/DefaultTestDynamoDbClient.kt rename to tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/internal/DefaultTestDynamoDbClient.kt index 328eb3fe8..514eb9358 100644 --- a/tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/DefaultTestDynamoDbClient.kt +++ b/tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/internal/DefaultTestDynamoDbClient.kt @@ -14,14 +14,17 @@ * limitations under the License. */ -package app.cash.tempest.testing +package app.cash.tempest.testing.internal +import app.cash.tempest.testing.TestDynamoDbClient +import app.cash.tempest.testing.TestTable import com.amazonaws.services.dynamodbv2.AmazonDynamoDB import com.amazonaws.services.dynamodbv2.AmazonDynamoDBStreams import com.google.common.util.concurrent.AbstractIdleService class DefaultTestDynamoDbClient( override val tables: List, + private val port: Int, ) : AbstractIdleService(), TestDynamoDbClient { override val dynamoDb: AmazonDynamoDB @@ -33,8 +36,8 @@ class DefaultTestDynamoDbClient( private var _dynamoDbStreams: AmazonDynamoDBStreams? = null override fun startUp() { - _dynamoDb = TestUtils.connect() - _dynamoDbStreams = TestUtils.connectToStreams() + _dynamoDb = connect(port) + _dynamoDbStreams = connectToStreams(port) // Cleans up the tables before each run. for (tableName in dynamoDb.listTables().tableNames) { diff --git a/tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/Logger.kt b/tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/internal/Logger.kt similarity index 98% rename from tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/Logger.kt rename to tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/internal/Logger.kt index 03c7965b0..5299a1d3c 100644 --- a/tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/Logger.kt +++ b/tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/internal/Logger.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package app.cash.tempest.testing +package app.cash.tempest.testing.internal import mu.KLogger import mu.KotlinLogging diff --git a/tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/TestUtils.kt b/tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/internal/TestUtils.kt similarity index 66% rename from tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/TestUtils.kt rename to tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/internal/TestUtils.kt index 5503ebf9d..1e5fb3f2a 100644 --- a/tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/TestUtils.kt +++ b/tempest-testing-internal/src/main/kotlin/app/cash/tempest/testing/internal/TestUtils.kt @@ -14,9 +14,9 @@ * limitations under the License. */ -package app.cash.tempest.testing +package app.cash.tempest.testing.internal -import com.amazonaws.auth.AWSCredentialsProvider +import app.cash.tempest.testing.TestTable import com.amazonaws.auth.AWSStaticCredentialsProvider import com.amazonaws.auth.BasicAWSCredentials import com.amazonaws.client.builder.AwsClientBuilder @@ -29,40 +29,36 @@ import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMapper import com.amazonaws.services.dynamodbv2.document.DynamoDB import com.amazonaws.services.dynamodbv2.model.ProvisionedThroughput -object TestUtils { - val port: Int = pickPort() +fun pickRandomPort(): Int { + // There is a tolerable chance of flaky tests caused by port collision. + return 58000 + (ProcessHandle.current().pid() % 1000).toInt() +} - private val url = "http://localhost:$port" +fun connect(port: Int): AmazonDynamoDB { + return AmazonDynamoDBClientBuilder.standard() + // The values that you supply for the AWS access key and the Region are only used to name + // the database file. + .withCredentials(AWS_CREDENTIALS_PROVIDER) + .withEndpointConfiguration(endpointConfiguration(port)) + .build() +} - private val awsCredentialsProvider: AWSCredentialsProvider = AWSStaticCredentialsProvider( - BasicAWSCredentials("key", "secret") - ) +fun connectToStreams(port: Int): AmazonDynamoDBStreams { + return AmazonDynamoDBStreamsClientBuilder.standard() + .withCredentials(AWS_CREDENTIALS_PROVIDER) + .withEndpointConfiguration(endpointConfiguration(port)) + .build() +} + +private val AWS_CREDENTIALS_PROVIDER = AWSStaticCredentialsProvider( + BasicAWSCredentials("key", "secret") +) - private val endpointConfiguration = AwsClientBuilder.EndpointConfiguration( - url, +private fun endpointConfiguration(port: Int): AwsClientBuilder.EndpointConfiguration { + return AwsClientBuilder.EndpointConfiguration( + "http://localhost:$port", Regions.US_WEST_2.toString() ) - - fun connect(): AmazonDynamoDB { - return AmazonDynamoDBClientBuilder.standard() - // The values that you supply for the AWS access key and the Region are only used to name - // the database file. - .withCredentials(awsCredentialsProvider) - .withEndpointConfiguration(endpointConfiguration) - .build() - } - - fun connectToStreams(): AmazonDynamoDBStreams { - return AmazonDynamoDBStreamsClientBuilder.standard() - .withCredentials(awsCredentialsProvider) - .withEndpointConfiguration(endpointConfiguration) - .build() - } - - private fun pickPort(): Int { - // There is a tolerable chance of flaky tests caused by port collision. - return 58000 + (ProcessHandle.current().pid() % 1000).toInt() - } } fun AmazonDynamoDB.createTable( diff --git a/tempest-testing-junit4/src/main/kotlin/app/cash/tempest/testing/TestDynamoDb.kt b/tempest-testing-junit4/src/main/kotlin/app/cash/tempest/testing/TestDynamoDb.kt index 6c36a89b4..7d404ed4f 100644 --- a/tempest-testing-junit4/src/main/kotlin/app/cash/tempest/testing/TestDynamoDb.kt +++ b/tempest-testing-junit4/src/main/kotlin/app/cash/tempest/testing/TestDynamoDb.kt @@ -16,9 +16,16 @@ package app.cash.tempest.testing +import app.cash.tempest.testing.internal.DefaultTestDynamoDbClient +import app.cash.tempest.testing.internal.getLogger +import app.cash.tempest.testing.internal.pickRandomPort import org.junit.rules.ExternalResource import java.util.concurrent.ConcurrentHashMap +/** + * This JUnit rule spins up a DynamoDB server in tests. It keeps the server running until the + * process exits and shares it across tests. + */ class TestDynamoDb private constructor( private val client: TestDynamoDbClient, private val server: TestDynamoDbServer @@ -54,7 +61,7 @@ class TestDynamoDb private constructor( } class Builder( - private val server: TestDynamoDbServer + private val serverFactory: TestDynamoDbServer.Factory<*> ) { private val tables = mutableListOf() @@ -66,10 +73,14 @@ class TestDynamoDb private constructor( this.tables.addAll(tables) } - fun build() = TestDynamoDb(DefaultTestDynamoDbClient(tables), server) + fun build() = TestDynamoDb( + DefaultTestDynamoDbClient(tables, DEFAULT_PORT), + serverFactory.create(DEFAULT_PORT) + ) } companion object { + private val DEFAULT_PORT = pickRandomPort() private val runningServers = ConcurrentHashMap.newKeySet() private val log = getLogger() } diff --git a/tempest-testing-junit4/src/test/kotlin/app/cash/tempest/testing/ExampleTest.kt b/tempest-testing-junit4/src/test/kotlin/app/cash/tempest/testing/ExampleTest.kt index a457bb7dd..7097dc14f 100644 --- a/tempest-testing-junit4/src/test/kotlin/app/cash/tempest/testing/ExampleTest.kt +++ b/tempest-testing-junit4/src/test/kotlin/app/cash/tempest/testing/ExampleTest.kt @@ -44,6 +44,6 @@ class ExampleTest { } } -fun testDb() = TestDynamoDb.Builder(JvmDynamoDbServer) +fun testDb() = TestDynamoDb.Builder(JvmDynamoDbServer.Factory) .addTable(TestTable.create()) .build() diff --git a/tempest-testing-junit5/src/main/kotlin/app/cash/tempest/testing/TestDynamoDb.kt b/tempest-testing-junit5/src/main/kotlin/app/cash/tempest/testing/TestDynamoDb.kt index d259e617c..26565f33d 100644 --- a/tempest-testing-junit5/src/main/kotlin/app/cash/tempest/testing/TestDynamoDb.kt +++ b/tempest-testing-junit5/src/main/kotlin/app/cash/tempest/testing/TestDynamoDb.kt @@ -16,11 +16,18 @@ package app.cash.tempest.testing +import app.cash.tempest.testing.internal.DefaultTestDynamoDbClient +import app.cash.tempest.testing.internal.getLogger +import app.cash.tempest.testing.internal.pickRandomPort import org.junit.jupiter.api.extension.AfterEachCallback import org.junit.jupiter.api.extension.BeforeEachCallback import org.junit.jupiter.api.extension.ExtensionContext import java.util.concurrent.ConcurrentHashMap +/** + * This JUnit extension spins up a DynamoDB server in tests. It keeps the server running until the + * process exits and shares it across tests. + */ class TestDynamoDb private constructor( private val client: TestDynamoDbClient, private val server: TestDynamoDbServer, @@ -56,7 +63,7 @@ class TestDynamoDb private constructor( } class Builder( - private val server: TestDynamoDbServer + private val serverFactory: TestDynamoDbServer.Factory<*> ) { private val tables = mutableListOf() @@ -68,10 +75,14 @@ class TestDynamoDb private constructor( this.tables.addAll(tables) } - fun build() = TestDynamoDb(DefaultTestDynamoDbClient(tables), server) + fun build() = TestDynamoDb( + DefaultTestDynamoDbClient(tables, DEFAULT_PORT), + serverFactory.create(DEFAULT_PORT) + ) } companion object { + private val DEFAULT_PORT = pickRandomPort() private val runningServers = ConcurrentHashMap.newKeySet() private val log = getLogger() } diff --git a/tempest-testing-junit5/src/test/kotlin/app/cash/tempest/testing/ExampleTest.kt b/tempest-testing-junit5/src/test/kotlin/app/cash/tempest/testing/ExampleTest.kt index 6d2a9cf70..5a55373ef 100644 --- a/tempest-testing-junit5/src/test/kotlin/app/cash/tempest/testing/ExampleTest.kt +++ b/tempest-testing-junit5/src/test/kotlin/app/cash/tempest/testing/ExampleTest.kt @@ -45,6 +45,6 @@ class ExampleTest { } } -fun testDb() = TestDynamoDb.Builder(JvmDynamoDbServer) +fun testDb() = TestDynamoDb.Builder(JvmDynamoDbServer.Factory) .addTable(TestTable.create()) .build() diff --git a/tempest-testing-jvm/src/main/kotlin/app/cash/tempest/testing/JvmDynamoDbServer.kt b/tempest-testing-jvm/src/main/kotlin/app/cash/tempest/testing/JvmDynamoDbServer.kt index 01015db8a..3692fe1a7 100644 --- a/tempest-testing-jvm/src/main/kotlin/app/cash/tempest/testing/JvmDynamoDbServer.kt +++ b/tempest-testing-jvm/src/main/kotlin/app/cash/tempest/testing/JvmDynamoDbServer.kt @@ -21,12 +21,11 @@ import com.amazonaws.services.dynamodbv2.local.server.DynamoDBProxyServer import com.google.common.util.concurrent.AbstractIdleService import java.io.File -object JvmDynamoDbServer : AbstractIdleService(), TestDynamoDbServer { +class JvmDynamoDbServer private constructor( + override val port: Int +) : AbstractIdleService(), TestDynamoDbServer { - private val pid = ProcessHandle.current().pid() - override val id = "jvm-dynamodb-local-$pid" - - override val port = TestUtils.port + override val id = "tempest-jvm-dynamodb-local-$port" private lateinit var server: DynamoDBProxyServer @@ -85,4 +84,8 @@ object JvmDynamoDbServer : AbstractIdleService(), TestDynamoDbServer { override fun shutDown() { server.stop() } + + object Factory : TestDynamoDbServer.Factory { + override fun create(port: Int) = JvmDynamoDbServer(port) + } } diff --git a/tempest-testing/src/main/kotlin/app/cash/tempest/testing/TestDynamoDbServer.kt b/tempest-testing/src/main/kotlin/app/cash/tempest/testing/TestDynamoDbServer.kt index 9e80ea6e7..45d39f321 100644 --- a/tempest-testing/src/main/kotlin/app/cash/tempest/testing/TestDynamoDbServer.kt +++ b/tempest-testing/src/main/kotlin/app/cash/tempest/testing/TestDynamoDbServer.kt @@ -24,4 +24,8 @@ import com.google.common.util.concurrent.Service interface TestDynamoDbServer : Service { val id: String val port: Int + + interface Factory { + fun create(port: Int): T + } } diff --git a/tempest/src/test/java/app/cash/tempest/interop/InteropTestUtils.java b/tempest/src/test/java/app/cash/tempest/interop/InteropTestUtils.java index bd9c9967e..13463b199 100644 --- a/tempest/src/test/java/app/cash/tempest/interop/InteropTestUtils.java +++ b/tempest/src/test/java/app/cash/tempest/interop/InteropTestUtils.java @@ -24,7 +24,7 @@ public class InteropTestUtils { public static TestDynamoDb testDb() { - return new TestDynamoDb.Builder(JvmDynamoDbServer.INSTANCE) + return new TestDynamoDb.Builder(JvmDynamoDbServer.Factory.INSTANCE) .addTable(TestTable.create(AliasItem.class)) .build(); } diff --git a/tempest/src/test/kotlin/app/cash/tempest/musiclibrary/TestUtils.kt b/tempest/src/test/kotlin/app/cash/tempest/musiclibrary/TestUtils.kt index 562060381..105ea2381 100644 --- a/tempest/src/test/kotlin/app/cash/tempest/musiclibrary/TestUtils.kt +++ b/tempest/src/test/kotlin/app/cash/tempest/musiclibrary/TestUtils.kt @@ -24,7 +24,7 @@ import app.cash.tempest.testing.TestTable import com.amazonaws.services.dynamodbv2.model.Projection import com.amazonaws.services.dynamodbv2.model.ProjectionType -fun testDb() = TestDynamoDb.Builder(JvmDynamoDbServer) +fun testDb() = TestDynamoDb.Builder(JvmDynamoDbServer.Factory) .addTable( TestTable.create { for (gsi in it.globalSecondaryIndexes) { diff --git a/tempest2-testing-docker/gradle.properties b/tempest2-testing-docker/gradle.properties index 2ad7829b7..0d947fe7d 100644 --- a/tempest2-testing-docker/gradle.properties +++ b/tempest2-testing-docker/gradle.properties @@ -1,4 +1,4 @@ -POM_ARTIFACT_ID=tempest-testing-docker -POM_NAME=tempest-testing-docker -POM_DESCRIPTION=tempest-testing-docker +POM_ARTIFACT_ID=tempest2-testing-docker +POM_NAME=tempest2-testing-docker +POM_DESCRIPTION=tempest2-testing-docker POM_PACKAGING=jar diff --git a/tempest2-testing-docker/src/main/kotlin/app/cash/tempest2/testing/Containers.kt b/tempest2-testing-docker/src/main/kotlin/app/cash/tempest2/testing/Containers.kt index 3217b0ba1..b621c4ef0 100644 --- a/tempest2-testing-docker/src/main/kotlin/app/cash/tempest2/testing/Containers.kt +++ b/tempest2-testing-docker/src/main/kotlin/app/cash/tempest2/testing/Containers.kt @@ -1,5 +1,6 @@ package app.cash.tempest2.testing +import app.cash.tempest2.testing.internal.getLogger import com.github.dockerjava.api.DockerClient import com.github.dockerjava.api.command.CreateContainerCmd import com.github.dockerjava.api.exception.NotFoundException diff --git a/tempest2-testing-docker/src/main/kotlin/app/cash/tempest2/testing/DockerDynamoDbServer.kt b/tempest2-testing-docker/src/main/kotlin/app/cash/tempest2/testing/DockerDynamoDbServer.kt index 1e90a4700..59d408263 100644 --- a/tempest2-testing-docker/src/main/kotlin/app/cash/tempest2/testing/DockerDynamoDbServer.kt +++ b/tempest2-testing-docker/src/main/kotlin/app/cash/tempest2/testing/DockerDynamoDbServer.kt @@ -16,24 +16,24 @@ package app.cash.tempest2.testing +import app.cash.tempest2.testing.internal.connect import com.github.dockerjava.api.model.ExposedPort import com.github.dockerjava.api.model.Ports import com.google.common.util.concurrent.AbstractIdleService import software.amazon.awssdk.services.dynamodb.model.DeleteTableRequest import software.amazon.awssdk.services.dynamodb.model.DynamoDbException -object DockerDynamoDbServer : AbstractIdleService(), TestDynamoDbServer { +class DockerDynamoDbServer private constructor( + override val port: Int +) : AbstractIdleService(), TestDynamoDbServer { - private val pid = ProcessHandle.current().pid() - override val id = "tempest2-docker-dynamodb-local-$pid" - - override val port = TestUtils.port + override val id = "tempest2-docker-dynamodb-local-$port" override fun startUp() { composer.start() // Temporary client to block until the container is running - val client = TestUtils.connect() + val client = connect(port) while (true) { try { client.deleteTable(DeleteTableRequest.builder().tableName("not a table").build()) @@ -57,7 +57,7 @@ object DockerDynamoDbServer : AbstractIdleService(), TestDynamoDbServer { // DynamoDB Local listens on port 8000 by default. val exposedClientPort = ExposedPort.tcp(8000) val portBindings = Ports() - portBindings.bind(exposedClientPort, Ports.Binding.bindPort(TestUtils.port)) + portBindings.bind(exposedClientPort, Ports.Binding.bindPort(port)) withImage("amazon/dynamodb-local") .withName(id) .withExposedPorts(exposedClientPort) @@ -65,4 +65,8 @@ object DockerDynamoDbServer : AbstractIdleService(), TestDynamoDbServer { .withPortBindings(portBindings) } ) + + object Factory : TestDynamoDbServer.Factory { + override fun create(port: Int) = DockerDynamoDbServer(port) + } } diff --git a/tempest2-testing-docker/src/test/kotlin/app/cash/tempest2/testing/ExampleTest.kt b/tempest2-testing-docker/src/test/kotlin/app/cash/tempest2/testing/ExampleTest.kt index 853a7f9a3..7fcb9fd60 100644 --- a/tempest2-testing-docker/src/test/kotlin/app/cash/tempest2/testing/ExampleTest.kt +++ b/tempest2-testing-docker/src/test/kotlin/app/cash/tempest2/testing/ExampleTest.kt @@ -45,6 +45,6 @@ class ExampleTest { } } -fun testDb() = TestDynamoDb.Builder(DockerDynamoDbServer) +fun testDb() = TestDynamoDb.Builder(DockerDynamoDbServer.Factory) .addTable(TestTable.create("alias_items")) .build() diff --git a/tempest2-testing-internal/gradle.properties b/tempest2-testing-internal/gradle.properties index 4e952f07c..d75f4dadc 100644 --- a/tempest2-testing-internal/gradle.properties +++ b/tempest2-testing-internal/gradle.properties @@ -1,4 +1,4 @@ -POM_ARTIFACT_ID=tempest-testing-internal -POM_NAME=tempest-testing-internal -POM_DESCRIPTION=tempest-testing-internal +POM_ARTIFACT_ID=tempest2-testing-internal +POM_NAME=tempest2-testing-internal +POM_DESCRIPTION=tempest2-testing-internal POM_PACKAGING=jar diff --git a/tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/DefaultTestDynamoDbClient.kt b/tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/internal/DefaultTestDynamoDbClient.kt similarity index 88% rename from tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/DefaultTestDynamoDbClient.kt rename to tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/internal/DefaultTestDynamoDbClient.kt index 591bb86bd..cb382cbdf 100644 --- a/tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/DefaultTestDynamoDbClient.kt +++ b/tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/internal/DefaultTestDynamoDbClient.kt @@ -14,8 +14,10 @@ * limitations under the License. */ -package app.cash.tempest2.testing +package app.cash.tempest2.testing.internal +import app.cash.tempest2.testing.TestDynamoDbClient +import app.cash.tempest2.testing.TestTable import com.google.common.util.concurrent.AbstractIdleService import software.amazon.awssdk.services.dynamodb.DynamoDbClient import software.amazon.awssdk.services.dynamodb.model.DeleteTableRequest @@ -23,6 +25,7 @@ import software.amazon.awssdk.services.dynamodb.streams.DynamoDbStreamsClient class DefaultTestDynamoDbClient( override val tables: List, + private val port: Int, ) : AbstractIdleService(), TestDynamoDbClient { override val dynamoDb: DynamoDbClient @@ -34,8 +37,8 @@ class DefaultTestDynamoDbClient( private var _dynamoDbStreams: DynamoDbStreamsClient? = null override fun startUp() { - _dynamoDb = TestUtils.connect() - _dynamoDbStreams = TestUtils.connectToStreams() + _dynamoDb = connect(port) + _dynamoDbStreams = connectToStreams(port) // Cleans up the tables before each run. for (tableName in dynamoDb.listTables().tableNames()) { diff --git a/tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/Logger.kt b/tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/internal/Logger.kt similarity index 98% rename from tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/Logger.kt rename to tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/internal/Logger.kt index 26a9daf64..6ccf72507 100644 --- a/tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/Logger.kt +++ b/tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/internal/Logger.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package app.cash.tempest2.testing +package app.cash.tempest2.testing.internal import mu.KLogger import mu.KotlinLogging diff --git a/tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/TestUtils.kt b/tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/internal/TestUtils.kt similarity index 64% rename from tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/TestUtils.kt rename to tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/internal/TestUtils.kt index ec973b190..8692a351a 100644 --- a/tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/TestUtils.kt +++ b/tempest2-testing-internal/src/main/kotlin/app/cash/tempest2/testing/internal/TestUtils.kt @@ -14,8 +14,9 @@ * limitations under the License. */ -package app.cash.tempest2.testing +package app.cash.tempest2.testing.internal +import app.cash.tempest2.testing.TestTable import software.amazon.awssdk.auth.credentials.AwsBasicCredentials import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient @@ -27,39 +28,33 @@ import software.amazon.awssdk.services.dynamodb.model.ProvisionedThroughput import software.amazon.awssdk.services.dynamodb.streams.DynamoDbStreamsClient import java.net.URI -object TestUtils { - val port: Int = pickPort() - - private val url = "http://localhost:$port" - - private val awsCredentialsProvider: StaticCredentialsProvider = StaticCredentialsProvider.create( - AwsBasicCredentials.create("key", "secret") - ) +fun pickRandomPort(): Int { + // There is a tolerable chance of flaky tests caused by port collision. + return 58000 + (ProcessHandle.current().pid() % 1000).toInt() +} - fun connect(): DynamoDbClient { - return DynamoDbClient.builder() - // The values that you supply for the AWS access key and the Region are only used to name - // the database file. - .credentialsProvider(awsCredentialsProvider) - .region(Region.US_WEST_2) - .endpointOverride(URI.create(url)) - .build() - } +private val AWS_CREDENTIALS_PROVIDER = StaticCredentialsProvider.create( + AwsBasicCredentials.create("key", "secret") +) - fun connectToStreams(): DynamoDbStreamsClient { - return DynamoDbStreamsClient.builder() - // The values that you supply for the AWS access key and the Region are only used to name - // the database file. - .credentialsProvider(awsCredentialsProvider) - .region(Region.US_WEST_2) - .endpointOverride(URI.create(url)) - .build() - } +fun connect(port: Int): DynamoDbClient { + return DynamoDbClient.builder() + // The values that you supply for the AWS access key and the Region are only used to name + // the database file. + .credentialsProvider(AWS_CREDENTIALS_PROVIDER) + .region(Region.US_WEST_2) + .endpointOverride(URI.create("http://localhost:$port")) + .build() +} - private fun pickPort(): Int { - // There is a tolerable chance of flaky tests caused by port collision. - return 58000 + (ProcessHandle.current().pid() % 1000).toInt() - } +fun connectToStreams(port: Int): DynamoDbStreamsClient { + return DynamoDbStreamsClient.builder() + // The values that you supply for the AWS access key and the Region are only used to name + // the database file. + .credentialsProvider(AWS_CREDENTIALS_PROVIDER) + .region(Region.US_WEST_2) + .endpointOverride(URI.create("http://localhost:$port")) + .build() } fun DynamoDbClient.createTable( diff --git a/tempest2-testing-junit4/gradle.properties b/tempest2-testing-junit4/gradle.properties index 78c70d3d2..c37fcbef7 100644 --- a/tempest2-testing-junit4/gradle.properties +++ b/tempest2-testing-junit4/gradle.properties @@ -1,4 +1,4 @@ -POM_ARTIFACT_ID=tempest-testing-junit4 -POM_NAME=tempest-testing-junit4 -POM_DESCRIPTION=tempest-testing-junit4 +POM_ARTIFACT_ID=tempest2-testing-junit4 +POM_NAME=tempest2-testing-junit4 +POM_DESCRIPTION=tempest2-testing-junit4 POM_PACKAGING=jar diff --git a/tempest2-testing-junit4/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDb.kt b/tempest2-testing-junit4/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDb.kt index 0c6bc6025..950f20eab 100644 --- a/tempest2-testing-junit4/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDb.kt +++ b/tempest2-testing-junit4/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDb.kt @@ -16,9 +16,16 @@ package app.cash.tempest2.testing +import app.cash.tempest2.testing.internal.DefaultTestDynamoDbClient +import app.cash.tempest2.testing.internal.getLogger +import app.cash.tempest2.testing.internal.pickRandomPort import org.junit.rules.ExternalResource import java.util.concurrent.ConcurrentHashMap +/** + * This JUnit rule spins up a DynamoDB server in tests. It keeps the server running until the + * process exits and shares it across tests. + */ class TestDynamoDb private constructor( private val client: TestDynamoDbClient, private val server: TestDynamoDbServer @@ -54,7 +61,7 @@ class TestDynamoDb private constructor( } class Builder( - private val server: TestDynamoDbServer + private val serverFactory: TestDynamoDbServer.Factory<*> ) { private val tables = mutableListOf() @@ -66,10 +73,14 @@ class TestDynamoDb private constructor( this.tables.addAll(tables) } - fun build() = TestDynamoDb(DefaultTestDynamoDbClient(tables), server) + fun build() = TestDynamoDb( + DefaultTestDynamoDbClient(tables, DEFAULT_PORT), + serverFactory.create(DEFAULT_PORT) + ) } companion object { + private val DEFAULT_PORT = pickRandomPort() private val runningServers = ConcurrentHashMap.newKeySet() private val log = getLogger() } diff --git a/tempest2-testing-junit4/src/test/kotlin/app/cash/tempest2/testing/ExampleTest.kt b/tempest2-testing-junit4/src/test/kotlin/app/cash/tempest2/testing/ExampleTest.kt index 0849e2d9f..251f5499c 100644 --- a/tempest2-testing-junit4/src/test/kotlin/app/cash/tempest2/testing/ExampleTest.kt +++ b/tempest2-testing-junit4/src/test/kotlin/app/cash/tempest2/testing/ExampleTest.kt @@ -44,6 +44,6 @@ class ExampleTest { } } -fun testDb() = TestDynamoDb.Builder(JvmDynamoDbServer) +fun testDb() = TestDynamoDb.Builder(JvmDynamoDbServer.Factory) .addTable(TestTable.create("alias_items")) .build() diff --git a/tempest2-testing-junit5/gradle.properties b/tempest2-testing-junit5/gradle.properties index a91495d5f..5f160c779 100644 --- a/tempest2-testing-junit5/gradle.properties +++ b/tempest2-testing-junit5/gradle.properties @@ -1,4 +1,4 @@ -POM_ARTIFACT_ID=tempest-testing-junit5 -POM_NAME=tempest-testing-junit5 -POM_DESCRIPTION=tempest-testing-junit5 +POM_ARTIFACT_ID=tempest2-testing-junit5 +POM_NAME=tempest2-testing-junit5 +POM_DESCRIPTION=tempest2-testing-junit5 POM_PACKAGING=jar diff --git a/tempest2-testing-junit5/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDb.kt b/tempest2-testing-junit5/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDb.kt index a14942680..14661b8c2 100644 --- a/tempest2-testing-junit5/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDb.kt +++ b/tempest2-testing-junit5/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDb.kt @@ -16,11 +16,18 @@ package app.cash.tempest2.testing +import app.cash.tempest2.testing.internal.DefaultTestDynamoDbClient +import app.cash.tempest2.testing.internal.getLogger +import app.cash.tempest2.testing.internal.pickRandomPort import org.junit.jupiter.api.extension.AfterEachCallback import org.junit.jupiter.api.extension.BeforeEachCallback import org.junit.jupiter.api.extension.ExtensionContext import java.util.concurrent.ConcurrentHashMap +/** + * This JUnit extension spins up a DynamoDB server in tests. It keeps the server running until the + * process exits and shares it across tests. + */ class TestDynamoDb private constructor( private val client: TestDynamoDbClient, private val server: TestDynamoDbServer, @@ -56,7 +63,7 @@ class TestDynamoDb private constructor( } class Builder( - private val server: TestDynamoDbServer + private val serverFactory: TestDynamoDbServer.Factory<*> ) { private val tables = mutableListOf() @@ -68,10 +75,14 @@ class TestDynamoDb private constructor( this.tables.addAll(tables) } - fun build() = TestDynamoDb(DefaultTestDynamoDbClient(tables), server) + fun build() = TestDynamoDb( + DefaultTestDynamoDbClient(tables, DEFAULT_PORT), + serverFactory.create(DEFAULT_PORT) + ) } companion object { + private val DEFAULT_PORT = pickRandomPort() private val runningServers = ConcurrentHashMap.newKeySet() private val log = getLogger() } diff --git a/tempest2-testing-junit5/src/test/kotlin/app/cash/tempest2/testing/ExampleTest.kt b/tempest2-testing-junit5/src/test/kotlin/app/cash/tempest2/testing/ExampleTest.kt index 10bdb230e..4312c1340 100644 --- a/tempest2-testing-junit5/src/test/kotlin/app/cash/tempest2/testing/ExampleTest.kt +++ b/tempest2-testing-junit5/src/test/kotlin/app/cash/tempest2/testing/ExampleTest.kt @@ -45,6 +45,6 @@ class ExampleTest { } } -fun testDb() = TestDynamoDb.Builder(JvmDynamoDbServer) +fun testDb() = TestDynamoDb.Builder(JvmDynamoDbServer.Factory) .addTable(TestTable.create("alias_items")) .build() diff --git a/tempest2-testing-jvm/gradle.properties b/tempest2-testing-jvm/gradle.properties index 6419ba914..f817fdd15 100644 --- a/tempest2-testing-jvm/gradle.properties +++ b/tempest2-testing-jvm/gradle.properties @@ -1,4 +1,4 @@ -POM_ARTIFACT_ID=tempest-testing-jvm -POM_NAME=tempest-testing-jvm -POM_DESCRIPTION=tempest-testing-jvm +POM_ARTIFACT_ID=tempest2-testing-jvm +POM_NAME=tempest2-testing-jvm +POM_DESCRIPTION=tempest2-testing-jvm POM_PACKAGING=jar diff --git a/tempest2-testing-jvm/src/main/kotlin/app/cash/tempest2/testing/JvmDynamoDbServer.kt b/tempest2-testing-jvm/src/main/kotlin/app/cash/tempest2/testing/JvmDynamoDbServer.kt index cea095d80..e7c641655 100644 --- a/tempest2-testing-jvm/src/main/kotlin/app/cash/tempest2/testing/JvmDynamoDbServer.kt +++ b/tempest2-testing-jvm/src/main/kotlin/app/cash/tempest2/testing/JvmDynamoDbServer.kt @@ -21,12 +21,11 @@ import com.amazonaws.services.dynamodbv2.local.server.DynamoDBProxyServer import com.google.common.util.concurrent.AbstractIdleService import java.io.File -object JvmDynamoDbServer : AbstractIdleService(), TestDynamoDbServer { +class JvmDynamoDbServer private constructor( + override val port: Int +) : AbstractIdleService(), TestDynamoDbServer { - private val pid = ProcessHandle.current().pid() - override val id = "tempest2-jvm-dynamodb-local-$pid" - - override val port = TestUtils.port + override val id = "tempest2-jvm-dynamodb-local-$port" private lateinit var server: DynamoDBProxyServer @@ -85,4 +84,8 @@ object JvmDynamoDbServer : AbstractIdleService(), TestDynamoDbServer { override fun shutDown() { server.stop() } + + object Factory : TestDynamoDbServer.Factory { + override fun create(port: Int) = JvmDynamoDbServer(port) + } } diff --git a/tempest2-testing/gradle.properties b/tempest2-testing/gradle.properties index bf8e5c6d8..120456f22 100644 --- a/tempest2-testing/gradle.properties +++ b/tempest2-testing/gradle.properties @@ -1,4 +1,4 @@ -POM_ARTIFACT_ID=tempest-testing -POM_NAME=tempest-testing -POM_DESCRIPTION=tempest-testing +POM_ARTIFACT_ID=tempest2-testing +POM_NAME=tempest2-testing +POM_DESCRIPTION=tempest2-testing POM_PACKAGING=jar diff --git a/tempest2-testing/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDbClient.kt b/tempest2-testing/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDbClient.kt index 44ed24d59..6928c696c 100644 --- a/tempest2-testing/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDbClient.kt +++ b/tempest2-testing/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDbClient.kt @@ -37,6 +37,10 @@ interface TestDynamoDbClient : Service { return logicalDb(type, emptyList()) } + fun logicalDb(type: KClass, vararg extensions: DynamoDbEnhancedClientExtension): DB { + return logicalDb(type, extensions.toList()) + } + fun logicalDb( type: KClass, extensions: List @@ -49,8 +53,8 @@ interface TestDynamoDbClient : Service { } } -inline fun TestDynamoDbClient.logicalDb(): DB { - return logicalDb(DB::class) +inline fun TestDynamoDbClient.logicalDb(vararg extensions: DynamoDbEnhancedClientExtension): DB { + return logicalDb(extensions.toList()) } inline fun TestDynamoDbClient.logicalDb(extensions: List): DB { diff --git a/tempest2-testing/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDbServer.kt b/tempest2-testing/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDbServer.kt index 0c27c9403..3007264c2 100644 --- a/tempest2-testing/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDbServer.kt +++ b/tempest2-testing/src/main/kotlin/app/cash/tempest2/testing/TestDynamoDbServer.kt @@ -24,4 +24,8 @@ import com.google.common.util.concurrent.Service interface TestDynamoDbServer : Service { val id: String val port: Int + + interface Factory { + fun create(port: Int): T + } } diff --git a/tempest2/src/test/java/app/cash/tempest2/interop/InteropTestUtils.java b/tempest2/src/test/java/app/cash/tempest2/interop/InteropTestUtils.java index 0453e526e..00cdfb6c3 100644 --- a/tempest2/src/test/java/app/cash/tempest2/interop/InteropTestUtils.java +++ b/tempest2/src/test/java/app/cash/tempest2/interop/InteropTestUtils.java @@ -24,7 +24,7 @@ public class InteropTestUtils { public static TestDynamoDb testDb() { - return new TestDynamoDb.Builder(JvmDynamoDbServer.INSTANCE) + return new TestDynamoDb.Builder(JvmDynamoDbServer.Factory.INSTANCE) .addTable(TestTable.create("j_alias_items", AliasItem.class)) .build(); } diff --git a/tempest2/src/test/kotlin/app/cash/tempest2/musiclibrary/TestUtils.kt b/tempest2/src/test/kotlin/app/cash/tempest2/musiclibrary/TestUtils.kt index 20cbe600e..fc9b62728 100644 --- a/tempest2/src/test/kotlin/app/cash/tempest2/musiclibrary/TestUtils.kt +++ b/tempest2/src/test/kotlin/app/cash/tempest2/musiclibrary/TestUtils.kt @@ -26,7 +26,7 @@ import software.amazon.awssdk.enhanced.dynamodb.model.EnhancedLocalSecondaryInde import software.amazon.awssdk.services.dynamodb.model.Projection import software.amazon.awssdk.services.dynamodb.model.ProvisionedThroughput -fun testDb() = TestDynamoDb.Builder(JvmDynamoDbServer) +fun testDb() = TestDynamoDb.Builder(JvmDynamoDbServer.Factory) .addTable( TestTable.create("music_items") { it.toBuilder()