Skip to content
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
82 commits
Select commit Hold shift + click to select a range
ed6e3cd
#430: Add testcontainers dependencies
DmitryNekrasov Mar 19, 2025
37e67fe
#430: Add dockerfiles
DmitryNekrasov Mar 19, 2025
0e4aadd
#430: Fix build.gradle.kts to depend on JUnit 5 to correct running of…
DmitryNekrasov Mar 19, 2025
20dc7bc
#430: Add container initialization, run simple commant inside contain…
DmitryNekrasov Mar 19, 2025
fbbd55f
#430: Add logger configuration
DmitryNekrasov Mar 19, 2025
5facbfa
#430: dockerfilePath refactoring
DmitryNekrasov Mar 19, 2025
8cfd36d
#430: Run tests inside containers
DmitryNekrasov Mar 19, 2025
683ce09
#430: Add defaultTimeZoneTest that runs inside containers
DmitryNekrasov Mar 19, 2025
e638490
#430: Split test on 2, pass cantainer as parameter
DmitryNekrasov Mar 19, 2025
3d59b39
#430: Add BeforeAll buildTestBinary
DmitryNekrasov Mar 19, 2025
0614e61
#430: Fix container stdout
DmitryNekrasov Mar 19, 2025
d294ac1
#430: Move defaultTimeZoneTest from common to linux/native
DmitryNekrasov Mar 19, 2025
fc08cb9
#430: Check INSIDE_TESTCONTAINERS env var to run test only in testcon…
DmitryNekrasov Mar 19, 2025
403ebf9
#430: Replace shouldRunTests check to Testcontainers.runIfAvailable
DmitryNekrasov Mar 20, 2025
54ad227
#430: Refactor Skipping test message
DmitryNekrasov Mar 20, 2025
1d0ff83
#430: Rename TimeZoneTest to TimeZoneConfigurationTest
DmitryNekrasov Mar 20, 2025
62e4f5b
#430: Split runTest to runTimeZoneTests and runAllTests
DmitryNekrasov Mar 20, 2025
6d84895
#430: Test fail is execResult.exitCode != 0
DmitryNekrasov Mar 20, 2025
7d620bd
#430: Removed unnecessary dependency junit-params
DmitryNekrasov Mar 20, 2025
269db7f
#430: First working iteration
DmitryNekrasov Mar 20, 2025
acfc235
#430: Add file comparison + some refactoring
DmitryNekrasov Mar 20, 2025
c52b60a
#430: Minor refactoring
DmitryNekrasov Mar 20, 2025
e36db1b
#430: Add 3 tests
DmitryNekrasov Mar 20, 2025
ddb105d
#430: Refactoring
DmitryNekrasov Mar 20, 2025
733158d
#430: Add allTimeZoneFilesMissingTest test, fails because of Expected…
DmitryNekrasov Mar 20, 2025
a156cad
#430: Add symlinkTimeZoneTest
DmitryNekrasov Mar 20, 2025
efc67b8
#430: Add invalidTimezoneFormatTest
DmitryNekrasov Mar 20, 2025
5902399
#430: Add commonTimeZoneTests
DmitryNekrasov Mar 20, 2025
325f92b
#430: Change Z to UTC in currentSystemDefaultZone()
DmitryNekrasov Mar 20, 2025
03e03c7
#430: UTC -> Z
DmitryNekrasov Mar 21, 2025
9f8aac8
#430: Add 2 new Dockerfile for Debian Jessie and Ubuntu 24.04
DmitryNekrasov Mar 21, 2025
0697ae8
#430: Fix platform for ubuntu
DmitryNekrasov Mar 21, 2025
dfdcb2a
#430: Remove some tests, run 2 tests on Debian Jessie
DmitryNekrasov Mar 21, 2025
bff21b1
#430: Add INSIDE_TESTCONTAINERS env var to Dockerfiles
DmitryNekrasov Mar 21, 2025
6b15d79
#430: Fix defaultTimeZoneTest test
DmitryNekrasov Mar 21, 2025
0ee491f
#430: Run tests on both containers
DmitryNekrasov Mar 21, 2025
548ed68
#430: Refactoring
DmitryNekrasov Mar 21, 2025
c9cf312
#430: Refactor tests
DmitryNekrasov Mar 21, 2025
0e7a3c9
#430: Refactor tests
DmitryNekrasov Mar 24, 2025
0240f49
#430: Add jessieDefaultConfigTest, jessieMissingLocaltimeTest, nobleD…
DmitryNekrasov Mar 24, 2025
c007bac
#430: Move ContainerType
DmitryNekrasov Mar 24, 2025
95dd6d9
#430: Refactor dockerfiles
DmitryNekrasov Mar 24, 2025
38cab93
#430: Refactor ContainerType
DmitryNekrasov Mar 24, 2025
9ee58d7
#430: Add nobleIncorrectSymlinkTest
DmitryNekrasov Mar 24, 2025
192f1a2
#430: Add disabledWithoutDocker = true (may help to users that don't …
DmitryNekrasov Mar 24, 2025
1dc04aa
#430: Add @TestInstance(PER_CLASS) to TimeZoneConfigurationTest
DmitryNekrasov Mar 24, 2025
df35ba0
#430: Add jessieMissingTimezoneTest
DmitryNekrasov Mar 24, 2025
2234375
#430: Add jessieIncorrectTimezoneTest
DmitryNekrasov Mar 24, 2025
7a38874
#430: Add jessieDifferentTimezonesTest
DmitryNekrasov Mar 24, 2025
7cc417f
#430: Add comment to fallsBackToUniversal
DmitryNekrasov Mar 24, 2025
d678adf
#430: Add container descriptions in ContainerType
DmitryNekrasov Mar 24, 2025
7f48fc3
#430: Remove WORKDIR from dockerfiles
DmitryNekrasov Mar 24, 2025
d6aa923
#430: Remove logging duplication
DmitryNekrasov Mar 24, 2025
d7acaca
#430: Add comment to currentSystemDefaultZone
DmitryNekrasov Mar 24, 2025
27d7306
#430: Add internal root field
DmitryNekrasov Mar 25, 2025
816f2af
#430: Add correctSymlinkTest
DmitryNekrasov Mar 26, 2025
901cc68
#430: Add TimeZoneConfigurationTest Ignore
DmitryNekrasov Mar 26, 2025
2964b49
#430: Add fallsBackToUTC test
DmitryNekrasov Mar 26, 2025
916703b
#430: Add pwd
DmitryNekrasov Mar 26, 2025
12f56d9
#430: Remove 'core' from path
DmitryNekrasov Mar 26, 2025
bf689b3
#430: Add missingTimezoneTest
DmitryNekrasov Mar 27, 2025
ed277ba
#430: Add incorrectTimezoneTest
DmitryNekrasov Mar 27, 2025
6cb1b0c
#430: Remove Oslo file from missing-localtime test
DmitryNekrasov Mar 27, 2025
e261749
#430: Add differentTimezonesTest
DmitryNekrasov Mar 27, 2025
b4f5cb3
#430: Add differentTimezonesTest
DmitryNekrasov Mar 27, 2025
da58775
#430: Remove pwd
DmitryNekrasov Mar 27, 2025
1180d03
#430: Add test related files
DmitryNekrasov Mar 27, 2025
159c4f2
#430: Remove testcontainers relaited files
DmitryNekrasov Mar 27, 2025
33e3c7f
#430: Remove testcontainers dependencies
DmitryNekrasov Mar 27, 2025
49480b8
#430: Remove logback-test.xml
DmitryNekrasov Mar 27, 2025
27128e8
#430: Introduce withFakeRoot helper method
DmitryNekrasov Mar 27, 2025
2198821
#430: Add comment about workaround
DmitryNekrasov Mar 31, 2025
7105210
#430: Rename root to systemTimezoneSearchRoot
DmitryNekrasov Mar 31, 2025
3866fbf
#430: Rename fallbackToUTCWhenNoLocaltime test
DmitryNekrasov Mar 31, 2025
f64b3bd
#430: Rename missingTimezoneWhenLocaltimeIsNotSymlinkTest test
DmitryNekrasov Mar 31, 2025
d2a9dca
#430: Remove exception message checks
DmitryNekrasov Mar 31, 2025
d429aa8
#430: Rename timezoneFileAgreesWithLocaltimeContents
DmitryNekrasov Mar 31, 2025
c446aa9
#430: Rename nonExistentTimezoneInTimezoneFile
DmitryNekrasov Mar 31, 2025
ea2b11f
#430: Rename timezoneFileDisagreesWithLocaltimeContentsTest
DmitryNekrasov Mar 31, 2025
756c89e
#430: Refactoring
DmitryNekrasov Mar 31, 2025
dd3ce5d
#430: Change exception message check
DmitryNekrasov Mar 31, 2025
c68adb0
#430: Change exception message check
DmitryNekrasov Mar 31, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -221,12 +221,23 @@ kotlin {
runtimeOnly(project(":kotlinx-datetime-zoneinfo"))
}
}

