Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
<a href="#badge">Badge</a> •
<a href="#data-refresh">Data Refresh</a> •
<a href="#faq">FAQ</a> •
<a href="#development-verification">Development Verification</a> •
<a href="#contributing">Contributing</a> •
<a href="#roadmap">Roadmap</a> •
<a href="#license">License</a> •
Expand Down Expand Up @@ -282,6 +283,20 @@ GitHub 프로필(`README.md`)에 동적 배지를 삽입해, 현재 티어와

---

<a id="development-verification"></a>
## 🧪 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`를 다시 실행하세요.

---

<a id="contributing"></a>
## 🤝 Contributing

Expand Down
11 changes: 11 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
}
Expand Down
167 changes: 167 additions & 0 deletions src/test/java/com/gitranker/api/testsupport/DockerPreflightCheck.java
Original file line number Diff line number Diff line change
@@ -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<String> DOCKER_CONTEXT_COMMAND = List.of("docker", "context", "show");
private static final List<String> 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<String> 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<String> command) {
ProcessBuilder processBuilder = new ProcessBuilder(command);
processBuilder.redirectErrorStream(false);

try {
Process process = processBuilder.start();
CompletableFuture<String> stdoutFuture =
CompletableFuture.supplyAsync(() -> readStream(process.getInputStream()));
CompletableFuture<String> 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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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");
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading