Photoruction工事中!

Photoructionの開発ブログです!

ユニットテストで関数の再定義エラーになった話

はじめに

こんにちは!10月に入社しました、Build Webチームの岡本です!

4年ぶりくらいにPHPを用いて開発を行っています。これまでは、フロントエンドをメインにエンジニアをやってきました。

最近、個人的に学びが多いエラーに遭遇できました👏

とある機能の実装をしていたのですが、リリース準備の作業が迫ってきているというところで、ユニットテストでエラーが発生してしまいました。

そこで本記事では、そのエラーについて調査して整理したことを、サンプルコードを用いながらアウトプットしようと思います!

前提条件

使用技術

まずは、本記事で使用している技術を紹介します。

【言語】
- PHP v8系

【フレームワーク】
- Laravel v10系
- PHPUnit v9系

【そのほか】
- Docker

ユニットテストフレームワークは、PHPUnitを使用しています。

やりたいこと

今回エラーが発生した処理でやりたかったことは、「controllerから受け取った値を画面に表示する際に、その値に対応する文字列と組み合わせて表示すること」でした。

サンプルコード

続いて、本記事で使用するコードです。

実際にエラーに遭遇した時のコードと近い状態を再現するためのサンプルコードを用意しました。

実際のコードでは、DBからデータを取得してくるなどの処理もありますが、今回のサンプルコードではそのあたりは省いて簡単なものにしています。

ただし、処理については、前出の「やりたいこと」で示した内容とほぼ同じにしています。

controller

<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Services\SampleMenuService;
use Illuminate\Http\Request;

class SampleController extends Controller
{
    private SampleMenuService $sampleMenuService;

    public function __construct(SampleMenuService $sampleMenuService)
    {
        $this->sampleMenuService = $sampleMenuService;
    }

    /**
     * サンプルページを表示する
     */
    public function getSample(Request $request)
    {
        $menuIds = $this->sampleMenuService->getAllMenus();

        return view('sample.index')->with([
            'title' => 'サンプルページ',
            'menus' => $menuIds,
        ]);
    }
}

blade

@extends('layouts.sample')

@section('title', $title)

@php
    use App\Services\SampleMenuService;

        // idに対応する文字列を組み合わせる
    function convertMenuString($id) {
        return '【'.$id.'】'.match($id) {
            SampleMenuService::OMELET_RICE => 'オムライス',
            SampleMenuService::HAMBURGER_STEAK => 'ハンバーグ',
            SampleMenuService::YAKITORI => '焼き鳥',
        };
    }
@endphp

@section('content')
    <h1>{{ $title }}</h1>

    <div class="items-list">
        <h2>メニュー</h2>
        @foreach($menus as $id)
            <div class="item-card">
                <span class="item-name">{{ convertMenuString($id) }}</span>
            </div>
        @endforeach
    </div>
@endsection

テストコード

<?php

declare(strict_types=1);

namespace Tests\Feature\Http\Controllers\SampleController;

use App\Services\SampleMenuService;
use Illuminate\Foundation\Testing\WithoutMiddleware;
use Tests\TestCase;

class GetSampleTest extends TestCase
{
    use WithoutMiddleware;

    protected function setUp(): void
    {
        parent::setUp();
    }

    /**
     * @test
     */
    public function サンプルページに渡されたタイトルが「サンプルページ」であること()
    {
        $response = $this->get(route('get_sample'));
        $response->assertViewHas('title', 'サンプルページ');
    }

    /**
     * @test
     */
    public function サンプルページに渡されたメニューが正しいこと() {
        $menus = [
            SampleMenuService::OMELET_RICE,
            SampleMenuService::HAMBURGER_STEAK,
            SampleMenuService::YAKITORI,
        ];
        $response = $this->get(route('get_sample'));
        $response->assertViewHas('menus', $menus);
    }
}

ブラウザでは以下のような表示になっています。

遭遇したエラー

エラー内容

遭遇したエラーはこちら。

Cannot redeclare 関数名()(previously declared in ...)

すでにある関数の再定義・重複を禁止するエラーです。

しかも、PHPUnit実行時にのみ、発生していました。

ブラウザを用いた動作確認では問題なく動いていました。

上記のサンプルコードでもテストを実行してみましょう。

PHPUnit実行結果(エラー発生)
今回遭遇したエラーと同様のエラーが出ているのがわかります。

この場合、convertMenuString()という関数が再定義されていることがエラーメッセージから読み取れます。

※ちなみに、テストケースが1件しかない場合は、ユニットテストは通りました。

再定義した記憶ないよ…??

しかし、同じ名前で実装されている関数は見当たりません。

エディタの検索を使っても、ない。

そもそもあれば、ブラウザを用いた動作確認でもエラーになっているかと思います。

原因

