nikkie-ftnextの日記

イベントレポートや読書メモを発信

Composerでautoloadを設定したつもりが、PHPUnitを流すと「Error: Class "..." not found」となって苦しみました(写経し損ねたnamespace)

はじめに

Namespaces are one honking great idea1、nikkieです。

2/11のPHPカンファレンス2024登壇2準備より1ネタ。
autoloadの設定でハマりました。

※この記事はハマったこととこうやったら解決できたというログを書き残すことを目的としています。
この方法がPHP界隈でいう"正解"なのかは自信がないところです(普段はPythonでTDDしていますが、PHPでテストを書くのが今回初めてです)。
PHPに関しては小さな点でもお気づきの点がありましたら、@ftnextまでお知らせください!

目次

PHPUnitFizzBuzzのテストを書く

スタート地点はこちらです

nikkie「よーし、PHPUnitで小さなテスト駆動開発3をやってみるぞー」

.
├── src/
│   └── FizzBuzz.php
├── tests/
│   └── FizzBuzzTest.php
├── vendor/
├── composer.json
└── composer.lock

src/FizzBuzz.php

<?php

class FizzBuzz
{
}

tests/FizzBuzzTest.php

<?php
use PHPUnit\Framework\TestCase;

require_once __DIR__ . "/../src/FizzBuzz.php";

class FizzBuzzTest extends TestCase
{
  public function test_1を渡すと文字列1を返す(): void
  {
    $fizzBuzz = new FizzBuzz();
  }
}

これは通ります。
ただし「This test did not perform any assertions」とrisky表示です。
(小さいテスト駆動開発としてはこの後アサーションを追加するので、ここまではうまくいっています)

ここで思った(思ってしまった)のです。
nikkie「require_once __DIR__ . "/../src/FizzBuzz.php";がちょっとかっこ悪いな。参考にしているちょうぜつ本にここの書き換えを扱ったコラムがあったぞ!」

ちょうぜつ本 6-4のコラムに従ってautoloadを導入

Composerのクラスローダーを使って、ファイル読み込みを書かなくてもクラス定義ファイルが読み込まれるようにするのが普通です。(Kindle版 pp.228-229)

nikkie「これだけでできるんだー。簡単そうだしやってみよう」

composer.jsonを編集します(リスト6-12 参考)4

{
    "require-dev": {
        "phpunit/phpunit": "^10.5"
+     },
+     "autoload": {
+         "psr-4": {"FizzBuzz\\": "src/"}
+     },
+     "autoload-dev": {
+         "psr-4": {"FizzBuzz\\": "tests/"}
    }
}

この状態でcomposer installを叩きます。

% composer install
Installing dependencies from lock file (including require-dev)
Verifying lock file contents can be installed on current platform.
Nothing to install, update or remove
Generating autoload files
23 packages you are using are looking for funding.
Use the `composer fund` command to find out more!

tests/FizzBuzzTest.phpを書き変えました

- require_once __DIR__ . "/../src/FizzBuzz.php";
+ require_once __DIR__ . "/../vendor/autoload.php";

vendor/bin/phpunit tests!(nikkie「動くでしょ」)

% vendor/bin/phpunit tests
PHPUnit 10.5.7 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.3.1

E                                                                   1 / 1 (100%)

Time: 00:00.008, Memory: 6.00 MB

There was 1 error:

1) FizzBuzzTest::test_1を渡すと文字列1を返す
Error: Class "FizzBuzz" not found

/.../tests/FizzBuzzTest.php:10

ERRORS!
Tests: 1, Assertions: 0, Errors: 1.

nikkie「あれ?」

読み飛ばしたnamespaceの宣言

この解決にハマったのですが、結論から言うと、ちょうぜつ本を写経しきれていませんでした。
リスト6-13や6-14のnamespaceの宣言を読み飛ばしていました。
namespaceの宣言があるとうまく動きます

src/FizzBuzz.php

<?php
+ namespace FizzBuzz;

class FizzBuzz
{
}

tests/FizzBuzzTest.php

<?php
+ namespace FizzBuzz;
+
use PHPUnit\Framework\TestCase;

require_once __DIR__ . "/../vendor/autoload.php";

class FizzBuzzTest extends TestCase
{
  public function test_1を渡すと文字列1を返す(): void
  {
    $fizzBuzz = new FizzBuzz();
  }
}

テストは流れ、require_onceを書き換える前と同じ結果です。

% vendor/bin/phpunit tests
PHPUnit 10.5.7 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.3.1

