diff --git a/README.md b/README.md
index d5c97b5..fb02290 100644
--- a/README.md
+++ b/README.md
@@ -27,6 +27,7 @@
Badge •
Data Refresh •
FAQ •
+ Development Verification •
Contributing •
Roadmap •
License •
@@ -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..b537c52
--- /dev/null
+++ b/src/test/java/com/gitranker/api/testsupport/DockerPreflightCheck.java
@@ -0,0 +1,167 @@
+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;
+
+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) {
+ if (exitCode == 0) {
+ throw new IllegalArgumentException("Failure result must have non-zero exit code");
+ }
+ 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);
+ processBuilder.redirectErrorStream(false);
+
+ try {
+ Process process = processBuilder.start();
+ 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());
+ } catch (InterruptedException exception) {
+ Thread.currentThread().interrupt();
+ 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
new file mode 100644
index 0000000..fa043e0
--- /dev/null
+++ b/src/test/java/com/gitranker/api/testsupport/DockerPreflightCheckTest.java
@@ -0,0 +1,88 @@
+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;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+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.");
+ }
+
+ @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");
+ }
+}
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);
+ }
+}