ここからは、このエラーの原因について調査したことを整理していこうと思います!

複数のテストケースを用意しており、controllerに定義した同じ関数を毎回呼び出します。 すると、毎回同じbladeを呼び出すことになり、bladeに定義した関数がそのたびに実行されることになります。

サンプルコードのPHPUnitテストコードで見てみましょう。

/**
 * @test
 */
public function サンプルページに渡されたタイトルが「サンプルページ」であること()
{
    $response = $this->get(route('get_sample'));
    $response->assertViewHas('title', 'サンプルページ');
}

/**
 * @test
 */
public function サンプルページに渡されたメニューが正しいこと() {
    $menus = [
        SampleMenuService::OMELET_RICE,
        SampleMenuService::HAMBURGER_STEAK,
        SampleMenuService::YAKITORI,
    ];
    $response = $this->get(route('get_sample'));
    $response->assertViewHas('menus', $menus);
}

2つのテストケースがあります。 それぞれでgetを使ってControllerに実装した関数getSampleを呼び出して実行しています。 Controllerで実装した関数getSampleでは、bladeにデータを渡しています。

return view('sample.index')->with([
    'title' => 'サンプルページ',
    'menus' => $menuIds,
]);

この関数が実行されることにより、bladeで定義された関数も実行されます。

さらに、PHPUnitの設定ファイルであるphpunit.xmlを見てみると、以下のような記述が…!

processIsolation="false”

上記の設定により、テスト実行時にキャッシュを利用する設定になっています。

php.iniの方も確認してみると、OPcacheがenableになっていました。 OPcacheは、コンパイルしたコードをキャッシュとして保存しておき、リクエストのたびにパースする処理を省く拡張モジュールです。

つまり1件目のテストケースで関数を実行したことでキャッシュがあるものの、2件目のテストケースで同じbladeが呼び出されます。すると、bladeに定義した関数が再び実行され、「関数が再定義された」という事象が発生してしまったということです。

解決方法

いよいよ、このエラーを解決した方法です…!!

serviceを用意するなど方法は色々あると思いますが、今回は「無名関数」に変更して解決しました。

bladeに実装していた関数の目的は以下の2つです。

  1. 見た目を整えること
    • ユーザーにとってわかりやすい表示にする
  2. bladeの中で同じ処理をしたいところが数箇所あった
    • 何度も同じコードを書かない

色んなところから呼ばれるというよりそのblade内だけで使いたい処理であり、わかりやすい表示にするため・使い勝手を良くするために必要な処理だったため、無名関数に変更するという修正方針をとりました。

早速サンプルコードの方も、無名関数で書き替えてみます。

@php
    use App\Services\SampleMenuService;

    // function convertMenuString($id) {
    //     return '【'.$id.'】'.match($id) {
    //         SampleMenuService::OMELET_RICE => 'オムライス',
    //         SampleMenuService::HAMBURGER_STEAK => 'ハンバーグ',
    //         SampleMenuService::YAKITORI => '焼き鳥',
    //     };
    // }
    
    // 無名関数に変更
    $convertMenuString = function ($id) {
        return '【'.$id.'】'.match($id) {
            SampleMenuService::OMELET_RICE => 'オムライス',
            SampleMenuService::HAMBURGER_STEAK => 'ハンバーグ',
            SampleMenuService::YAKITORI => '焼き鳥',
        };
    };
@endphp

呼び出しているところも修正します。 変数に代入された無名関数を呼び出すので、$マークがついた$convertMenuStringを呼び出します。

@section('content')
    <h1>{{ $title }}</h1>

    <div class="items-list">
        <h2>メニュー</h2>
        @foreach($menus as $id)
            <div class="item-card">
                    <!-- $マークをつける -->
                <span class="item-name">{{ $convertMenuString($id) }}</span>
            </div>
        @endforeach
    </div>
@endsection

それでは、PHPUnitを実行してみます…!

PHPUnit実行結果(成功)
これで無事、PHPUnitが通りました🎉

ブラウザでの挙動にも影響なしです!

おわりに

以上、PHPUnitで遭遇したエラーと調査したことのアウトプットでした!

エラーが出た当初は、リリース準備のため急ぎで対応する必要があり、「一旦無名関数で対処する」という感覚でした。

しかし、後で原因を調査して整理していると、PHPUnitの設定ファイルの情報に行き着いたり、PHPのErrorに関する情報に行き着いたり、本記事では収まり切らないくらい学びが多い課題でした。

もっと色々と書きたかったですが、テーマの方向性がとっ散らかっちゃうのでここまでとします。

最後までお読みくださり、ありがとうございました!

参考資料

PHP公式ドキュメント 無名関数

PHP公式ドキュメント OPcache

株式会社フォトラクションでは一緒に働く仲間を募集しています

www.wantedly.com