diff --git a/experimental/src/main/scala/org/testcontainers/testcontainers4s/containers/BrowserWebDriverContainer.scala b/experimental/src/main/scala/org/testcontainers/testcontainers4s/containers/BrowserWebDriverContainer.scala new file mode 100644 index 00000000..85c50333 --- /dev/null +++ b/experimental/src/main/scala/org/testcontainers/testcontainers4s/containers/BrowserWebDriverContainer.scala @@ -0,0 +1,60 @@ +package org.testcontainers.testcontainers4s.containers + +import java.io.File +import java.net.URL +import java.util.Optional + +import org.openqa.selenium.Capabilities +import org.openqa.selenium.remote.RemoteWebDriver +import org.testcontainers.containers.BrowserWebDriverContainer.VncRecordingMode +import org.testcontainers.containers.{RecordingFileFactory, BrowserWebDriverContainer => JavaBrowserWebDriverContainer} +import org.testcontainers.lifecycle.TestDescription +import org.testcontainers.testcontainers4s.lifecycle.TestLifecycleAware + +object BrowserWebDriverContainer { + + case class Def( + dockerImageName: Option[String] = None, + capabilities: Option[Capabilities] = None, + recordingMode: Option[(VncRecordingMode, File)] = None, + recordingFileFactory: Option[RecordingFileFactory] = None, + ) extends Container { + + override type Container = BrowserWebDriverContainer + + override def createContainer(): BrowserWebDriverContainer = { + val javaContainer = dockerImageName match { + case Some(imageName) => new JavaBrowserWebDriverContainer(imageName) + case None => new JavaBrowserWebDriverContainer() + } + capabilities.foreach(javaContainer.withCapabilities) + recordingMode.foreach { case (mode, file) => + javaContainer.withRecordingMode(mode, file) + } + recordingFileFactory.foreach(javaContainer.withRecordingFileFactory) + + new BrowserWebDriverContainer(javaContainer) + } + } +} + +class BrowserWebDriverContainer private[containers] ( + val underlyingUnsafeContainer: JavaBrowserWebDriverContainer[_] +) extends ContainerRuntime with TestLifecycleAware { + + override type JavaContainer = JavaBrowserWebDriverContainer[_] + + override def afterTest(description: TestDescription, throwable: Option[Throwable]): Unit = { + val javaThrowable: Optional[Throwable] = throwable match { + case Some(error) => Optional.of(error) + case None => Optional.empty() + } + underlyingUnsafeContainer.afterTest(description, javaThrowable) + } + + def seleniumAddress: URL = underlyingUnsafeContainer.getSeleniumAddress + def vncAddress: String = underlyingUnsafeContainer.getVncAddress + def password: String = underlyingUnsafeContainer.getPassword + def port: Int = underlyingUnsafeContainer.getPort + def webDriver: RemoteWebDriver = underlyingUnsafeContainer.getWebDriver +} diff --git a/experimental/src/main/scala/org/testcontainers/testcontainers4s/containers/ContainerRuntime.scala b/experimental/src/main/scala/org/testcontainers/testcontainers4s/containers/ContainerRuntime.scala new file mode 100644 index 00000000..e211dac4 --- /dev/null +++ b/experimental/src/main/scala/org/testcontainers/testcontainers4s/containers/ContainerRuntime.scala @@ -0,0 +1,61 @@ +package org.testcontainers.testcontainers4s.containers + +import org.testcontainers.containers.{GenericContainer => JavaGenericContainer} + +trait ContainerRuntime extends ContainerList { + + type JavaContainer <: JavaGenericContainer[_] + + def underlyingUnsafeContainer: JavaContainer + + def stop(): Unit = underlyingUnsafeContainer.stop() + + def start(): Unit = underlyingUnsafeContainer.start() +} +object ContainerRuntime { + type Aux[JC <: JavaGenericContainer[_]] = ContainerRuntime { type JavaContainer = JC } +} + +trait Container { + + type Container <: org.testcontainers.testcontainers4s.containers.ContainerRuntime + + protected def createContainer(): Container + + def start(): Container = { + val container = createContainer() + container.underlyingUnsafeContainer.start() + container + } +} + +sealed trait ContainerList { + + def stop(): Unit + + def foreach(f: ContainerRuntime => Unit): Unit = { + // TODO: test it + this match { + case and(head, tail) => + head.foreach(f) + tail.foreach(f) + + case container: ContainerRuntime => + f(container) + } + } + +} +final case class and[C1 <: ContainerList, C2 <: ContainerList](head : C1, tail : C2) extends ContainerList { + override def stop(): Unit = { + // TODO: test stopping order + head.stop() + tail.stop() + } +} + +object ContainerList { + implicit class ContainerListOps[T <: ContainerList](val self: T) extends AnyVal { + def and[T2 <: ContainerList](that: T2): T and T2 = org.testcontainers.testcontainers4s.containers.and(self, that) + } +} diff --git a/experimental/src/main/scala/org/testcontainers/testcontainers4s/containers/GenericContainer.scala b/experimental/src/main/scala/org/testcontainers/testcontainers4s/containers/GenericContainer.scala new file mode 100644 index 00000000..8773f8fe --- /dev/null +++ b/experimental/src/main/scala/org/testcontainers/testcontainers4s/containers/GenericContainer.scala @@ -0,0 +1,39 @@ +package org.testcontainers.testcontainers4s.containers + +import org.testcontainers.containers.BindMode +import org.testcontainers.containers.wait.strategy.WaitStrategy +import org.testcontainers.containers.{GenericContainer => JavaGenericContainer} + +object GenericContainer { + + def apply( + dockerImageName: String, // TODO: Future[String] like in the old implementation? + exposedPorts: List[Int] = List.empty, + env: Map[String, String] = Map.empty, + command: List[String] = List.empty, + classpathResourceMapping: List[(String, String, BindMode)] = List.empty, + waitStrategy: Option[WaitStrategy] = None + ): JavaGenericContainer[_] = { + val javaContainer = new JavaGenericContainer(dockerImageName) + + if (exposedPorts.nonEmpty) { + javaContainer.withExposedPorts(exposedPorts.map(int2Integer): _*) + } + env.foreach(Function.tupled(javaContainer.withEnv)) + if (command.nonEmpty) { + javaContainer.withCommand(command: _*) + } + classpathResourceMapping.foreach(Function.tupled(javaContainer.withClasspathResourceMapping)) + waitStrategy.foreach(javaContainer.waitingFor) + + javaContainer + } + + abstract class Def[C <: GenericContainer](init: => C) extends Container { + override type Container = C + protected def createContainer(): C = init + } +} +abstract class GenericContainer(val underlyingUnsafeContainer: JavaGenericContainer[_]) extends ContainerRuntime { + override type JavaContainer = JavaGenericContainer[_] +} diff --git a/experimental/src/main/scala/org/testcontainers/testcontainers4s/containers/JdbcDatabaseContainer.scala b/experimental/src/main/scala/org/testcontainers/testcontainers4s/containers/JdbcDatabaseContainer.scala new file mode 100644 index 00000000..e7d4ff86 --- /dev/null +++ b/experimental/src/main/scala/org/testcontainers/testcontainers4s/containers/JdbcDatabaseContainer.scala @@ -0,0 +1,20 @@ +package org.testcontainers.testcontainers4s.containers + +import java.sql.Driver + +import org.testcontainers.containers.{JdbcDatabaseContainer => JavaJdbcDatabaseContainer} + +trait JdbcDatabaseContainer[T <: JavaJdbcDatabaseContainer[_]] { container: ContainerRuntime.Aux[T] => + + def driverClassName: String = underlyingUnsafeContainer.getDriverClassName + + def jdbcDriverInstance: Driver = underlyingUnsafeContainer.getJdbcDriverInstance + + def jdbcUrl: String = underlyingUnsafeContainer.getJdbcUrl + + def databaseName: String = underlyingUnsafeContainer.getDatabaseName + + def username: String = underlyingUnsafeContainer.getUsername + + def password: String = underlyingUnsafeContainer.getPassword +} diff --git a/experimental/src/main/scala/org/testcontainers/testcontainers4s/containers/MySQLContainer.scala b/experimental/src/main/scala/org/testcontainers/testcontainers4s/containers/MySQLContainer.scala new file mode 100644 index 00000000..8bebd4c7 --- /dev/null +++ b/experimental/src/main/scala/org/testcontainers/testcontainers4s/containers/MySQLContainer.scala @@ -0,0 +1,41 @@ +package org.testcontainers.testcontainers4s.containers + +import java.sql.Driver + +import org.testcontainers.containers.{MySQLContainer => JavaMySQLContainer} +import org.testcontainers.testcontainers4s.containers.MySQLContainer.{MySQLContainerRuntime, defaultDatabaseName, defaultDockerImageName, defaultPassword, defaultUsername} + +object MySQLContainer { + + val defaultDockerImageName = s"${JavaMySQLContainer.IMAGE}:${JavaMySQLContainer.DEFAULT_TAG}" + val defaultDatabaseName = "test" + val defaultUsername = "test" + val defaultPassword = "test" + + class MySQLContainerRuntime private[containers] ( + val underlyingUnsafeContainer: JavaMySQLContainer[_] + ) extends ContainerRuntime with JdbcDatabaseContainer[JavaMySQLContainer[_]] { + override type JavaContainer = JavaMySQLContainer[_] + } +} +case class MySQLContainer( + dockerImageName: String = defaultDockerImageName, + databaseName: String = defaultDatabaseName, + username: String = defaultUsername, + password: String = defaultPassword, + configurationOverride: Option[String] = None, + ) extends Container { + + override type Container = MySQLContainerRuntime + + override def createContainer(): MySQLContainerRuntime = { + val javaContainer = new JavaMySQLContainer(dockerImageName) + javaContainer.withDatabaseName(databaseName) + javaContainer.withPassword(password) + javaContainer.withUsername(username) + configurationOverride.foreach(javaContainer.withConfigurationOverride) + new MySQLContainerRuntime(javaContainer) + } + + @deprecated def jdbcUrl(implicit c: Container): String = c.jdbcUrl +} diff --git a/experimental/src/main/scala/org/testcontainers/testcontainers4s/containers/PostgreSQLContainer.scala b/experimental/src/main/scala/org/testcontainers/testcontainers4s/containers/PostgreSQLContainer.scala new file mode 100644 index 00000000..f1efc262 --- /dev/null +++ b/experimental/src/main/scala/org/testcontainers/testcontainers4s/containers/PostgreSQLContainer.scala @@ -0,0 +1,37 @@ +package org.testcontainers.testcontainers4s.containers + +import org.testcontainers.containers.{PostgreSQLContainer => JavaPostgreSQLContainer} +import org.testcontainers.testcontainers4s.containers.PostgreSQLContainer.{PostgreSQLContainerRuntime, defaultDatabaseName, defaultDockerImageName, defaultPassword, defaultUsername} + +object PostgreSQLContainer { + + val defaultDockerImageName = s"${JavaPostgreSQLContainer.IMAGE}:${JavaPostgreSQLContainer.DEFAULT_TAG}" + val defaultDatabaseName = "test" + val defaultUsername = "test" + val defaultPassword = "test" + + class PostgreSQLContainerRuntime private[containers] ( + val underlyingUnsafeContainer: JavaPostgreSQLContainer[_] + ) extends ContainerRuntime with JdbcDatabaseContainer[JavaPostgreSQLContainer[_]] { + override type JavaContainer = JavaPostgreSQLContainer[_] + } +} + +// TODO: add extraConfiguration with java container? +case class PostgreSQLContainer( + dockerImageName: String = defaultDockerImageName, + databaseName: String = defaultDatabaseName, + username: String = defaultUsername, + password: String = defaultPassword, + ) extends Container { + + override type Container = PostgreSQLContainerRuntime + + override def createContainer(): PostgreSQLContainerRuntime = { + val javaContainer = new JavaPostgreSQLContainer(dockerImageName) + javaContainer.withDatabaseName(databaseName) + javaContainer.withPassword(password) + javaContainer.withUsername(username) + new PostgreSQLContainerRuntime(javaContainer) + } +} \ No newline at end of file diff --git a/experimental/src/main/scala/org/testcontainers/testcontainers4s/containers/scalatest/TestContainers.scala b/experimental/src/main/scala/org/testcontainers/testcontainers4s/containers/scalatest/TestContainers.scala new file mode 100644 index 00000000..eba7fa99 --- /dev/null +++ b/experimental/src/main/scala/org/testcontainers/testcontainers4s/containers/scalatest/TestContainers.scala @@ -0,0 +1,191 @@ +package org.testcontainers.testcontainers4s.containers.scalatest + +import org.junit.runner.{Description => JunitDescription} +import org.scalatest.{Args, CompositeStatus, Status, Suite, SuiteMixin} +import org.testcontainers.lifecycle.TestDescription +import org.testcontainers.testcontainers4s.containers.{Container, ContainerList} +import org.testcontainers.testcontainers4s.containers.scalatest.TestContainers.TestContainersSuite +import org.testcontainers.testcontainers4s.lifecycle.TestLifecycleAware + +private[scalatest] object TestContainers { + + implicit def junit2testContainersDescription(junit: JunitDescription): TestDescription = { + new TestDescription { + override def getTestId: String = junit.getDisplayName + override def getFilesystemFriendlyName: String = s"${junit.getClassName}-${junit.getMethodName}" + } + } + + // Copy-pasted from `org.scalatest.junit.JUnitRunner.createDescription` + def createDescription(suite: Suite): JunitDescription = { + val description = JunitDescription.createSuiteDescription(suite.getClass) + // If we don't add the testNames and nested suites in, we get + // Unrooted Tests show up in Eclipse + for (name <- suite.testNames) { + description.addChild(JunitDescription.createTestDescription(suite.getClass, name)) + } + for (nestedSuite <- suite.nestedSuites) { + description.addChild(createDescription(nestedSuite)) + } + description + } + + trait TestContainersSuite extends SuiteMixin { self: Suite => + + type Containers <: ContainerList + + def startContainers(): Containers + + def withContainers(runTest: Containers => Unit): Unit = { + val c = startedContainers.getOrElse(throw IllegalWithContainersCall()) + runTest(c) + } + + private val suiteDescription = createDescription(self) + + @volatile private[scalatest] var startedContainers: Option[Containers] = None + + @deprecated implicit def containerRuntime: Containers = startedContainers.get // TODO exception handling + + private[scalatest] def beforeTest(containers: Containers): Unit = { + containers.foreach { + case container: TestLifecycleAware => container.beforeTest(suiteDescription) + case _ => // do nothing + } + } + + private[scalatest] def afterTest(containers: Containers, throwable: Option[Throwable]): Unit = { + containers.foreach { + case container: TestLifecycleAware => container.afterTest(suiteDescription, throwable) + case _ => // do nothing + } + } + + def afterStart(): Unit = {} + + def beforeStop(): Unit = {} + } +} + +case class IllegalWithContainersCall() extends IllegalStateException( + "'withContainers' method can't be used before all containers are started. " + + "'withContainers' method should be used only in test cases to prevent this." +) + +trait TestContainersForAll extends TestContainersSuite { self: Suite => + + abstract override def run(testName: Option[String], args: Args): Status = { + if (expectedTestCount(args.filter) == 0) { + new CompositeStatus(Set.empty) + } else { + startedContainers = Some(startContainers()) + try { + afterStart() + super.run(testName, args) + } finally { + try { + beforeStop() + } + finally { + try { + startedContainers.foreach(_.stop()) + } + finally { + startedContainers = None + } + } + } + } + } + + abstract protected override def runTest(testName: String, args: Args): Status = { + @volatile var testCalled = false + @volatile var afterTestCalled = false + + try { + startedContainers.foreach(beforeTest) + testCalled = true + val status = super.runTest(testName, args) + if (!status.succeeds()) { + afterTestCalled = true + startedContainers.foreach(afterTest(_, Some(new RuntimeException(status.toString)))) + } + status + } + catch { + case e: Throwable => + if (testCalled && !afterTestCalled) { + afterTestCalled = true + startedContainers.foreach(afterTest(_, Some(e))) + } + + throw e + } + } +} + +trait TestContainersForEach extends TestContainersSuite { self: Suite => + + abstract protected override def runTest(testName: String, args: Args): Status = { + val containers = startContainers() + startedContainers = Some(containers) + + @volatile var testCalled = false + @volatile var afterTestCalled = false + + try { + afterStart() + beforeTest(containers) + testCalled = true + val status = super.runTest(testName, args) + if (!status.succeeds()) { + afterTestCalled = true + afterTest(containers, Some(new RuntimeException(status.toString))) + } + status + } + catch { + case e: Throwable => + if (testCalled && !afterTestCalled) { + afterTestCalled = true + afterTest(containers, Some(e)) + } + + throw e + } + finally { + try { + beforeStop() + } + finally { + try { + containers.stop() + } + finally { + startedContainers = None + } + } + } + } +} + +trait TestContainerForAll extends TestContainersForAll { self: Suite => + + val containerDef: Container + + final override type Containers = containerDef.Container + + override def startContainers(): containerDef.Container = { + containerDef.start() + } +} + +trait TestContainerForEach extends TestContainersForEach { self: Suite => + + val container: Container + final override type Containers = container.Container + + override def startContainers(): container.Container = { + container.start() + } +} diff --git a/experimental/src/main/scala/org/testcontainers/testcontainers4s/lifecycle/TestLifecycleAware.scala b/experimental/src/main/scala/org/testcontainers/testcontainers4s/lifecycle/TestLifecycleAware.scala new file mode 100644 index 00000000..c02807fe --- /dev/null +++ b/experimental/src/main/scala/org/testcontainers/testcontainers4s/lifecycle/TestLifecycleAware.scala @@ -0,0 +1,11 @@ +package org.testcontainers.testcontainers4s.lifecycle + +import org.testcontainers.lifecycle.TestDescription +import org.testcontainers.testcontainers4s.containers.ContainerRuntime + +trait TestLifecycleAware { self: ContainerRuntime => + + def beforeTest(description: TestDescription): Unit = {} + + def afterTest(description: TestDescription, throwable: Option[Throwable]): Unit = {} +} diff --git a/experimental/src/test/scala/org/testcontainers/testcontainers4s/containers/MultipleContainersSuite.scala b/experimental/src/test/scala/org/testcontainers/testcontainers4s/containers/MultipleContainersSuite.scala new file mode 100644 index 00000000..13a455c1 --- /dev/null +++ b/experimental/src/test/scala/org/testcontainers/testcontainers4s/containers/MultipleContainersSuite.scala @@ -0,0 +1,24 @@ +package org.testcontainers.testcontainers4s.containers + +import org.scalatest.FreeSpec +import org.testcontainers.testcontainers4s.containers.MySQLContainer.MySQLContainerRuntime +import org.testcontainers.testcontainers4s.containers.PostgreSQLContainer.PostgreSQLContainerRuntime +import org.testcontainers.testcontainers4s.containers.scalatest.TestContainersForAll + +class MultipleContainersSuite extends FreeSpec with TestContainersForAll { + + override type Containers = PostgreSQLContainerRuntime and MySQLContainerRuntime + + override def startContainers(): Containers = { + val pg = PostgreSQLContainer().start() + val mySql = MySQLContainer().start() + + pg and mySql + } + + "foo" - { + "bar" in withContainers { case pg and mySql => + assert(pg.jdbcUrl.nonEmpty && mySql.jdbcUrl.nonEmpty) + } + } +} diff --git a/experimental/src/test/scala/org/testcontainers/testcontainers4s/containers/MyGenericContainer.scala b/experimental/src/test/scala/org/testcontainers/testcontainers4s/containers/MyGenericContainer.scala new file mode 100644 index 00000000..1fb31680 --- /dev/null +++ b/experimental/src/test/scala/org/testcontainers/testcontainers4s/containers/MyGenericContainer.scala @@ -0,0 +1,11 @@ +package org.testcontainers.testcontainers4s.containers + +import org.testcontainers.containers.{GenericContainer => JavaGenericContainer} + +object MyGenericContainer { + + case class Def() extends GenericContainer.Def[MyGenericContainer]( + new MyGenericContainer(GenericContainer("foobar")) + ) +} +class MyGenericContainer(underlying: JavaGenericContainer[_]) extends GenericContainer(underlying) diff --git a/experimental/src/test/scala/org/testcontainers/testcontainers4s/containers/SingleContainerSuite.scala b/experimental/src/test/scala/org/testcontainers/testcontainers4s/containers/SingleContainerSuite.scala new file mode 100644 index 00000000..8739c5da --- /dev/null +++ b/experimental/src/test/scala/org/testcontainers/testcontainers4s/containers/SingleContainerSuite.scala @@ -0,0 +1,27 @@ +package org.testcontainers.testcontainers4s.containers + +import org.scalatest.{FlatSpec, FreeSpec} +import org.testcontainers.testcontainers4s.containers.scalatest.TestContainerForEach + +class SingleContainerSuite extends FreeSpec with TestContainerForEach { + + override val container = MySQLContainer() + + "foo" - { + "bar" in withContainers { db => + assert(db.jdbcUrl.nonEmpty) + } + "baz" in withContainers { db => + assert(db.jdbcUrl.nonEmpty) + } + } +} + +class OldContainerSpec extends FlatSpec with TestContainerForEach { + + override val container = MySQLContainer() + + it should "test" in { + assert(container.jdbcUrl.nonEmpty) + } +}