R                                                                   1 / 1 (100%)

Time: 00:00.004, Memory: 6.00 MB

There was 1 risky test:

1) FizzBuzz\FizzBuzzTest::test_1を渡すと文字列1を返す
This test did not perform any assertions

/.../tests/FizzBuzzTest.php:10

OK, but there were issues!
Tests: 1, Assertions: 0, Risky: 1.

うまくいく理由を数掘りだけしたログ

composer install

vendor/composer/autoload_psr4.php を書き換えているんですね。

return array(
    // 省略
    'FizzBuzz\\' => array($baseDir . '/src', $baseDir . '/tests'),
    // 省略
);

Composerのドキュメントを当たったところ
https://getcomposer.org/doc/01-basic-usage.md#autoloading
composer dump-autoloadを叩くとあり、「composer installじゃない!?」と思いましたが、上記のようにinstallでも動いたのでこのコマンドの差はなさそうです(installはdump-autoloadを実行しているのかな)

上記のファイルが変更されていると知ったのはこちらの記事のおかげ

(この記事ではdumpautoloadコマンドです。ハイフンはあってもなくてもいいみたいですね)

namespaceの宣言

PHPのドキュメントを参照します。

PHP名前空間は、関連するクラスやインターフェイス、関数、そして定数をひとまとめにして扱うものです。(名前空間の概要より)

<?phpの直後にnamespaceを書く必要があることを知ります。

名前空間を宣言するには、キーワード namespace を使用します。名前空間を含むファイルでは、他のコードより前にファイルの先頭で名前空間を宣言しなければなりません。(名前空間の定義より)

「2つのファイルにnamespace FizzBuzz;(同じnamespace)って書いていいの?」という引っかかりには以下の説明を見つけました5

(略) 同一の名前空間を複数のファイルで定義することができます。 これにより、ひとつの名前空間の内容をファイルシステム上で分割することができます。(名前空間の定義より)

FAQの一般的な質問も見ていきます。
https://www.php.net/manual/ja/language.namespaces.faq.php

じわじわ分かってきているのは、namespace FizzBuzz;をsrcにもtestsにも書くと、tests側でFizzBuzzと書いたときに「FizzBuzz名前空間FizzBuzzクラス」のことを言っていそうだということ。

根拠はFAQ内の例7より
修飾されていない name のようなクラス名はどのように解決される?

それ以外(※補注 エイリアスを指定する import 文がない)の場合は、現在の名前空間が name の先頭に付け加えられます。

だから見つかるんですね。

名前空間を宣言していないとき、testsでFizzBuzzと書いたら名前空間の外部にあるグローバルクラスを意味しており(例2 参照)、そのためにnot found(見つからなかった)のかなというのが今の理解です

useを使ってもうまくいく

useなるキーワードもよく分かっていないのですが、tests側でuseしてもnot foundは解決しました。
(GPT-4に泣きついて知りました。not foundが解決した状態になり心の安寧を取り戻せました)

<?php
- namespace FizzBuzz;
-
use PHPUnit\Framework\TestCase;
+ use FizzBuzz\FizzBuzz;

require_once __DIR__ . "/../vendor/autoload.php";

class FizzBuzzTest extends TestCase
{
  public function test_1を渡すと文字列1を返す(): void
  {
    $fizzBuzz = new FizzBuzz();
  }
}

分かっていないこと:
srcにもtestsにも同じnamespaceを宣言するのと、testsはuseにするの、優劣ってあるんだろうか?6
読み違えてるかもしれませんが、ちょうぜつ本は6-4のコラムのcomposer.jsonから前者を採用していると思われます

終わりに

PHPUnitを使う中でrequire_onceをかっこよくしようとComposerのautoloadに手を出し、ハマってから解決するまででした。
namespaceの宣言が肝でした!


  1. The Zen of Pythonより。python -m thisで確認できることを最近知りました
  2. 大阪でお会いしましょう! チケット:https://peatix.com/event/3752841/view
  3. Pythonの例でアウトプットしています。 PHP版は当日をお楽しみに!
  4. キーの"psr-4"とはこちら
  5. どちらもちょっとかじっただけの言語なので間違っていそうですが、PHPのnamespaceってJavaのpackageに近いのかも
  6. 親しんでいるPythonは、いろいろなやり方があるよりやり方は1つだけを好む文化なのです(The Zen of Pythonより「There should be one-- and preferably only one --obvious way to do it.」)