Skip to content

Commit 755e1d4

Browse files
committed
refactor: share more container setup
1 parent 468d8cb commit 755e1d4

7 files changed

Lines changed: 188 additions & 258 deletions

File tree

scalatestsuite/package.mill

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import build.modules.{BaseModule, SharedDependencies}
66
object `package` extends BaseModule {
77
override def moduleName = "scalatestsuite"
88
override def isComponent: Boolean = false
9-
def mvnDeps: T[Seq[Dep]] = SharedDependencies.logging ++ SharedDependencies.database ++ SharedDependencies.testing
10-
override def moduleDeps = Seq(build.common, build.testbase, build.database)
9+
def mvnDeps: T[Seq[Dep]] = SharedDependencies.logging ++ SharedDependencies.database ++ SharedDependencies
10+
.testing ++ SharedDependencies.circe
11+
override def moduleDeps = Seq(build.common, build.testbase, build.database)
1112
object test extends TestBase
1213
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* Part of NDLA scalatestsuite
3+
* Copyright (C) 2026 NDLA
4+
*
5+
* See LICENSE
6+
*
7+
*/
8+
9+
package no.ndla.scalatestsuite
10+
11+
import io.circe.{Decoder, Encoder}
12+
import org.testcontainers.containers.GenericContainer
13+
14+
import scala.util.Try
15+
16+
abstract class ContainerIntegrationSuiteBase[CT <: GenericContainer[?], O](using Encoder[O], Decoder[O]) {
17+
private val skipContainerSpawn: Boolean = sys.env.getOrElse("NDLA_SKIP_CONTAINER_SPAWN", "false") == "true"
18+
private val disableSharedContainers: Boolean = sys.env.getOrElse("NDLA_DISABLE_SHARED_CONTAINERS", "false") == "true"
19+
private var standaloneContainer: Option[CT] = None
20+
21+
protected val containerName: String
22+
protected def createContainer(): CT
23+
protected def fromContainer(container: CT): O
24+
protected def fromEnv(): O
25+
protected def healthCheck(info: O): Boolean
26+
27+
lazy val output: Try[O] = Try {
28+
if (skipContainerSpawn) {
29+
fromEnv()
30+
} else if (disableSharedContainers) {
31+
val container = createContainer()
32+
container.withReuse(false)
33+
container.start()
34+
standaloneContainer = Some(container)
35+
fromContainer(container)
36+
} else {
37+
SharedContainer
38+
.acquire(
39+
name = containerName,
40+
startContainer = {
41+
val container = createContainer()
42+
container.withReuse(true)
43+
container.start()
44+
SharedContainerInfo(container.getContainerId, fromContainer(container))
45+
},
46+
healthCheck = healthCheck,
47+
)
48+
.data
49+
}
50+
}
51+
52+
def close(): Unit = if (!skipContainerSpawn && disableSharedContainers) standaloneContainer.foreach(_.stop())
53+
}

scalatestsuite/src/main/scala/no/ndla/scalatestsuite/ContainerSuite.scala

Lines changed: 0 additions & 16 deletions
This file was deleted.

scalatestsuite/src/main/scala/no/ndla/scalatestsuite/DatabaseIntegrationSuite.scala

Lines changed: 37 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,18 @@
99
package no.ndla.scalatestsuite
1010

1111
import com.zaxxer.hikari.HikariConfig
12+
import io.circe.Codec
13+
import io.circe.generic.semiauto.deriveCodec
1214
import no.ndla.common.configuration.BaseProps
1315
import no.ndla.database.{DataSource, DatabaseProps}
1416

1517
import java.sql.DriverManager
1618
import scala.util.Try
1719
import sys.env
1820

19-
trait DatabaseIntegrationSuite extends UnitTestSuite with ContainerSuite {
21+
trait DatabaseIntegrationSuite extends UnitTestSuite {
22+
case class PgConnectionInfo(host: String, port: Int, username: String, password: String, databaseName: String)
23+
2024
lazy val props: BaseProps & DatabaseProps
2125

2226
val PostgresqlVersion: String = "17.5"
@@ -26,74 +30,38 @@ trait DatabaseIntegrationSuite extends UnitTestSuite with ContainerSuite {
2630
private val defaultDatabaseName: String = "postgres"
2731
private val defaultPassword: String = "hemmelig"
2832

29-
private var standalonePgContainer: Option[PgContainer] = None
30-
31-
case class PgConnectionInfo(host: String, port: Int, username: String, password: String, databaseName: String)
33+
private given Codec[PgConnectionInfo] = deriveCodec
34+
35+
protected object postgresContainer extends ContainerIntegrationSuiteBase[PgContainer, PgConnectionInfo] {
36+
override protected val containerName: String = "postgres"
37+
38+
override protected def createContainer(): PgContainer =
39+
PgContainer(PostgresqlVersion, defaultUsername, defaultPassword, defaultDatabaseName)
40+
41+
override protected def fromContainer(c: PgContainer): PgConnectionInfo = PgConnectionInfo(
42+
host = c.getHost,
43+
port = c.getMappedPort(5432).intValue(),
44+
username = c.getUsername,
45+
password = c.getPassword,
46+
databaseName = c.getDatabaseName,
47+
)
48+
49+
override protected def fromEnv(): PgConnectionInfo = PgConnectionInfo(
50+
host = env.getOrElse("META_SERVER", "localhost"),
51+
port = env.getOrElse("META_PORT", "5432").toInt,
52+
username = env.getOrElse("META_USERNAME", defaultUsername),
53+
password = env.getOrElse("META_PASSWORD", defaultPassword),
54+
databaseName = env.getOrElse("META_RESOURCE", defaultDatabaseName),
55+
)
56+
57+
override protected def healthCheck(info: PgConnectionInfo): Boolean = Try {
58+
val url = s"jdbc:postgresql://${info.host}:${info.port}/${info.databaseName}"
59+
val conn = DriverManager.getConnection(url, info.username, info.password)
60+
conn.close()
61+
}.isSuccess
62+
}
3263

33-
private def startPgContainer(): PgContainer =
34-
PgContainer(PostgresqlVersion, defaultUsername, defaultPassword, defaultDatabaseName)
35-
36-
val pgConnectionInfo: Try[PgConnectionInfo] =
37-
if (skipContainerSpawn) {
38-
Try {
39-
PgConnectionInfo(
40-
host = env.getOrElse("META_SERVER", "localhost"),
41-
port = env.getOrElse("META_PORT", "5432").toInt,
42-
username = env.getOrElse("META_USERNAME", defaultUsername),
43-
password = env.getOrElse("META_PASSWORD", defaultPassword),
44-
databaseName = env.getOrElse("META_RESOURCE", defaultDatabaseName),
45-
)
46-
}
47-
} else if (disableSharedContainers) {
48-
Try {
49-
val c = startPgContainer()
50-
c.start()
51-
standalonePgContainer = Some(c)
52-
PgConnectionInfo(
53-
host = c.getHost,
54-
port = c.getMappedPort(5432),
55-
username = c.getUsername,
56-
password = c.getPassword,
57-
databaseName = c.getDatabaseName,
58-
)
59-
}
60-
} else {
61-
Try {
62-
val info = SharedContainer.acquire(
63-
name = "postgres",
64-
healthCheckPort = 5432,
65-
healthCheck = info => {
66-
Try {
67-
val url = s"jdbc:postgresql://${info.data("host")}:${info.data("port")}/${info.data("databaseName")}"
68-
val conn = DriverManager.getConnection(url, info.data("username"), info.data("password"))
69-
conn.close()
70-
}.isSuccess
71-
},
72-
startContainer = () => {
73-
val c = startPgContainer()
74-
c.withReuse(true): Unit
75-
c.start()
76-
SharedContainerInfo(
77-
containerId = c.getContainerId,
78-
data = Map(
79-
"host" -> c.getHost,
80-
"port" -> c.getMappedPort(5432).toString,
81-
"username" -> c.getUsername,
82-
"password" -> c.getPassword,
83-
"databaseName" -> c.getDatabaseName,
84-
),
85-
)
86-
},
87-
)
88-
PgConnectionInfo(
89-
host = info.data("host"),
90-
port = info.data("port").toInt,
91-
username = info.data("username"),
92-
password = info.data("password"),
93-
databaseName = info.data("databaseName"),
94-
)
95-
}
96-
}
64+
lazy val pgConnectionInfo: Try[PgConnectionInfo] = postgresContainer.output
9765

9866
def testDataSource: Try[DataSource] = pgConnectionInfo.flatMap(pgc =>
9967
Try {
@@ -143,8 +111,6 @@ trait DatabaseIntegrationSuite extends UnitTestSuite with ContainerSuite {
143111
override def afterAll(): Unit = {
144112
super.afterAll()
145113
restoreDatabaseEnv()
146-
if (!skipContainerSpawn && disableSharedContainers) {
147-
standalonePgContainer.foreach(_.stop())
148-
}
114+
postgresContainer.close()
149115
}
150116
}

scalatestsuite/src/main/scala/no/ndla/scalatestsuite/ElasticsearchIntegrationSuite.scala

Lines changed: 42 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -17,92 +17,64 @@ import java.time.Duration
1717
import scala.util.Try
1818
import sys.env
1919

20-
trait ElasticsearchIntegrationSuite extends UnitTestSuite with ContainerSuite {
20+
trait ElasticsearchIntegrationSuite extends UnitTestSuite {
2121
val ElasticsearchImage: String = "docker.elastic.co/elasticsearch/elasticsearch:8.18.1"
2222

23-
private var standaloneEsContainer: Option[ElasticsearchContainer] = None
23+
protected object elasticsearchContainer extends ContainerIntegrationSuiteBase[ElasticsearchContainer, String] {
24+
override protected val containerName: String = "elasticsearch"
2425

25-
private def esImageName: DockerImageName = {
26-
val imgName = env.getOrElse("SEARCH_ENGINE_IMAGE", ElasticsearchImage)
27-
DockerImageName.parse(imgName)
28-
}
26+
private def esImageName: DockerImageName =
27+
DockerImageName.parse(env.getOrElse("SEARCH_ENGINE_IMAGE", ElasticsearchImage))
2928

30-
private def startEsContainer(): ElasticsearchContainer = {
31-
val container = new ElasticsearchContainer(esImageName)
32-
container.withStartupTimeout(Duration.ofSeconds(180))
33-
container.addEnv("xpack.security.enabled", "false")
34-
container.addEnv("ES_JAVA_OPTS", "-Xms1g -Xmx1g")
35-
container.addEnv("discovery.type", "single-node")
36-
container.withCopyFileToContainer(
37-
MountableFile.forClasspathResource("search-engine/compound-words-norwegian-wordlist.txt"),
38-
"/usr/share/elasticsearch/config/compound-words-norwegian-wordlist.txt",
39-
)
40-
container.withCopyFileToContainer(
41-
MountableFile.forClasspathResource("search-engine/hyph"),
42-
"/usr/share/elasticsearch/config/hyph",
43-
)
44-
container
45-
}
29+
override protected def createContainer(): ElasticsearchContainer = {
30+
val container = new ElasticsearchContainer(esImageName)
31+
container.withStartupTimeout(Duration.ofSeconds(180))
32+
container.addEnv("xpack.security.enabled", "false")
33+
container.addEnv("ES_JAVA_OPTS", "-Xms1g -Xmx1g")
34+
container.addEnv("discovery.type", "single-node")
35+
container.withCopyFileToContainer(
36+
MountableFile.forClasspathResource("search-engine/compound-words-norwegian-wordlist.txt"),
37+
"/usr/share/elasticsearch/config/compound-words-norwegian-wordlist.txt",
38+
)
39+
container.withCopyFileToContainer(
40+
MountableFile.forClasspathResource("search-engine/hyph"),
41+
"/usr/share/elasticsearch/config/hyph",
42+
)
43+
container
44+
}
4645

47-
private def isElasticsearchReady(info: SharedContainerInfo): Boolean = {
48-
Try {
49-
val request = HttpRequest
50-
.newBuilder(URI.create(s"http://${info.data("host")}:${info.data("port")}"))
51-
.timeout(Duration.ofSeconds(2))
52-
.GET()
53-
.build()
46+
override protected def fromContainer(c: ElasticsearchContainer): String = {
47+
val addr = s"http://${c.getHttpHostAddress}"
48+
println(s"Running '${ElasticsearchIntegrationSuite.this.getClass.getName}' elasticsearch at $addr")
49+
addr
50+
}
51+
52+
override protected def fromEnv(): String = {
53+
val addr = env.getOrElse("SEARCH_SERVER", "http://localhost:9200")
54+
val normalized =
55+
if (addr.startsWith("http://")) addr
56+
else s"http://$addr"
57+
println(
58+
s"Running '${ElasticsearchIntegrationSuite.this.getClass.getName}' elasticsearch at $normalized (external)"
59+
)
60+
normalized
61+
}
62+
63+
override protected def healthCheck(url: String): Boolean = Try {
64+
val request = HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(2)).GET().build()
5465
val response = HttpClient
5566
.newBuilder()
5667
.connectTimeout(Duration.ofSeconds(2))
5768
.build()
5869
.send(request, HttpResponse.BodyHandlers.discarding())
59-
6070
response.statusCode() >= 200 && response.statusCode() < 500
6171
}.getOrElse(false)
6272
}
6373

64-
private def getSearchServerEnvOrDefault = {
65-
val addr = env.getOrElse("SEARCH_SERVER", "http://localhost:9200")
66-
if (addr.startsWith("http://")) addr
67-
else s"http://$addr"
68-
}
69-
70-
val elasticSearchHost: String =
71-
if (skipContainerSpawn) {
72-
val addr = getSearchServerEnvOrDefault
73-
println(s"Running '${this.getClass.getName}' elasticsearch at $addr (external)")
74-
addr
75-
} else if (disableSharedContainers) {
76-
val container = startEsContainer()
77-
container.start()
78-
standaloneEsContainer = Some(container)
79-
val addr = s"http://${container.getHttpHostAddress}"
80-
println(s"Running '${this.getClass.getName}' elasticsearch at $addr (standalone)")
81-
addr
82-
} else {
83-
val info = SharedContainer.acquire(
84-
name = "elasticsearch",
85-
healthCheckPort = 9200,
86-
healthCheck = isElasticsearchReady,
87-
startContainer = () => {
88-
val container = startEsContainer()
89-
container.withReuse(true): Unit
90-
container.start()
91-
val hostAddress = container.getHttpHostAddress
92-
val host = hostAddress.split(":")(0)
93-
val port = hostAddress.split(":")(1)
94-
SharedContainerInfo(containerId = container.getContainerId, data = Map("host" -> host, "port" -> port))
95-
},
96-
)
97-
val addr = s"http://${info.data("host")}:${info.data("port")}"
98-
println(s"Running '${this.getClass.getName}' elasticsearch at $addr")
99-
addr
100-
}
74+
lazy val elasticSearchHost: String = elasticsearchContainer.output.get
10175

10276
override def afterAll(): Unit = {
10377
super.afterAll()
104-
if (!skipContainerSpawn && disableSharedContainers) {
105-
standaloneEsContainer.foreach(_.stop())
106-
}
78+
elasticsearchContainer.close()
10779
}
10880
}

0 commit comments

Comments
 (0)