val jvmTest by getting {
dependencies {
implementation("org.testcontainers:testcontainers:1.19.7")
implementation("org.testcontainers:junit-jupiter:1.19.7")
implementation("org.junit.jupiter:junit-jupiter-api:5.10.2")
runtimeOnly("org.junit.jupiter:junit-jupiter-engine:5.10.2")
implementation("ch.qos.logback:logback-classic:1.2.13")
}
}
}
}

tasks {
val jvmTest by existing(Test::class) {
// maxHeapSize = "1024m"
useJUnitPlatform()
}

val compileJavaModuleInfo by registering(JavaCompile::class) {
Expand Down
10 changes: 10 additions & 0 deletions core/jvm/test/testcontainers/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
FROM --platform=linux/arm64 ubuntu:24.04

RUN apt-get update && apt-get install -y tzdata

ENV INSIDE_TESTCONTAINERS=true

# 4: Arctic/Longyearbyen
RUN echo 4 | dpkg-reconfigure tzdata

WORKDIR /app
106 changes: 106 additions & 0 deletions core/jvm/test/testcontainers/TimeZoneConfigurationTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Copyright 2019-2025 JetBrains s.r.o. and contributors.
* Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file.
*/

package testcontainers

import org.junit.jupiter.api.BeforeAll
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import org.junit.jupiter.api.Test
import org.slf4j.LoggerFactory
import org.testcontainers.containers.Container.ExecResult

@Testcontainers
class TimeZoneConfigurationTest {

@Container
private val container = createTimezoneTestContainer()

@Test
fun defaultTimeZoneTest() {
assertExecSuccess(container.execDefaultTimeZoneTest())
}

@Test
fun debianCopyTimeZoneTest() {
assertExecSuccess(container.execDebianCopyTimeZoneTest())
}

@Test
fun timezoneMismatchTest() {
assertExecSuccess(container.execTimezoneMismatchTest())
}

@Test
fun missingEtcTimezoneTest() {
assertExecSuccess(container.execMissingEtcTimezoneTest())
}

@Test
fun allTimeZoneFilesMissingTest() {
assertExecSuccess(container.execAllTimeZoneFilesMissingTest())
}

@Test
fun symlinkTimeZoneTest() {
assertExecSuccess(container.execSymlinkTimeZoneTest())
}

@Test
fun invalidTimezoneFormatTest() {
assertExecSuccess(container.execInvalidTimezoneFormatTest())
}

@Test
fun commonTimeZoneTests() {
assertExecSuccess(container.execCommonTimeZoneTests())
}

private fun assertExecSuccess(execResult: ExecResult) {
logger.info("Container stdout:\n${execResult.stdout}")
logger.info("Container stderr:\n${execResult.stderr}")
logger.info("Container exit code: ${execResult.exitCode}")

if (execResult.exitCode != 0) {
throw AssertionError(
"""
|Command execution failed with exit code ${execResult.exitCode}.
|Stdout:
|${execResult.stdout}
|Stderr:
|${execResult.stderr}
""".trimMargin()
)
}
}

companion object {
private val logger = LoggerFactory.getLogger(TimeZoneConfigurationTest::class.java)

@JvmStatic
@BeforeAll
fun buildTestBinary() {
logger.info("Building test binary...")

val process = ProcessBuilder()
.command("../gradlew", "linkDebugTestLinuxArm64")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, the process is:

  • Gradle runs our tests.
  • Linux tests include the per-container checks, but if they detect they are not running inside the development container, they will not run.
  • JVM tests call Gradle to build Linux executables.
  • Having built Linux executables, JVM tests launch a container per test and run the created binaries there, specifying that only a single test should run.

This feels way too complicated to me:

  1. I believe tests that should run in a container should be in a separate binary from normal tests. This is the better separation of responsibilities: this way, it will be possible to compile the compact, to-the-point test suite for containers without also compiling all Linux tests, and vice versa, so there is less warning cross-pollution, better parallelization of tests, etc.
  2. Running Gradle from the JVM (and then using hardcoded paths to access the result) seems suboptimal: the logging logic is custom; I don't know how build timeout logic will work with this indirection; this is reinventing the wheel. Why not introduce a proper dependency in Gradle instead? There can be a separate test run with its own dependencies that depends on the binary compilation task outputs and (probably) accepts the path to the resulting binary as a system property or something like that. This way, the build logic stays in Gradle, without leaking into tests; no need to introduce any loggers and such.
  3. Whenever we change the JVM implementation, it makes sense to run all JVM tests in the core module to check that the implementation is still correct. However, with the containers being part of the JVM tests, this will no longer be as quick and straightforward, as it will take some extra time to run Linux tests for some reason. The test containers should be a separate test run, probably in another Gradle module even. We already have several modules purely for testing some specialized scenarios.

In essence, I propose the following:

  • All container-related test logic is moved into a separate Gradle module.
  • The logic of running the containers (be it test-containers or just pure Gradle code) depends (in the Gradle sense) on the binary containing the actual test logic. The logic of running the containers receives the path to the binary as a parameter.

The binary doesn't even necessarily have to use kotlin.test, it can be as simple as a fun main(args: Array<String>) that checks its argument to see which check to perform.

What we win:

  • Less intricate code interactions, better readability, improved code locality.
  • The CI is one step closer to our build logic and can control/analyze our tests better.
  • Flexibility: if this is a separate module, we can easily conditionally enable or disable container-based tests altogether.
  • Improved build parallelization.
  • The dependency on loggers can be removed.
  • No need for an environment variable check.

.redirectErrorStream(true)
.start()

process.inputStream.bufferedReader().use { reader ->
reader.lines().forEach { line ->
logger.info("Build: {}", line)
}
}

val exitCode = process.waitFor()
if (exitCode != 0) {
throw IllegalStateException("Failed to build test binary: exit code $exitCode")
}

logger.info("Test binary built successfully")
}
}
}
84 changes: 84 additions & 0 deletions core/jvm/test/testcontainers/TimezoneTestContainer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
* Copyright 2019-2025 JetBrains s.r.o. and contributors.
* Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file.
*/

package testcontainers

import org.testcontainers.containers.BindMode
import org.testcontainers.containers.Container.ExecResult
import org.testcontainers.containers.GenericContainer
import org.testcontainers.images.builder.ImageFromDockerfile
import java.nio.file.Path
import java.nio.file.Paths

class TimezoneTestContainer(dockerfilePath: Path, binaryDir: String, imageName: String) :
GenericContainer<TimezoneTestContainer>(ImageFromDockerfile(imageName).withDockerfile(dockerfilePath)) {

init {
withCommand("tail -f /dev/null")
withFileSystemBind(binaryDir, "/app", BindMode.READ_WRITE)
}

fun execDefaultTimeZoneTest(): ExecResult {
return execTest("kotlinx.datetime.test.TimeZoneLinuxNativeTest.defaultTimeZoneTest")
}

fun execDebianCopyTimeZoneTest(): ExecResult {
exec("rm /etc/localtime")
exec("cp /usr/share/zoneinfo/Europe/Berlin /etc/localtime")
exec("echo 'Europe/Berlin' > /etc/timezone")
return execTest("kotlinx.datetime.test.TimeZoneLinuxNativeTest.debianCopyTimeZoneTest")
}

fun execTimezoneMismatchTest(): ExecResult {
exec("rm -f /etc/localtime")
exec("cp /usr/share/zoneinfo/Europe/Berlin /etc/localtime")
exec("echo 'Europe/Paris' > /etc/timezone")
return execTest("kotlinx.datetime.test.TimeZoneLinuxNativeTest.timezoneMismatchTest")
}

fun execMissingEtcTimezoneTest(): ExecResult {
exec("rm -f /etc/timezone")
return execTest("kotlinx.datetime.test.TimeZoneLinuxNativeTest.missingEtcTimezoneTest")
}

fun execAllTimeZoneFilesMissingTest(): ExecResult {
exec("rm -f /etc/localtime")
exec("rm -f /etc/timezone")
return execTest("kotlinx.datetime.test.TimeZoneLinuxNativeTest.allTimeZoneFilesMissingTest")
}

fun execSymlinkTimeZoneTest(): ExecResult {
exec("rm -f /etc/localtime")
exec("ln -sf /usr/share/zoneinfo/Europe/Paris /etc/localtime")
exec("echo 'Europe/Paris' > /etc/timezone")
return execTest("kotlinx.datetime.test.TimeZoneLinuxNativeTest.symlinkTimeZoneTest")
}

fun execInvalidTimezoneFormatTest(): ExecResult {
exec("rm -f /etc/localtime")
exec("cp /usr/share/zoneinfo/Europe/Berlin /etc/localtime")
exec("echo 'Invalid/Timezone/Format' > /etc/timezone")
return execTest("kotlinx.datetime.test.TimeZoneLinuxNativeTest.invalidTimezoneFormatTest")
}

fun execCommonTimeZoneTests(): ExecResult {
exec("rm -f /etc/localtime")
exec("cp /usr/share/zoneinfo/$(cat /etc/timezone) /etc/localtime")
return execTest("kotlinx.datetime.test.TimeZoneTest.*")
}

private fun execTest(testFilter: String): ExecResult =
exec("chmod +x /app/test.kexe && /app/test.kexe --ktest_filter=$testFilter")

private fun exec(command: String): ExecResult = execInContainer("bash", "-c", command)
}

fun createTimezoneTestContainer(): TimezoneTestContainer {
return TimezoneTestContainer(
Paths.get("./jvm/test/testcontainers/Dockerfile"),
"./build/bin/linuxArm64/debugTest/",
"ubuntu-arctic-longyearbyen"
)
}
16 changes: 16 additions & 0 deletions core/jvm/testResources/logback-test.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>

<logger name="org.testcontainers" level="INFO"/>
<logger name="com.github.dockerjava" level="WARN"/>
<logger name="testcontainers" level="DEBUG"/>

<root level="INFO">
<appender-ref ref="CONSOLE" />
</root>
</configuration>
42 changes: 37 additions & 5 deletions core/linux/src/internal/TimeZoneNative.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

package kotlinx.datetime.internal

import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.toKString
import kotlinx.datetime.IllegalTimeZoneException
import kotlinx.datetime.TimeZone

Expand All @@ -16,10 +18,40 @@ internal actual fun getAvailableZoneIds(): Set<String> =

private val tzdb = runCatching { TzdbOnFilesystem() }

@OptIn(ExperimentalForeignApi::class)
private fun getTimezoneFromEtcTimezone(): String? {
val timezoneContent = Path.fromString("/etc/timezone").readBytes()?.toKString()?.trim() ?: return null
val zoneId = chaseSymlinks("/usr/share/zoneinfo/$timezoneContent")
?.splitTimeZonePath()?.second?.toString()
?: return null

val zoneInfoBytes = Path.fromString("/usr/share/zoneinfo/$zoneId").readBytes() ?: return null
val localtimeBytes = Path.fromString("/etc/localtime").readBytes() ?: return null

if (!localtimeBytes.contentEquals(zoneInfoBytes)) {
val displayTimezone = when (timezoneContent) {
zoneId -> "'$zoneId'"
else -> "'$timezoneContent' (resolved to '$zoneId')"
}
throw IllegalTimeZoneException(
"Timezone mismatch: /etc/timezone specifies $displayTimezone " +
"but /etc/localtime content differs from /usr/share/zoneinfo/$zoneId"
)
}

return zoneId
}

internal actual fun currentSystemDefaultZone(): Pair<String, TimeZone?> {
// according to https://www.man7.org/linux/man-pages/man5/localtime.5.html, when there is no symlink, UTC is used
val zonePath = currentSystemTimeZonePath ?: return "Z" to null
val zoneId = zonePath.splitTimeZonePath()?.second?.toString()
?: throw IllegalTimeZoneException("Could not determine the timezone ID that `$zonePath` corresponds to")
return zoneId to null
}

zonePath.splitTimeZonePath()?.second?.toString()?.let { zoneId ->
return zoneId to null
}

getTimezoneFromEtcTimezone()?.let { zoneId ->
return zoneId to null
}

throw IllegalTimeZoneException("Could not determine the timezone ID that `$zonePath` corresponds to")
}
21 changes: 21 additions & 0 deletions core/linux/test/TestcontainersSupport.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* Copyright 2019-2025 JetBrains s.r.o. and contributors.
* Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file.
*/

package kotlinx.datetime.test

import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.toKString
import platform.posix.getenv

object Testcontainers {

@OptIn(ExperimentalForeignApi::class)
val available: Boolean
get() = getenv("INSIDE_TESTCONTAINERS")?.toKString()?.toBoolean() == true

inline fun runIfAvailable(block: () -> Unit) {
if (available) block() else println("[----------] Skipping test that requires testcontainers...")
}
}
Loading