0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Laravelのstub機能でテスト自動生成をやってみた

0
Posted at

はじめに

stub機能 × Artisanコマンドでテスト自動生成できるとのことでやってみました。

やること

生成対象データ(自由な形式)
        ↓
パターン分類
        ↓
stubテンプレートに置き換え
        ↓
テストコードを吐き出す

という流れで、似たようなテストコードを大量に安定して生成できる仕組みです。

実装方針

今回の仕組みの骨格はこの4つ。

  1. 共通テンプレート(テストクラス全体の枠組み)
  2. パターン別テンプレート(テストメソッドの型)
  3. 入力データの読み込み
  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
        );
    }
}

「まず生成 → 必要なら微調整」という流れにできるので、手書きするよりもかなり楽になります。

まとめ

今回のテスト自動生成は、一度仕組み化してしまえばサクッとテストが増やせるしチームのテストスタイルが統一されるというメリットを感じています。

参考記事

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?