はじめに
stub機能 × Artisanコマンドでテスト自動生成できるとのことでやってみました。
やること
生成対象データ(自由な形式)
↓
パターン分類
↓
stubテンプレートに置き換え
↓
テストコードを吐き出す
という流れで、似たようなテストコードを大量に安定して生成できる仕組みです。
実装方針
今回の仕組みの骨格はこの4つ。
- 共通テンプレート(テストクラス全体の枠組み)
- パターン別テンプレート(テストメソッドの型)
- 入力データの読み込み
- Artisanコマンドで結合して出力
- ディレクトリ構造
project-root/
├── stubs/
│ ├── generated-test.stub
│ └── methods/
│ ├── http-status.stub
│ └── file-contains.stub
├── app/Console/Commands/
│ └── MakeGeneratedTest.php
└── tests/Feature/Generated/
共通テンプレート(generated-test.stub)
<?php
namespace Tests\Feature\Generated;
use Tests\TestCase;
class {{ class }} extends TestCase
{
protected function setUp(): void
{
parent::setUp();
}
{{ methods }}
}
クラスの枠だけを定義して、
実際のテストメソッドは後から差し込む形にしています。
HTTP系テンプレート(methods/http-status.stub)
public function test_http_status_{{ target_lower }}_{{ slug }}(): void
{
$response = $this->{{ http_method }}(
'{{ route_path }}',
{{ request_params }}
);
$response->assertStatus({{ expected_status }});
}
expected_status をプレースホルダーにしているので、
200以外のケースにも柔軟に対応できます。
ファイル検証テンプレート(methods/file-contains.stub)
public function test_file_contains_{{ target_lower }}_{{ slug }}(): void
{
$content = file_get_contents({{ file_path }});
$this->assertStringContainsString(
{{ expected_string }},
$content
);
}
Artisanコマンド
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
class MakeGeneratedTest extends Command
{
protected $signature = 'make:generated-test {target}';
protected $description = 'stubを使ってテストを自動生成する';
public function handle(): int
{
$target = $this->argument('target');
$className = ucfirst($target).'GeneratedTest';
$items = $this->loadItems($target);
$methods = $this->renderMethods($target, $items);
$baseStub = file_get_contents(
base_path('stubs/generated-test.stub')
);
$content = str_replace(
['{{ class }}', '{{ methods }}'],
[$className, $methods],
$baseStub
);
$dir = base_path('tests/Feature/Generated');
if (!is_dir($dir)) {
mkdir($dir, 0777, true);
}
file_put_contents("{$dir}/{$className}.php", $content);
$this->info("Generated: {$className}");
return self::SUCCESS;
}
private function loadItems(string $target): array
{
// 実際はJSONやDBから取得してもOK
return [
[
'template' => 'methods/http-status',
'method' => 'get',
'route' => '/articles',
'params' => '[]',
'status' => 200,
],
[
'template' => 'methods/file-contains',
'file' => "base_path('routes/web.php')",
'expected' => "'Route::'",
],
];
}
private function renderMethods(string $target, array $items): string
{
$methods = [];
foreach ($items as $item) {
$stubPath = base_path("stubs/{$item['template']}.stub");
if (!file_exists($stubPath)) {
continue;
}
$stub = file_get_contents($stubPath);
$slug = Str::snake($item['route'] ?? $item['file']);
$replacements = [
'{{ target_lower }}' => strtolower($target),
'{{ slug }}' => $slug,
'{{ http_method }}' => $item['method'] ?? 'get',
'{{ route_path }}' => $item['route'] ?? '/',
'{{ request_params }}' => $item['params'] ?? '[]',
'{{ expected_status }}' => $item['status'] ?? 200,
'{{ file_path }}' => $item['file'] ?? "''",
'{{ expected_string }}' => $item['expected'] ?? "''",
];
$methods[] = str_replace(
array_keys($replacements),
array_values($replacements),
$stub
);
}
return implode("\n", $methods);
}
}
- コマンド実行
php artisan make:generated-test Article
- 生成されるテスト
class ArticleGeneratedTest extends TestCase
{
public function test_http_status_article_articles(): void
{
$response = $this->get('/articles', []);
$response->assertStatus(200);
}
public function test_file_contains_article_routes_web(): void
{
$content = file_get_contents(base_path('routes/web.php'));
$this->assertStringContainsString(
'Route::',
$content
);
}
}
「まず生成 → 必要なら微調整」という流れにできるので、手書きするよりもかなり楽になります。
まとめ
今回のテスト自動生成は、一度仕組み化してしまえばサクッとテストが増やせるしチームのテストスタイルが統一されるというメリットを感じています。