|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +Generates JUnit4 + MockK unit tests for Kotlin files using the Claude API. |
| 4 | +
|
| 5 | +Usage: |
| 6 | + python generate_tests.py <file1.kt> [file2.kt ...] |
| 7 | +
|
| 8 | +Or via environment variable: |
| 9 | + CHANGED_FILES="file1.kt\nfile2.kt" python generate_tests.py |
| 10 | +""" |
| 11 | + |
| 12 | +import os |
| 13 | +import sys |
| 14 | +import re |
| 15 | +import anthropic |
| 16 | + |
| 17 | + |
| 18 | +def extract_package(content: str) -> str: |
| 19 | + match = re.search(r"^package\s+([\w.]+)", content, re.MULTILINE) |
| 20 | + return match.group(1) if match else "" |
| 21 | + |
| 22 | + |
| 23 | +def source_to_test_path(source_path: str) -> str: |
| 24 | + """ |
| 25 | + Maps a production source path to its expected test path. |
| 26 | +
|
| 27 | + Examples: |
| 28 | + src/commonMain/kotlin/com/...Foo.kt -> src/test/java/com/...FooTest.kt |
| 29 | + src/androidMain/kotlin/com/...Bar.kt -> src/test/java/com/...BarTest.kt |
| 30 | + """ |
| 31 | + path = re.sub( |
| 32 | + r"src/(common|android)Main/kotlin/", |
| 33 | + "src/test/java/", |
| 34 | + source_path, |
| 35 | + ) |
| 36 | + return path[:-3] + "Test.kt" |
| 37 | + |
| 38 | + |
| 39 | +def file_name_without_ext(path: str) -> str: |
| 40 | + return os.path.basename(path).replace(".kt", "") |
| 41 | + |
| 42 | + |
| 43 | +def build_prompt(file_path: str, content: str, package_name: str) -> str: |
| 44 | + class_name = file_name_without_ext(file_path) |
| 45 | + return f"""You are an expert Kotlin developer specializing in unit testing for Kotlin Multiplatform (KMP) projects. |
| 46 | +
|
| 47 | +## Project context |
| 48 | +- **CraftD** is a Server Driven UI library for Android/KMP. |
| 49 | +- The server returns a list of `SimpleProperties` (key + value as `JsonElement`). |
| 50 | +- The lib renders native components dynamically via `CraftDBuilder`. |
| 51 | +- Stack: Kotlin, JUnit4, MockK, kotlinx.serialization, Jetpack Compose. |
| 52 | +
|
| 53 | +## Your task |
| 54 | +Generate comprehensive unit tests for the Kotlin source file below. |
| 55 | +
|
| 56 | +## Requirements |
| 57 | +- Framework: **JUnit4** (`@RunWith(JUnit4::class)`) + **MockK** (`io.mockk.*`) |
| 58 | +- Package: `{package_name}` |
| 59 | +- Test class name: `{class_name}Test` |
| 60 | +- Test method style: backtick notation — e.g. `` `given null JsonElement when convertToElement then returns null`() `` |
| 61 | +- **Data classes**: test construction with all params, default values, `copy()`, `equals`/`hashCode`. |
| 62 | +- **Extension functions with JsonElement**: test null input, valid JSON deserialization, malformed/wrong-type JSON (expect `null` return). |
| 63 | +- **DiffUtil callbacks**: test `areItemsTheSame` (same key, different key) and `areContentsTheSame` (equal value, different value, `AbstractMap` comparison edge case). |
| 64 | +- **Enums**: verify all enum constant names exist using `enumValueOf`. |
| 65 | +- **Mapper functions** (`toSimpleProperties`, `toListSimpleProperties`): test with sample data, empty list, and null-value fields. |
| 66 | +- Do **not** include explanations or markdown fences. |
| 67 | +- Output **only** the complete Kotlin test file, starting with `package {package_name}`. |
| 68 | +
|
| 69 | +## Source file |
| 70 | +Path: `{file_path}` |
| 71 | +
|
| 72 | +```kotlin |
| 73 | +{content} |
| 74 | +``` |
| 75 | +""" |
| 76 | + |
| 77 | + |
| 78 | +def call_claude(client: anthropic.Anthropic, prompt: str) -> str: |
| 79 | + message = client.messages.create( |
| 80 | + model="claude-haiku-4-5-20251001", |
| 81 | + max_tokens=4096, |
| 82 | + messages=[{"role": "user", "content": prompt}], |
| 83 | + ) |
| 84 | + return message.content[0].text |
| 85 | + |
| 86 | + |
| 87 | +def write_github_output(key: str, value: str) -> None: |
| 88 | + output_file = os.environ.get("GITHUB_OUTPUT", "") |
| 89 | + if not output_file: |
| 90 | + return |
| 91 | + with open(output_file, "a") as f: |
| 92 | + if "\n" in value: |
| 93 | + f.write(f"{key}<<EOF\n{value}\nEOF\n") |
| 94 | + else: |
| 95 | + f.write(f"{key}={value}\n") |
| 96 | + |
| 97 | + |
| 98 | +def write_step_summary(lines: list) -> None: |
| 99 | + summary_file = os.environ.get("GITHUB_STEP_SUMMARY", "") |
| 100 | + if not summary_file: |
| 101 | + return |
| 102 | + with open(summary_file, "a") as f: |
| 103 | + f.write("\n".join(lines) + "\n") |
| 104 | + |
| 105 | + |
| 106 | +def main() -> None: |
| 107 | + api_key = os.environ.get("ANTHROPIC_API_KEY") |
| 108 | + if not api_key: |
| 109 | + print("ERROR: ANTHROPIC_API_KEY is not set.", file=sys.stderr) |
| 110 | + sys.exit(1) |
| 111 | + |
| 112 | + # Collect files from CLI args or CHANGED_FILES env var |
| 113 | + if len(sys.argv) > 1: |
| 114 | + raw = " ".join(sys.argv[1:]) |
| 115 | + files = [f.strip() for f in re.split(r"[\s]+", raw) if f.strip()] |
| 116 | + else: |
| 117 | + files = [ |
| 118 | + f.strip() |
| 119 | + for f in os.environ.get("CHANGED_FILES", "").splitlines() |
| 120 | + if f.strip() |
| 121 | + ] |
| 122 | + |
| 123 | + if not files: |
| 124 | + print("No Kotlin files to process.") |
| 125 | + write_github_output("generated_count", "0") |
| 126 | + sys.exit(0) |
| 127 | + |
| 128 | + client = anthropic.Anthropic(api_key=api_key) |
| 129 | + generated = [] |
| 130 | + |
| 131 | + for file_path in files: |
| 132 | + if not os.path.isfile(file_path): |
| 133 | + print(f"[SKIP] File not found: {file_path}") |
| 134 | + continue |
| 135 | + |
| 136 | + test_path = source_to_test_path(file_path) |
| 137 | + |
| 138 | + if os.path.isfile(test_path): |
| 139 | + print(f"[SKIP] Test already exists: {test_path}") |
| 140 | + continue |
| 141 | + |
| 142 | + print(f"[GEN] {file_path}") |
| 143 | + print(f" -> {test_path}") |
| 144 | + |
| 145 | + with open(file_path, "r", encoding="utf-8") as f: |
| 146 | + content = f.read() |
| 147 | + |
| 148 | + package_name = extract_package(content) |
| 149 | + if not package_name: |
| 150 | + print(f"[WARN] Could not extract package from {file_path}, skipping.") |
| 151 | + continue |
| 152 | + |
| 153 | + try: |
| 154 | + prompt = build_prompt(file_path, content, package_name) |
| 155 | + test_content = call_claude(client, prompt) |
| 156 | + |
| 157 | + os.makedirs(os.path.dirname(test_path), exist_ok=True) |
| 158 | + with open(test_path, "w", encoding="utf-8") as f: |
| 159 | + f.write(test_content) |
| 160 | + |
| 161 | + generated.append( |
| 162 | + { |
| 163 | + "source": file_path, |
| 164 | + "test": test_path, |
| 165 | + "test_name": file_name_without_ext(file_path) + "Test.kt", |
| 166 | + } |
| 167 | + ) |
| 168 | + print(f"[OK] Written: {test_path}\n") |
| 169 | + |
| 170 | + except Exception as exc: |
| 171 | + print(f"[ERROR] Failed to generate test for {file_path}: {exc}", file=sys.stderr) |
| 172 | + |
| 173 | + count = len(generated) |
| 174 | + print(f"\nDone. Generated {count} test file(s).") |
| 175 | + |
| 176 | + write_github_output("generated_count", str(count)) |
| 177 | + |
| 178 | + if generated: |
| 179 | + test_files = "\n".join(item["test"] for item in generated) |
| 180 | + covered_names = ", ".join(item["test_name"] for item in generated) |
| 181 | + write_github_output("test_files", test_files) |
| 182 | + write_github_output("covered_names", covered_names) |
| 183 | + |
| 184 | + summary_lines = [ |
| 185 | + "## Auto-generated Unit Tests", |
| 186 | + "", |
| 187 | + "| Source file | Generated test |", |
| 188 | + "| --- | --- |", |
| 189 | + ] |
| 190 | + for item in generated: |
| 191 | + summary_lines.append(f"| `{item['source']}` | `{item['test']}` |") |
| 192 | + write_step_summary(summary_lines) |
| 193 | + |
| 194 | + |
| 195 | +if __name__ == "__main__": |
| 196 | + main() |
0 commit comments