From 67234f61247e25139342cc91e16b96664aaddc1e Mon Sep 17 00:00:00 2001 From: hyoseok Date: Thu, 26 Mar 2026 13:15:38 +0900 Subject: [PATCH 1/2] test: harden docker preflight for integration verification --- README.md | 15 ++ build.gradle | 11 ++ .../api/testsupport/DockerPreflightCheck.java | 148 ++++++++++++++++++ .../testsupport/DockerPreflightCheckTest.java | 79 ++++++++++ .../api/testsupport/DockerPreflightMain.java | 18 +++ 5 files changed, 271 insertions(+) create mode 100644 src/test/java/com/gitranker/api/testsupport/DockerPreflightCheck.java create mode 100644 src/test/java/com/gitranker/api/testsupport/DockerPreflightCheckTest.java create mode 100644 src/test/java/com/gitranker/api/testsupport/DockerPreflightMain.java diff --git a/README.md b/README.md index d5c97b5..fb02290 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ BadgeData RefreshFAQ • + Development VerificationContributingRoadmapLicense • @@ -282,6 +283,20 @@ GitHub 프로필(`README.md`)에 동적 배지를 삽입해, 현재 티어와 --- + +## 🧪 Development Verification + +로컬 변경 후 backend 검증은 아래 순서로 실행합니다. + +- `./gradlew test jacocoTestCoverageVerification`: 단위 테스트와 커버리지 검증을 실행합니다. Docker가 없어도 동작합니다. +- `./gradlew verifyDockerAvailable`: Testcontainers 기반 통합 테스트 전에 Docker CLI와 daemon 연결 가능 여부를 fail-fast로 확인합니다. +- `./gradlew integrationTest`: `verifyDockerAvailable`를 먼저 실행한 뒤 `*IT` 통합 테스트를 수행합니다. Docker가 준비되지 않았으면 환경 문제로 즉시 실패합니다. + +> [!TIP] +> `verifyDockerAvailable`가 실패하면 먼저 `docker version`이 성공하는지 확인한 뒤 `./gradlew integrationTest`를 다시 실행하세요. + +--- + ## 🤝 Contributing diff --git a/build.gradle b/build.gradle index 2cfa78d..f0dd51c 100644 --- a/build.gradle +++ b/build.gradle @@ -74,6 +74,15 @@ tasks.named('processResources') { } } +tasks.register('verifyDockerAvailable', JavaExec) { + group = 'verification' + description = 'Fails fast when Docker is unavailable for Testcontainers-based integration tests.' + mainClass = 'com.gitranker.api.testsupport.DockerPreflightMain' + classpath = sourceSets.test.runtimeClasspath + + dependsOn tasks.named('testClasses') +} + // 통합 테스트: *IT.java만 실행 (Docker/Testcontainers 필요) // check 라이프사이클에 포함하지 않음 — Docker 없는 환경에서 build가 실패하지 않도록 의도적 제외 // CI에서는 별도 단계로 명시적 실행: ./gradlew integrationTest @@ -86,6 +95,8 @@ tasks.register('integrationTest', Test) { testClassesDirs = sourceSets.test.output.classesDirs classpath = sourceSets.test.runtimeClasspath + dependsOn tasks.named('verifyDockerAvailable') + // Docker 29+는 최소 API 1.44를 요구하지만 docker-java 3.4.0은 기본값 1.32로 요청함 systemProperty 'api.version', '1.44' } diff --git a/src/test/java/com/gitranker/api/testsupport/DockerPreflightCheck.java b/src/test/java/com/gitranker/api/testsupport/DockerPreflightCheck.java new file mode 100644 index 0000000..df0be5e --- /dev/null +++ b/src/test/java/com/gitranker/api/testsupport/DockerPreflightCheck.java @@ -0,0 +1,148 @@ +package com.gitranker.api.testsupport; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Objects; + +public final class DockerPreflightCheck { + + private static final List DOCKER_CONTEXT_COMMAND = List.of("docker", "context", "show"); + private static final List DOCKER_VERSION_COMMAND = + List.of("docker", "version", "--format", "{{.Server.APIVersion}}"); + + private final CommandRunner commandRunner; + + public DockerPreflightCheck(CommandRunner commandRunner) { + this.commandRunner = Objects.requireNonNull(commandRunner); + } + + public DockerPreflightResult run() { + CommandResult contextResult = commandRunner.run(DOCKER_CONTEXT_COMMAND); + String context = normalizedOrFallback(contextResult.stdout(), "unavailable"); + + CommandResult versionResult = commandRunner.run(DOCKER_VERSION_COMMAND); + if (versionResult.isSuccess()) { + String apiVersion = normalizedOrFallback(versionResult.stdout(), "unknown"); + return DockerPreflightResult.success( + "Docker preflight passed. context=%s serverApiVersion=%s".formatted(context, apiVersion)); + } + + return DockerPreflightResult.failure(buildFailureMessage(context, versionResult)); + } + + private String buildFailureMessage(String context, CommandResult versionResult) { + String diagnostic = versionResult.primaryDiagnostic(); + String cause = determineCause(versionResult); + String firstStep = isDockerCliMissing(versionResult) + ? "- Install Docker Desktop, OrbStack, or another compatible Docker runtime." + : "- Start Docker Desktop, OrbStack, or another compatible Docker runtime and confirm `docker version` succeeds."; + + return """ + Docker preflight failed for Testcontainers integration tests. + Cause: %s + Context: %s + Details: %s + Next steps: + %s + - If you only need code-level verification, run `./gradlew test jacocoTestCoverageVerification`. + - Re-run `./gradlew integrationTest` after Docker is available. + """.formatted(cause, context, diagnostic, firstStep); + } + + private String determineCause(CommandResult versionResult) { + if (isDockerCliMissing(versionResult)) { + return "Docker CLI is not installed or not available on PATH."; + } + if (isDockerDaemonUnavailable(versionResult)) { + return "Docker daemon is not reachable from the current shell."; + } + return "Docker returned a non-zero exit code before Testcontainers could start."; + } + + private boolean isDockerCliMissing(CommandResult versionResult) { + String diagnostic = versionResult.primaryDiagnostic().toLowerCase(); + return versionResult.exitCode() == 127 + || diagnostic.contains("command not found") + || diagnostic.contains("no such file or directory"); + } + + private boolean isDockerDaemonUnavailable(CommandResult versionResult) { + String diagnostic = versionResult.primaryDiagnostic().toLowerCase(); + return diagnostic.contains("cannot connect to the docker daemon") + || diagnostic.contains("is the docker daemon running") + || diagnostic.contains("error during connect"); + } + + private String normalizedOrFallback(String value, String fallback) { + String normalized = value == null ? "" : value.trim(); + return normalized.isEmpty() ? fallback : normalized; + } +} + +@FunctionalInterface +interface CommandRunner { + + CommandResult run(List command); +} + +record CommandResult(int exitCode, String stdout, String stderr) { + + static CommandResult success(String stdout, String stderr) { + return new CommandResult(0, stdout, stderr); + } + + static CommandResult failure(int exitCode, String stdout, String stderr) { + return new CommandResult(exitCode, stdout, stderr); + } + + boolean isSuccess() { + return exitCode == 0; + } + + String primaryDiagnostic() { + String stderrText = stderr == null ? "" : stderr.trim(); + if (!stderrText.isEmpty()) { + return stderrText; + } + + String stdoutText = stdout == null ? "" : stdout.trim(); + if (!stdoutText.isEmpty()) { + return stdoutText; + } + + return "No additional docker diagnostic output was produced."; + } +} + +record DockerPreflightResult(boolean isSuccess, String message) { + + static DockerPreflightResult success(String message) { + return new DockerPreflightResult(true, message); + } + + static DockerPreflightResult failure(String message) { + return new DockerPreflightResult(false, message); + } +} + +final class ProcessCommandRunner implements CommandRunner { + + @Override + public CommandResult run(List command) { + ProcessBuilder processBuilder = new ProcessBuilder(command); + + try { + Process process = processBuilder.start(); + String stdout = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + String stderr = new String(process.getErrorStream().readAllBytes(), StandardCharsets.UTF_8); + int exitCode = process.waitFor(); + return new CommandResult(exitCode, stdout, stderr); + } catch (IOException exception) { + return CommandResult.failure(127, "", exception.getMessage()); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return CommandResult.failure(130, "", "Interrupted while running command: " + String.join(" ", command)); + } + } +} diff --git a/src/test/java/com/gitranker/api/testsupport/DockerPreflightCheckTest.java b/src/test/java/com/gitranker/api/testsupport/DockerPreflightCheckTest.java new file mode 100644 index 0000000..3c2a6fb --- /dev/null +++ b/src/test/java/com/gitranker/api/testsupport/DockerPreflightCheckTest.java @@ -0,0 +1,79 @@ +package com.gitranker.api.testsupport; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class DockerPreflightCheckTest { + + @Test + @DisplayName("docker daemon이 reachable이면 성공 결과를 반환한다") + void should_returnSuccess_when_dockerDaemonIsReachable() { + DockerPreflightCheck check = new DockerPreflightCheck(command -> { + if (command.equals(List.of("docker", "context", "show"))) { + return CommandResult.success("orbstack\n", ""); + } + if (command.equals(List.of("docker", "version", "--format", "{{.Server.APIVersion}}"))) { + return CommandResult.success("1.51\n", ""); + } + throw new IllegalArgumentException("Unexpected command: " + command); + }); + + DockerPreflightResult result = check.run(); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.message()) + .isEqualTo("Docker preflight passed. context=orbstack serverApiVersion=1.51"); + } + + @Test + @DisplayName("docker daemon에 연결할 수 없으면 환경 문제를 설명하는 실패 결과를 반환한다") + void should_returnHelpfulFailure_when_dockerDaemonIsUnavailable() { + DockerPreflightCheck check = new DockerPreflightCheck(command -> { + if (command.equals(List.of("docker", "context", "show"))) { + return CommandResult.success("orbstack\n", ""); + } + if (command.equals(List.of("docker", "version", "--format", "{{.Server.APIVersion}}"))) { + return CommandResult.failure( + 1, + "", + "Cannot connect to the Docker daemon at unix:///tmp/docker.sock. Is the docker daemon running?"); + } + throw new IllegalArgumentException("Unexpected command: " + command); + }); + + DockerPreflightResult result = check.run(); + + assertThat(result.isSuccess()).isFalse(); + assertThat(result.message()).contains("Docker preflight failed for Testcontainers integration tests."); + assertThat(result.message()).contains("Cause: Docker daemon is not reachable from the current shell."); + assertThat(result.message()).contains("Context: orbstack"); + assertThat(result.message()).contains("docker version"); + assertThat(result.message()).contains("./gradlew test jacocoTestCoverageVerification"); + assertThat(result.message()).contains("./gradlew integrationTest"); + } + + @Test + @DisplayName("docker CLI가 없으면 설치 전제조건을 안내하는 실패 결과를 반환한다") + void should_returnHelpfulFailure_when_dockerCliIsMissing() { + DockerPreflightCheck check = new DockerPreflightCheck(command -> { + if (command.equals(List.of("docker", "context", "show"))) { + return CommandResult.failure(127, "", "docker: command not found"); + } + if (command.equals(List.of("docker", "version", "--format", "{{.Server.APIVersion}}"))) { + return CommandResult.failure(127, "", "docker: command not found"); + } + throw new IllegalArgumentException("Unexpected command: " + command); + }); + + DockerPreflightResult result = check.run(); + + assertThat(result.isSuccess()).isFalse(); + assertThat(result.message()).contains("Cause: Docker CLI is not installed or not available on PATH."); + assertThat(result.message()).contains("Context: unavailable"); + assertThat(result.message()).contains("Install Docker Desktop, OrbStack, or another compatible Docker runtime."); + } +} diff --git a/src/test/java/com/gitranker/api/testsupport/DockerPreflightMain.java b/src/test/java/com/gitranker/api/testsupport/DockerPreflightMain.java new file mode 100644 index 0000000..7a3e217 --- /dev/null +++ b/src/test/java/com/gitranker/api/testsupport/DockerPreflightMain.java @@ -0,0 +1,18 @@ +package com.gitranker.api.testsupport; + +public final class DockerPreflightMain { + + private DockerPreflightMain() { + } + + public static void main(String[] args) { + DockerPreflightResult result = new DockerPreflightCheck(new ProcessCommandRunner()).run(); + if (result.isSuccess()) { + System.out.println(result.message()); + return; + } + + System.err.println(result.message()); + System.exit(1); + } +} From e31a9964e7a28d0d1e56094466d8cff74dd8126a Mon Sep 17 00:00:00 2001 From: hyoseok Date: Thu, 26 Mar 2026 13:29:10 +0900 Subject: [PATCH 2/2] test: address docker preflight review notes --- .../api/testsupport/DockerPreflightCheck.java | 23 +++++++++++++++++-- .../testsupport/DockerPreflightCheckTest.java | 9 ++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/gitranker/api/testsupport/DockerPreflightCheck.java b/src/test/java/com/gitranker/api/testsupport/DockerPreflightCheck.java index df0be5e..b537c52 100644 --- a/src/test/java/com/gitranker/api/testsupport/DockerPreflightCheck.java +++ b/src/test/java/com/gitranker/api/testsupport/DockerPreflightCheck.java @@ -1,7 +1,10 @@ package com.gitranker.api.testsupport; +import java.io.InputStream; import java.io.IOException; +import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; import java.util.List; import java.util.Objects; @@ -93,6 +96,9 @@ static CommandResult success(String stdout, String stderr) { } static CommandResult failure(int exitCode, String stdout, String stderr) { + if (exitCode == 0) { + throw new IllegalArgumentException("Failure result must have non-zero exit code"); + } return new CommandResult(exitCode, stdout, stderr); } @@ -131,12 +137,17 @@ final class ProcessCommandRunner implements CommandRunner { @Override public CommandResult run(List command) { ProcessBuilder processBuilder = new ProcessBuilder(command); + processBuilder.redirectErrorStream(false); try { Process process = processBuilder.start(); - String stdout = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); - String stderr = new String(process.getErrorStream().readAllBytes(), StandardCharsets.UTF_8); + CompletableFuture stdoutFuture = + CompletableFuture.supplyAsync(() -> readStream(process.getInputStream())); + CompletableFuture stderrFuture = + CompletableFuture.supplyAsync(() -> readStream(process.getErrorStream())); int exitCode = process.waitFor(); + String stdout = stdoutFuture.join(); + String stderr = stderrFuture.join(); return new CommandResult(exitCode, stdout, stderr); } catch (IOException exception) { return CommandResult.failure(127, "", exception.getMessage()); @@ -145,4 +156,12 @@ public CommandResult run(List command) { return CommandResult.failure(130, "", "Interrupted while running command: " + String.join(" ", command)); } } + + private String readStream(InputStream inputStream) { + try { + return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new UncheckedIOException(exception); + } + } } diff --git a/src/test/java/com/gitranker/api/testsupport/DockerPreflightCheckTest.java b/src/test/java/com/gitranker/api/testsupport/DockerPreflightCheckTest.java index 3c2a6fb..fa043e0 100644 --- a/src/test/java/com/gitranker/api/testsupport/DockerPreflightCheckTest.java +++ b/src/test/java/com/gitranker/api/testsupport/DockerPreflightCheckTest.java @@ -6,6 +6,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class DockerPreflightCheckTest { @@ -76,4 +77,12 @@ void should_returnHelpfulFailure_when_dockerCliIsMissing() { assertThat(result.message()).contains("Context: unavailable"); assertThat(result.message()).contains("Install Docker Desktop, OrbStack, or another compatible Docker runtime."); } + + @Test + @DisplayName("failure result는 zero exit code를 허용하지 않는다") + void should_rejectZeroExitCode_when_creatingFailureResult() { + assertThatThrownBy(() -> CommandResult.failure(0, "", "unexpected")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Failure result must have non-zero exit code"); + } }