PHP tool

提供:senooken JP Wiki
2025年8月25日 (月) 16:12時点におけるSenooken (トーク | 投稿記録)による版 (PSR-4)

PHPDoc

PHPDoc reference

About

PHPDocはPHP流のコメントのスタイル。phpDocumentorはPHPDocからドキュメントを生成するツール。PHPDocを解析して文書を作成したり利用するソフトはいろいろある。例えば、VS CodeやPHPStormなどのIDEもPHPDocを使う。

Definition of a Type

phpDocumentor

ABNF

type-expression          = 1*(array-of-type-expression|array-of-type|type ["|"])
array-of-type-expression = "(" type-expression ")[]"
array-of-type            = type "[]"
type                     = class-name|keyword
class-name               = 1*CHAR
keyword                  = "string"|"integer"|"int"|"boolean"|"bool"|"float"
                           |"double"|"object"|"mixed"|"array"|"resource"|"scalar"
                           |"void"|"null"|"callable"|"false"|"true"|"self"

nullable

型でnullを許容する場合、PHPDocではint|nullのように|で型をunion型のように書く。

PHP 7.1以上だと?intの書き方が許容されているが、PHPDocは|だけ。null|のように先頭に持ってくるのがわかりやすい。型は長いことがあるから。

Tag

General

よく使う@param/@returnの構文。

<type-expression          = 1*(array-of-type-expression|array-of-type|type ["|"])
array-of-type-expression = "(" type-expression ")[]"
array-of-type            = type "[]"
type                     = class-name|keyword
class-name               = 1*CHAR
keyword                  = "string"|"integer"|"int"|"boolean"|"bool"|"float"
                           |"double"|"object"|"mixed"|"array"|"resource"|"scalar"
                           |"void"|"null"|"callable"|"false"|"true"|"self"

クラス名以外は全小文字。

基本は @<directive> <Type> <name> <description> の書式。スペース区切り。

  • @property: __get/__setのマジックプロパティーを使う場合にクラスの注釈部で指定する。基本は使わない。が、型定義のない親クラスの型の明示にも使える。
  • @var: 変数、プロパティー、定数で使用する。一番よく使う。
/** @var int $int This is a counter. */
$int = 0;

// There should be no docblock here.
$int++;

class Foo
{
    /**
     * Full docblock with a summary.
     *
     * @var int
     */
    const INDENT = 4;

    /** @var string|null Short docblock, should contain a description. */
    protected $description = null;

    public function setDescription($description)
    {
        // There should be no docblock here.
        $this->description = $description;
    }
}

inline tag reference

inline tag reference - phpDocumentor

以下のタグはインラインでも使用可能。ツールによって若干解釈方法が異なる。

  • @example
  • @internal
  • @inheritdoc
  • @link
  • @see

以下の書式で全体を波括弧で囲んで使う。

{@tag value}

@return

phpDocumentor

https://chatgpt.com/c/67998298-f248-800b-a921-a47210ab8d72

関数やメソッドの説明のために関数定義の前に書くのが正しい使い方。

return文の直前に書くものではない。returnのところを説明したかったら、通常コメント。

ただ、関数の@returnでreturnが何を返すのかを書いた方がいい。

注意喚起

https://chatgpt.com/c/67998298-f248-800b-a921-a47210ab8d72

注意が必要な挙動を文書化したい場合。@warnや@noticeはない。代わりの気泡を使う。

タグ 使い道
@todo 修正や機能追加が必要な場合
@deprecated 非推奨の機能を警告
@throws 例外が発生する可能性を示す
// /* */ 特定の処理に対する注意を記述

迷ったら/** */でいいと思われる。

マジックメソッド

https://chatgpt.com/c/67a5c298-bdf8-800b-9f42-8618f36274ab

独自クラスで__getや__setで独自のマジックメソッドで動的プロパティーを実装している場合。PHPDocがないと型や補完が一切されなくて辛い。

/**
 * クラスの説明
 *
 * @property-read string $name ユーザーの名前
 * @property int $age ユーザーの年齢
 */
class User {
    private array $data = [];

    public function __get(string $name) {
        return $this->data[$name] ?? null;
    }

    public function __set(string $name, $value): void {
        $this->data[$name] = $value;
    }
}

$name/$ageのプロパティー名のプロパティーが動的に追加されるなら、@propertyで変数名と型を書いておくと補完してくれる。

https://chatgpt.com/c/67a9546f-904c-800b-9459-af827e0b9fee

__getに@returnしてもいいが、これはプロパティーの情報がない。__getに@returnするくらいなら、@property(-read) でプロパティーとセットで書いた方がいい。

Other

Array

PHPでよく使うArray。PHPDocでの表現方法がある。

  1. @return array
  2. @return int[]
  3. @return int[][]
  4. @return (int|string)[]

PHPDocでの配列の表現方法は以上。単純配列の配列の配列の場合、[]を増やせばいい。

連想配列については説明なし。arrayしかない。単純配列はPHPDocのこれで問題ない。問題は連想配列。

ArrayShape

PHPDocでは未対応の連想配列だが、他の静的解析ツールの独自拡張で対応している。ArrayShapeという注釈。generics arrayとも呼んでいる。

いくつかの記法がある。

function getUserData(): array {
    return [
        'name' => 'John Doe',
        'age' => 30,
        'email' => 'john.doe@example.com',
    ];
}
 * @return array{name: string, age: int, email: string}
/**
 * @return array{
 *     user: array{name: string, age: int, email: string},
 *     address: array{city: string, zip: int}
 * }
 */
@var array{foo: int, bar?: string} <var-name> 説明。
@var array<array{foo: int, bar?: string}> <var-name> 説明。

【PHP】タイプヒンティングをより強力にするArrayShape - デザインワン・ジャパン Tech Blog

こういう書式。ただし、この書式だと要素は説明できないので変数全体のところに説明を入れる。

連想配列のキーが可変の場合。以下のように<>で型だけ指定する。{}はキー名が決まっている場合。

array<string, int>

リスト型で、要素ごとに型が決まっているなら、キー部分を数字にすると型を明示できる。

 * @return array{0: string, 1: int, 2: string}
/**
 * @param array{
 *     0: string, // ユーザー名
 *     1: int,    // 年齢
 *     2: bool    // アクティブフラグ
 * } $userData
 */
function processUser(array $userData): void {
    // ...
}
/**
 * @param list{
 *     string, // ユーザー名
 *     int,    // 年齢
 *     bool    // アクティブフラグ
 * } $userData
 */
function processUser(array $userData): void {
    // ...
}
/** @var array{0: string, 1: int, 2: bool} $userData ユーザー情報:名前, 年齢, アクティブ */

3番目の形式がいい気がする。

stdClass

https://chatgpt.com/c/67b833b8-1d20-800b-bc27-16dfa133e3b0

関数で複数の値を一度に返したい時がある。その際の選択肢はオブジェクトか配列。

オブジェクトはstdClass。stdClassは動的にプロパティーを設定する前提。だが、phpdocでうまく認識してくれない。

@var stdClassでこの後ろに説明を掛けるくらい。

配列の方がまだいいか。

プロパティーの継承

https://chatgpt.com/c/67cab3d0-eac4-800b-96c3-85ea480d1d91

子クラス側で再定義してしまうと、そちらが優先される。再定義しなければ、親クラスのプロパティーのphpdocが使われる。

型を使いこなすためのPHPDocの書き方 - RAKUS Developers Blog | ラクス エンジニアブログ

例えば、外部のフレームワークを使っていて、そのベースクラス側で型定義がない場合、継承後に@propertyで使いたいプロパティーの型を明示できる。

この方法を使わない場合、同じプロパティーを初期値指定で再定義しないといけない。@propertyで指定するとそれを回避できる。

phpDocumentor

Ref: Home | phpDocumentor.

PHPのソースコードにコメントを残す際に、構文に従って記載すると、ツールで表示したり、文書に出力できたりする。ソースコードリーディングにも役立つので、積極的に記載したほうがよさそう。構文を整理しておく。

特に記法が大事。

ファイル冒頭の<?php の直後あたりに書くと、ファイルレベルのDocBlockになる。逆にclassの直前などに書くと、ファイル冒頭でもclassレベルになる。

以下の要素に前置できる。

  • require(_once)
  • include(_once)
  • class
  • interface
  • trait
  • function (including methods)
  • property
  • constant
  • variables, both local and global scope.

Inheritance

DocBlockはSummary/Descriptionを上書きしたり、拡張できる。@inheritdocを使う。

要素ごとに以下のタグを継承する。

Elements Inherited tags
Any @author, @version, @copyright
Classes and Interfaces @category, @package, @subpackage
Methods @param, @return, @throws
Properties @var

@subpackageタグは同じ@packageの親クラスのときだけ継承される。

一番よく使う変数の説明は@var (変数、プロパティー) と@param (関数引数)。

DocBlock

DocComments

DocBlockはDocCommentと呼ばれるコメントで囲まれる。DocCommentは/**で始まり、*/で終わる。そして、DocComment内の行の先頭は* で始まるべき。

<?php
/**
 * This is a DocBlock.
 */
function associatedFunction()
{
}

/** This is a single line DocComment. */

複数行形式と1行形式がある。

変数などの説明には1行形式でいいと思う。

PHPDoc

DocBlockは3部構成。

  1. Summary=短い説明。改行直前の.か空行で終わり。
  2. Description=長い説明。アルゴリズムの機能や、使用方法、例など。最初のタグか、改行、DocBlockの終端で終わる。
  3. Tags/Anntations=要素のメタ情報。新しい行の@から始まる。

具体例。

<?php
/**
 * A summary informing the user what the associated element does.
 *
 * A *description*, that can span multiple lines, to go _in-depth_ into
 * the details of this element and to provide some background information
 * or textual references.
 *
 * @param string $myArgument With a *description* of this argument,
 *                           these may also span multiple lines.
 *
 * @return void
 */
 function myFunction($myArgument)
 {
 }

Summary

/**
 * This is a summary
 *
 * This is a description
 */
/**
 * This is a summary.
 * This is a description
 */

https://chatgpt.com/c/67400d8d-0300-800b-8db3-f3453fee3355

なお、summary/descriptionとtagの間は空行はあってもなくてもいい。縦に間延びするのでなくていいと思う。

空行があると、*の直後に終端スペースが残ったりしてごみが入ることがあるし。

Usage/code example

https://chatgpt.com/c/674687f5-6fc0-800b-95a6-34758bfda434

サンプルコードの埋め込み表示はmarkdownのコードブロック記法を使う。

/**
 * この関数は数値を二倍にします。
 *
 * 使用例:
 * ```php
 * $result = double(5);
 * echo $result; // 出力: 10
 * ```
 *
 * @param int $number 入力値
 * @return int 倍になった値
 */
function double(int $number): int {
    return $number * 2;
}

外部ファイルにサンプルファイルがある場合、@exampleタグで外部ファイルのパスを相対URIか絶対URIで指定できる (@example - phpDocumentor)。phpdocでフォーマット時におそらく、リンクなどで中身をみれるようにしてくれるのだと思う。

https://chatgpt.com/c/67998298-f248-800b-a921-a47210ab8d72

他に@see/@tutorialも使える。が、専用のものはない。/** */ 内でUsageなどで普通の文章で説明するしかない。

Package manager

Composer

Composer

PHPのパッケージ管理システム。PHP v5.3.2以上で動作する。

Install

Ref: インストール: Composer | モダンなPHPのパッケージマネージャー – senooken JP.

LOCAL=~/.local PKG=composer VER= DIR=$LOCAL/stow/$PKG-$VER/bin
[ -e installer ] || wget https://getcomposer.org/installer
[ -e installer ] || curl -LO https://getcomposer.org/installer
mkdir -p $DIR
php installer --install-dir="$DIR" --filename=$PKG

公式サイトからinstallerをダウンロードしてそれを使って任意の場所に設置する。

Composer

php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
php composer-setup.php --install-dir=$HOME/.local/bin --filename=composer

これでもいい。

Usage

Basic usage - Composer

Composerを使う場合,composer.jsonファイルを用意する。このファイルはプロジェクトの依存関係を記載する。VCSで管理すべきファイルだ。

このファイルに使用するライブラリーを以下のように記入する。

<{
    "require": {
        "monolog/monolog": "1.0.*"
    }
}

composer.jsonに指定する最初の項目はrequireキーだ。このキーで依存パッケージをComposerに知らせる。パッケージ名とバージョンを指定する。

新規にパッケージを追加する場合は、以下のコマンドでインストールとcomposer.jsonへの追記を行えます。同時に、composer.lockファイルも作成されます。composer.lockも管理すべきファイル。

composer require "monolog/monolog:1.0.*"

パッケージ名はベンダー名とプロジェクト名から構成される。

1.0.*は1.0の任意のバージョンを示す。

composer.jsonを用意したら,以下のようにcomposerのinstallコマンドを実行する。

php composer.phar install
composer install

これにより,vendorディレクトリーにパッケージがインストールされる。デフォルトでrequire-devの開発用パッケージもインストールする。除外したければ、--no-devのオプションを指定する。

プロジェクトにgitを使っている場合,.gitignoreにvendorディレクトリーを追加したほうがいい。

Composerによるインストールが完了すると,composer.lockファイルにダウンロードしたパッケージとバージョンを出力する。composer.lockをプロジェクトリポジトリーに追加して,プロジェクトメンバー全員が同じバージョンのパッケージを使用する。

composer.lockが存在するプロジェクトで上記コマンドを実行する場合,composer.jsonの内容に加えて,composer.lockの内容も参照されて,composer.lockと同じバージョンがインストールされる。

パッケージを最新バージョンに更新したい場合,composer updateコマンドを使う。このコマンドを実行すると,最新バージョンをインストールして,composer.lockも更新する。動作としては,composer.lockを削除後にcomposer installを実行することと等しい。composer updateは基本的には使わない。

updateやinstallの後にパッケージ名を指定すると,指定したパッケージだけ更新やインストールできる。

composer update monolog/monolog

Composerでインストール可能な主なパッケージは,Packagistリポジトリーに配置されている。

Autoloading (自動読み込み )

About

Basic usage - Composer ライブラリーの自動読み込みのために,Composerはvendor/autoload.phpファイルを生成する。以下のように,ライブラリーのクラスを使用するファイルの先頭でこのファイルを読み込めば使えるようになる。

<php
require __DIR__ . '/vendor/autoload.php';

$log = new Monolog\Logger('name');
$log->pushHandler(new Monolog\Handler\StreamHandler('app.log', Monolog\Logger::WARNING));
$log->addWarning('Foo');

このほかに、composer.jsonのautoload欄に指定することで,ライブラリー以外の自分のコードも自動読み込みの対象にできる。

{
    "autoload": {
        "psr-4": {"Acme\\": "src/"}
    }
}
PSR-4

PSR-4: Autoloader - PHP-FIG psr-4はPHPの標準仕様。 名前空間、クラスメイト、ファイルパスとの対応関係を記している。

  1. 名前空間の単位ごとに、ディレクトリーに対応。
  2. 大文字小文字は区別。
  3. 大文字小文字を区別して、ファイル名は.phpの拡張子で終わる。
dump-autoload

composer.jsonを編集した場合、composer dump-autoloadを実行してvendor/autolaod.phpを必ず更新します。

https://chatgpt.com/share/6837bdb1-1798-800b-a982-650ce38bc30a

なお、require_onceではなくrequireになっているのには理由がある。

  • そもそも何回も読み込む用途ではない。普通アプリのルートで1回しか読み込まない。
  • 万が一複数回読み込んでも、内部的にspl_autoload_registerを呼んでいて、重複登録されない。
  • requireのほうが読み込み確認がないぶんわずかに早い。

Libraries

Libraries - Composer

自前のライブラリーをComposerでインストール可能な形式にする方法がある。

Every project is a package

ディレクトリーにcomposer.jsonがあると、そのディレクトリーはパッケージになる。プロジェクトとパッケージの違いは、名前の有無。プロジェクトは名前のないパッケージという扱いになる。

パッケージをインストール可能にするにあたって、composer.jsonに最低限名前が必要。

{
    "name": "acme/hello-world",
    "require": {
        "monolog/monolog": "1.0.*"
    }
}

acme/hello-worldというプロジェクトになる。acmeはベンダー名で、ベンダー名は必須。

ベンダー名に迷う場合、GitHubのユーザー名が適している。パッケージ名は小文字必須。単語区切りは-にするのが慣例。

Library Versioning

VCSでパッケージを管理している場合、composerはVCSからバージョンを自動で判別する。VCSを使っていない場合だけ、versionプロパティーを追加する。

{
    "version": "1.0.0"
}
Publishing to a VCS

composer.jsonを用意したらVCSのリモートリポジトリーに公開する。ベンダー名とユーザー名は不一致でも問題ない。

公開したパッケージを取り込む場合、requireで指定する。

{
    "name": "acme/blog",
    "repositories": [
        {
            "type": "vcs",
            "url": "https://github.com/username/hello-world"
        }
    ],
    "require": {
        "acme/hello-world": "dev-master"
    }
}

パッケージ名hello-worldに必要なリポジトリーの情報をrepositoriesで指定している。たぶん、末尾のパッケージ名とリポジトリー名は一致が必要。

Publishing to packagist

VCSでの公開のケースは以上。ただ、repositoriesの情報は省略する方法がある。これは、Packagistに登録している場合。composerはpackagitstから同盟パッケージを探す。公開して問題ないなら、Packagistへの登録を検討する。

Light-weight distribution packages

.githubディレクトリーのように、パッケージに不要なファイルがある。

.gitattributesでパッケージやzipに含めないファイルを指定できる。

// .gitattributes
/demo export-ignore
phpunit.xml.dist export-ignore
/.github/ export-ignore

以下のコマンドで確認できる。

git archive branchName --format zip -o file.zip

パッケージに含まれないだけで、Gitリポジトリーには入っている。

Distribution

php - How to include Composer within a GitHub repository - Stack Overflow

Composerを使ったプロジェクトを一般配布する場合。ユーザーにcomposer installしてもらえるならリポジトリーのまま配布すればいい。

composer installしてもらえないならば、以下の2の方法をとるしかない。

  1. vendorディレクトリーをコミットに含める。
  2. リリース用zipにのみvendorを含める (例: Yoast/wordpress-seo: Yoast SEO for WordPress)。

Yoast SEOはGruntでリリース用に用意している模様。

composer.jsonにscriptsを含めることできるし、package.jsonでもOK。

Repositories

Repositories - Composer

パッケージとリポジトリーの概念。

Concepts
Package

パッケージは何かを含むディレクトリー。名前とバージョンを含んでいて、パッケージを識別する。

Repository

パッケージのソース。パッケージとバージョンのリスト。

Types

リポジトリーの種類を指定する。

  • composer: デフォルト
  • vcs: gitリポジトリー。ローカルのgitリポジトリーもurlにパス指定で対応できるが、基本はURL。ローカルはtype=path指定で対応する。
  • package: zipファイルなど。
Hosting your own

外部のホスティングサービス、サイトなどに配置していないものをリポジトリーとして使いたいことがあったりする。

例えば、社内のライブラリーのビルド結果とか。プライベートなリポジトリーとか。

  • type=artifact: ZIPやtarファイル。ルートにcomposer.jsonを含む。
  • type: path: 絶対パス、相対パスでローカルディレクトリーを指定。VCSの場合、type=vcsでurlを相対パスでもできるが、ローカルの場合はpathにするのがよさそう。。バージョンは現在のブランチ・タグから推測する。あるいは、パッケージのcomposer.jsonで明示。どれでも解決できない場合、dev-masterとみなす。

Articles

Scripts

composerの実行中に生じるイベントに対して行うコールバックがScriptsの基本的な概念。それとは別に、独自コマンドも追加できる。

testなどちょっとした定型コマンドの実行に便利。

Event names

いくつかイベントがある。

  • Command Events
    • post-install-cmd: install実行後。
Writing custom commands

composer.jsonの以下のプロパティー定義でcomposer testでphpunitを実行できる。

{
    "scripts": {
        "test": "phpunit",
        "do-something": "MyVendor\\MyClass::doSomething"
        "my-cmd": "MyVendor\\MyCommand"
    }
}

コマンドにオプションを渡す場合、--で区切る。

composer test -- --filter <pattern>

これでtestに--filter <pattern>を渡す。

コマンドの他に、PHPコード自体も実行できる。

composer do-something arg

これでstatic function doSomething(\Composer\Script\Event $event)が呼ばれる。

Executing PHP scripts

@phpや@composerと@を前置すると、composer.jsonを呼び出している@php/@composerを流用してコマンドを実行できる。

{
    "scripts": {
        "test": [
            "@php script.php",
            "phpunit"
        ]
    }
}

また、通常のシェルスクリプトを記述する場合、PHP_BINARYの環境変数で、実行中PHPのフルパスを参照できる。

Versions and constraints

Versions and constraints - Composer

composer requireなどで指定するパッケージのバージョンにはいくつか記法がある。このバージョン部分は、composerではversion constraint (バージョン制約) と呼んでいる。このバージョン制約で、チェックアウト対象を判断する。

~/my-library$ git branch
v1
v2
my-feature
another-feature
~/my-library$ git tag
v1.0
v1.0.1
v1.0.2
v1.1-BETA
v1.1-RC1
v1.1-RC2
v1.1
v1.1.1
v2.0-BETA
v2.0-RC1
v2.0
v2.0.1
v2.0.2
tag

基本的に、composerはタグを扱う。

上記のようなタグの場合、composerは先頭のvなどのプレフィクスを除外して考える。基本的にはこの中で、一番新しいものを優先的に探す。

branch

タグではなく、ブランチのチェックアウトが必要なら、特別なdev-*プレフィクス/サフィックスを指定してブランチを指定する。

上記の例で、my-featureブランチの指定が必要ならば、dev-my-featureを指定する。

ブランチ名がバージョン名と似ている場合、記法が変わる。v1.x-devのように指定する。v1タグではなく、v1ブランチを明示するために、.xは必須。あるいは、タグ名とブランチ名を完全に別の名前 (v1ブランチの代わりにv1.xブランチ) にしておけば、.xは不要。

バージョン名によく似たブランチ名を指定する場合だけ、dev-プレイフィクスではなく、-devサフィックスを指定する。



Test

General

Static

PHPの静的解析ツール。

  • php -l
  • PHPStan
    • Larastan: LaravelでPHPStanを使うと、static呼び出しなどで大量のエラーが出ますので、それらをカバーしてくれます。
  • PHP Code Sniffer
  • PHPMD
  • Psalm
  • PhpStorm
  • Rector

上記が有名。

PHPStanとPsalmはベースラインを設定できるので既存のプロジェクトにも導入しやすい。

PHPのバージョンアップの互換性の確認などもできる。

古いスタイルのPHPの改善

https://grok.com/share/c2hhcmQtMw%3D%3D_938039fe-7a79-4b4b-aad5-6b0fb3e71c8f

https://grok.com/share/c2hhcmQtMw%3D%3D_87563ab0-56ca-4faa-aa15-f5fb31dcf52d

昔ながらの、要請が来たらheader/echoで手動で応答を返すスタイルのPHPコード。このままだと、テストとかしにくいし、同一コードが散見して冗長になる。保守に問題がある。

改善手順がある。

仕様化テスト/Characterization test

読書メモ『レガシーコード改善ガイド』マイケル・C・フェザーズ|まくろぐ

特徴付けテストと直訳することもあるらしい。マイケル・C・フェザーズの2009年翻訳のレガシーコード改善ガイドで提唱された内容。著者の独自の概念。

ドキュメントや仕様が不明瞭なコードを扱う際に有効。既存コードの動作を把握し、そのふるまいを固定化するための手法。

現状コードが今何をするのかをテストで記録する。リファクタリングとかをする前に現在の動作をある程度保証するテストを記載することで、意図しない影響を防げる。

例えば、昔ながらの、phpファイルがそのまま応答を返すタイプのアプリの場合。

環境変数を設定して、バッファリングで、文字列が歩かないかで、ある程度現在のふるまいをテストできる。

PHP

declare(strict_types=1);

新規ファイルには、基本的に指定したほうがいい。既存ファイルは慎重に。

php -l

-l/--syntac-checkオプションで構文チェックのみを行う。-lはlintのlだと思われる。

成功したらNo syntax errors detected in <filename> が標準出力に書き込まれ、リターンコードは 0

失敗した場合、テキスト Errors parsing <filename> に加え、内部パーサエラーメッセージ が標準出力に書き込まれ、シェルリターンコードは、 -1 となります。

このオプションは、(未定義の関数のような)致命的なエラー(fatal error) はみつけません。致命的なエラーについても調べたい場合は、 -f を使用してください。

-lは-r (コードの実行)とは併用不能。以下のコードで全phpファイルをチェックできる。

for i in `find . -name \*.php`; do php -l $i | grep -v "No syntax errors"; done

上記コードより、以下の方が少し早い。

find . -name \*.php -exec php -l {} \; | grep -v '^No syntax errors'

外部ツールも不要で手軽で悪くない。特に、-lで実行するphpのバージョンを変えるだけでチェックでき、PHPのバージョンアップ時の事前の簡易チェック、粗探しとしてとして悪くない (PHPerのための「静的解析」を語り合う【PHP TechCafe イベントレポート】 - RAKUS Developers Blog | ラクス エンジニアブログ)。

ただ、php -lは構文エラーのみで、型チェックはできない。PHPStanを使うしかない模様。

gitのpre-commitに登録する場合、以下のような内容にするとよい。

#!/bin/sh
## Lint added/modified PHP file.
set -eu
has_error=false
PHP_FILES=$(git diff --staged --name-only --diff-filter=ACM | grep '\.php$' || :)
[ -z "$PHP_FILES" ] && exit || :

while read -r file; do
	php -l "$file" | grep -v '^No syntax errors' && has_error=true
done <<-EOT
$PHP_FILES
EOT
$has_error && exit 1 || :

./vendor/bin/phpstan analyze --memory-limit 5G $PHP_FILES || has_error=true
$has_error && exit 1 || :
# parameters.pathsのコメントアウトを解除して全体解析する場合、上記のファイル単位のphpstanをやめて、以下の全体解析を実行する。
# ./vendor/bin/phpstan analyze --memory-limit 5G || exit 1

ただ、根本的なPHPの構文エラーがあると、PHPStanはそこで失敗して詳細情報がない。php -lもあっていい気がする。

PHPStan

導入が簡単なので黙って導入したらよさそう。

User Guide

Getting Started

Getting Started | PHPStan

composer require --dev phpstan/phpstan

以下のコマンドでバージョンを確認できればOK。

vendor/bin/phpstan analyze --version
PHPStan - PHP Static Analysis Tool 2.1.10
git pre-commit

hooks/pre-commit

#!/bin/sh
## Lint added/modified PHP file.
set -eu
has_error=false
PHP_FILES=$(git diff --staged --name-only --diff-filter=ACM | grep '\.php$' || :)
[ -z "$PHP_FILES" ] && exit || :

while read -r file; do
	php -l "$file" | grep -v '^No syntax errors' && has_error=true
done <<-EOT
$PHP_FILES
EOT
$has_error && exit 1 || :

./vendor/bin/phpstan analyze --memory-limit 5G $PHP_FILES || has_error=true
$has_error && exit 1 || :
# parameters.pathsのコメントアウトを解除して全体解析する場合、上記のファイル単位のphpstanをやめて、以下の全体解析を実行する。
# ./vendor/bin/phpstan analyze --memory-limit 5G || exit 1
# phpstan.dist.neon
includes:
    - phpstan-baseline.neon
parameters:
    level: 0
    tmpDir: var/tmp/phpstan
    # parallel:
    #    processTimeout: 15000.0
    # scanDirectories:
    #     - ../../
    # todo ある程度指摘対応ができたら常に全体解析にする。
    # paths:
    excludePaths:
        - var

最初は修正ファイルだけphpstanで解析して、安定してきたらプロジェクト全体を解析する形にするとよいだろう。

phpstanのファイル単独実行は時間がかかるので、修正対象をまとめて実行した方がいい。

https://grok.com/share/c2hhcmQtMw%3D%3D_85f34631-62c7-426a-89cb-ecb407c0246b

なお、WSLやDockerを使っている場合、docker exec <container-name> php -lなどで、docker内のphp/phpstanをホスト側で使うのがいい。

docker runで起動している場合、--nameでコンテナー名を固定する。

Command Line Usage

Command Line Usage | PHPStan

Analyzing code
vendor/bin/phpstan analyse [options] [<paths>...]

いくつか重要なオプションがある。

  • paths: 検査対象ファイルパス。設定ファイルで指定可能。
  • --level|-l: 実行レベル。設定ファイルで指定可能。
  • --configuration|-c: 設定ファイルを指定する。
  • --generate-baseline|-b: ベースラインを作成する。オプション引数で出力ファイルのパスを指定できる。デフォルトはphpstan-baseline.neon。
  • --memory-limit: php.iniと同じ形式で最大メモリーを指定。
Running without arguments

PHPStanは基本的に、コマンド引数で指定した、ディレクトリー類を対象に解析する。

毎回コマンド引数を指定するのは手間なので、設定ファイルに記述しておくこともできる。

以下の条件を満たせば、引数なしで、設定ファイルの内容で解析できる。

  1. phpstan.neonかphpstan.neon.distの存在。
  2. pathsに解析対象パスリストが存在。
  3. levelパラメーターの存在。

最小限の例。

parameters:
	level: 0
	paths:
		- src
		- tests

現実的な例。

includes:
    - phpstan-baseline.neon
parameters:
    level: 0
    parallel:
       processTimeout: 15000.0
    tmpDir: var/tmp/phpstan
    scanDirectories:
    paths:
    excludePaths:
Minimum file

https://chatgpt.com/c/67fe029c-9ce0-800b-9397-8e1d7f18b303

phpstanは基本的に引数で<paths>でディレクトリーやファイルを指定する。ただし、ここで指定していないファイルは、見に行けないので未定義の警告などが出る。

基本はパス全体を指定する。コマンドライン引数か、phpstan.dist.neonで指定しておく。

他に、pre-commitように、autoload_filesかbootstarpFilesでベースの依存ファイルを指定する。

他に、phpstanはcomposerのautoloadを理解するので、composer.jsonにautoloadを追加する。

Reflection error: Circular reference to class

名前通り循環参照が起きている。具体的に、自分と同名のクラスを最終的にextendsしている。

元ファイルを直す他、excludePathsで検査対象などから除外すれば回避できる。

Internal error: Child process timed out after 3000.0 seconds. Try making it longer with parallel.processTimeout setting.

https://chatgpt.com/share/68020423-6350-800b-9200-3d8c2d2483e2

--generate-baselineを指定すると、初回だけ時間がかかる。

parallel.processTimeoutでタイムアウトの時間を設定する。成功させるために、進捗率と現在の設定の比率でカバーできるだけの時間にする。3000で40 %くらいの進捗だったら9000とか。

初回だけ時間がかかる。

Syntax error, unexpected T_EXTENDS on line 18

https://chatgpt.com/c/6801fda7-2434-800b-84da-601c44645488

phpstanを実行すると上記のような、エラーが出ることがある。これは、PHPStanの前に根本的なPHPの構文エラーの可能性が高い。

PHPStan実行の前に、先にphp -lで解決しておく必要がある。

time -p (find . -name \*.php -exec php -l {} \; | grep -v 'No syntax errors' >php-l.log) 2>&1
Error while loading phpstan-baseline.neon: Invalid UTF-8 sequence.

作成したBaselineをincludeで読み込んで実行すると上記のエラーが出た。

https://chatgpt.com/share/68020423-6350-800b-9200-3d8c2d2483e2

ベースラインファイルにへんな文字が入ったのが原因。

以下のコマンドで変な文字の行を特定できる。

grep -axv '.*' phpstan-baseline.neon

--error-format=rawを指定してbaselineを作り直すと良い。

The Baseline

The Baseline | PHPStan

PHPStanはレベルベースでチェックしてくれる。が、既存コードで警告が多い場合、新規追加分だけ考慮したいだろう。

そういう場合に、ベースラインが役立つ。

以下のように、--generate-baselineオプションを指定して、PHPStanを実行すると、現在のエラーリストをエクスポートしてくれる。

vendor/bin/phpstan analyse --generate-baseline

ベースラインファイルを使う場合、オプションで指定するか、設定ファイルで明記する。

デフォルトでphpstan-baseline.neon。オプション引数で、出力ベースラインファイルを指定できる。

作成したら、設定ファイルphpstan.dist.neonに読み込む。

includes:
	- phpstan-baseline.neon

parameters:
	# your usual configuration options

これで次回からはベースラインのエラーを無視してくれる。必要に応じて、ベースラインを編集したらい、ベースラインを再生成してもいい。

なお、ベースラインで無視したエラーがなくなった場合、ベースラインファイルから削除するまで、PHPStanは通知する。この通知を無効にしたかったら、以下を設定する。

parameters:
	reportUnmatchedIgnoredErrors: false

なお、ベースライン作成時に、以下の警告が出ることがあり、全ての指摘をベースラインに出力できるわけではない。

[WARNING] Baseline generated with 79012 errors.                                
Some errors could not be put into baseline. Re-run PHPStan with "-vv"
and fix them.              

致命的なエラーはベースラインに出力できないので、直すしかない。

Discovering Symbols

Discovering Symbols | PHPStan

https://chatgpt.com/c/67e0b340-da84-800b-89c8-4c72691fdc44

pathsで指定したファイルで使用されているシンボルをPHPStanは必要とする。デフォルトで以下の2箇所を探す。

  • pathsの対象 (コマンドライン引数と設定ファイル)。
  • composerの依存関係

基本はこれで見つかるはず。

Third party code outside of Composer dependencies

PEARとか追加で設定が必要だったりする。そういう場合、scanFilesとscanDirectoriesの設定が使える。

これらで指定したファイル、ディレクトリーをシンボルとして探す。探すだけで解析はしない。

https://chatgpt.com/share/680b240a-bf28-800b-bfc2-a84f639fe0c0

プロジェクト外部はみれないらしい。

autoload_filesでautoload.phpを指定するか。bootstrapFilesで読み込む。

https://grok.com/share/c2hhcmQtMw%3D%3D_fc03d339-2ee1-4f76-85bd-ee567a2575ef

例えば、PEARの依存関係を追加したい場合、以下のコマンドでpearのパスを確認する。

pear config-get php_dir

これをscanDirectoriesに指定すればOK。

parameters:
    scanFiles:
        - /usr/local/php
Bootstrap

https://grok.com/share/c2hhcmQtMw%3D%3D_fc03d339-2ee1-4f76-85bd-ee567a2575ef

PHPStanの実行前に、PHPのランタイム設定したい場合、bootstrapFilesで自前の起動時ファイルを指定できる。

parameters:
	bootstrapFiles:
		- phpstan-bootstrap.php

例えば、環境変数の設定とかをこれでうまくできる。独自のautoload的なことやっている場合もこれでいける。

Output Format

Output Format | PHPStan

--error-formatのオプションでPHPStanの報告形式を指定できる。

.neonファイルに以下の形式で指定可能。

parameters:
	errorFormat: json

特に重要なものがある。

  • table: デフォルト。
  • raw: 1行1データで機械処理用。文字化けなどが起こりにくい。
  • json: IDEなどとの連携用。
  • checkstyle: XML形式。CIツール連携用。

困ったらrawを指定しておく。

Ignoring Errors

Ignoring Errors | PHPStan

Excluding whole files
Broken symbolic link

シンボリックリンクのリンク切れなどで以下のエラーが出ることがある。

  Could not read file:

https://chatgpt.com/c/68088168-ac54-800b-b78e-e28743b81011

excludePathsはファイルの中身に有効で、scanDirectiriesで指定したディレクトリーに、破損したシンボリックリンクがあると、スキャン対象に入った時点で失敗する。

事前に破損したシンボリックリンクを削除しておく必要がある。

以下のコマンドで破損リンクを削除できる。

find path/to/scan -type l ! -exec test -e {} \; -exec rm {} \;
# find path/to/scan -type l ! -exec test -e {} \; -print # 確認用
Result Cache

PHPStanは分析実行結果をキャッシュする。条件は、解析対象ファイルリスト。同一じゃないとキャッシュは使えない。

キャッシュが使えると、数秒で解析が終わることもある。

tmpDir

実行結果は%tmpDir%/resultCache.phpになる。

sys_get_temp_dir() . '/phpstan' (usually /tmp/phpstan)

parameters.tmpDirで上書き指定可能。

Macだと以下のような場所。

php -r 'echo sys_get_temp_dir() ."\n";'
/var/folders/hr/9q7pmn9x5_788w3fsp9dd71r316wyt/T

このtmpDirのデフォルトは、基本的にOSを再起動すると消去される。なので、基本的にneonファイルに以下のような指定がほぼ必須。

parameters:tmpDir: var/tmp/phpstan

.gitignoreにもついでに以下を追加する。

/var/
scannedFiles

phpstanを実行すると以下のようにキャッシュの不一致が示されることがある。

time -p vendor/bin/phpstan analyze --memory-limit=5G --generate-baseline --error-format=raw -vv 2>&1 | tee phpstan.log

Note: Using configuration file phpstan.dist.neon.
Result cache not used because the metadata do not match: projectConfig, scannedFiles

設定ファイルと、スキャンファイル一覧のどちらか。

原因把握のために、scannedFilesを確認したい。

resultCache.phpのscannedFilesの連想配列のキーに、ファイル一覧が入っている。

確認すると、.phpの拡張子のファイルが入っている。.gitとかは含んでいない。が、tmpDirに指定したキャッシュ内の.phpは見ている。除外必要。

parameters.fileExtensionsで追加可能 (Config Reference | PHPStan)。

paths/excludePath/scanDirectories

結果キャッシュに影響のある中で、scannedFilesに影響のあるこれらの設定の関係がわかりにくいので整理する。

  • pathsの指定があれば含む。
  • scanDirectoriesがあれば含む
  • excludePath (.analyseAndScan) の指定があれば、除外される。paths内で一部を除外する場合に使う。シンボルとしては見たい場合はexcludePath.analyseの指定。analyseと併用・別々に記載したい場合はanalayseAndScan。scanDirectories以下も同様。気持ち悪いが、analyzeではなくてanalyseじゃないと認識されない。
parameters:
    paths:
        - src
    excludePaths:
        analyse:
            - src/thirdparty
        analyseAndScan:
            - src/broken

srcの中のsrc/thirdpartyを、解析対象に含めたくないが、シンボルとしては認識したい場合。src/brokenは見に行くとエラーになるのでシンブルとしても無視。

Config Reference

Config Reference | PHPStan

基本はphpstan.neon.distまたはphpstan.dist.neonをバージョン管理する。ユーザーはphpstan.neonで上書きできるようにする。

phpstan.dist.neonのほうが、拡張子が明示されてよく感じる。

PHPUnit

PHPUnitを使用すれば自動単体テスト (Unit test) が可能だ。

About

Version

情報源: Supported Versions of PHPUnit – The PHP Testing Framework

PHPUnitのバージョンごとに対応しているPHPのバージョンが決まっている。

PHP v7.4に対応してい最後のバージョンはPHPUnit 9なので、当分はこれを使うのが良い。

Install

以下のコマンドでPHP 7.4に対応している最後のPHPUnitのv9をインストール。

composer require --dev phpunit/phpunit ^9

composer.jsonに以下が追加される。

{
    "require-dev": {
        "phpunit/phpunit": "^9"
    }
}

composer.jsonとcomposer.lockをVCSで管理する。

composerのautoloadのclassmapでソースファイルのルートディレクトリーを指定する。

{
    "autoload": {
        "classmap": [
            "src/"
        ]
    },
    "require-dev": {
        "phpunit/phpunit": "^9"
    }
}

最後に以下のコマンドでvendor/autoload.phpを更新。

composer dump-autoload

Getting Started with PHPUnit 9

Getting Started with Version 9 of PHPUnit

  1. phpunitをインストール
  2. テストコード記述
  3. phpunit実行

上記の3ステップで使う。

以下のように引数に、ディレクトリーかテスト対象ファイルの相対パスを探す。

phpunit tests

2. Writing Tests for PHPUnit

出典: 2. Writing Tests for PHPUnit — PHPUnit 9.6 Manual

基本的な使用方法を整理する。

  1. 基本的にはクラス単位で試験コードを記載。<Class>クラスの試験コードは<Class>Testの命名にする。
  2. <Class>Test はPHPUnit\Framwork\TestCaseを継承させる。
  3. 試験はpublicのtest*メソッドの命名にする。あるいは、@testのアノテーションを付ければ、命名規則に従わなくてもいい。
  4. test*メソッド内で、assertSame() などで、期待値との比較で試験を行う。
  5. test*メソッドで1メソッドに対して、試験する。例えば、正常形、異常系、境界値など。1メソッド1テストメソッドにするとわかりやすい。

例:

<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;

final class StackTest extends TestCase
{
    private static $dbh;
    private $instance;
    
    public static function setUpBeforeClass(): void
    {
       // DB接続などクラス全体の初期化処理
       self::$dbh = new PDO('');
    }
    public static function tearDownAfterClass(): void
    {
        self::$dbh = null;
    }
    protected function setUp(): void
    {
      // 該当インスタンスの生成などメソッド単位の初期化処理。
      $instance = new Stack();
    }
    public function testPushAndPop(): void
    {
        $stack = [];
        $this->assertSame(0, count($stack));

        array_push($stack, 'foo');
        $this->assertSame('foo', $stack[count($stack)-1]);
        $this->assertSame(1, count($stack));

        $this->assertSame('foo', array_pop($stack));
        $this->assertSame(0, count($stack));
    }
}

命名規則があっていないと、以下のメッセージが出てphpunitの実行に失敗する。

Class <Class>Test could not be found in /path/to/<Class>Test.php

後述するが、メソッドテスト時は、data providerでテストデータを与えて、1テストメソッドで1試験対象メソッドをテストするといい。

Depends

前回の試験で準備した結果を利用したい場合、@dependsのアノテーションでテスト関数を指定しておくと、指定したテスト関数のreturnを引数に受け継いだテスト関数を記述できる。

@dependsは複数指定でき、指定順に事前に試験が実行されて引数に渡される。

Data Provider
About

ある関数に対して、テストケースを用意して、複数の引数の組み合わせを試験したいことがよくある。こういうときのために、テスト関数に渡す引数を生成する関数のデータプロバイダーを指定できる。@dataProviderで関数を指定する。データプロバイダーは引数のリストを配列で返すようにする。

データプロバイダーを使うことで、テスト用のメソッドは1個で、データだけ変えてテストできる。1メソッド1テストメソッドで対応できてスマート。また、データプロバイダーを使うと、どのデータで失敗したかがわかる。

<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;

final class DataTest extends TestCase
{
    /**
     * @dataProvider additionProvider
     */
    public function testAdd(int $a, int $b, int $expected): void
    {
        $this->assertSame($expected, $a + $b);
    }

    public function additionProvider(): array
    {
        return [
            [0, 0, 0],
            [0, 1, 1],
            [1, 0, 1],
            [1, 1, 3]
        ];
    }
}

data providerの使用方法。

  1. テストデータを配列かIteratorで返却するpublic メソッド (data provider) を用意
  2. テストメソッドの引数で、data providerの配列を受け付ける。
  3. data providerを使用したいテストメソッドで、@dataProvider <data provider method> のアノテーションを指定する。

データセットはリストでもいいが、連想配列でキーにテスト名を書くとわかりやすい。

<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;

final class DataTest extends TestCase
{
    /**
     * @dataProvider additionProvider
     */
    public function testAdd(int $a, int $b, int $expected): void
    {
        $this->assertSame($expected, $a + $b);
    }

    public function additionProvider(): array
    {
        return [
            'adding zeros'  => [0, 0, 0],
            'zero plus one' => [0, 1, 1],
            'one plus zero' => [1, 0, 1],
            'one plus one'  => [1, 1, 3]
        ];
    }
}

テストデータに意味がある場合は、記載した方がよさそう。このテストデータ部分にMock用のデータを渡したりもできる。

Mockでデータに応じて処理を変える場合も、データプロバイダーのデータでif文などの条件を入れたり、mock_dataのような専用のキーを渡す形にすればいい。

データ数が多い場合、名前付き配列にしておくと、どういうデータ項目で失敗したかがわかりやすい。Iteratorオブジェクトを返してもいい。

Exception

テストデータによって、例外が発生することを期待する場合、いくつか方法がある。テストデータにthrowsException:boolの列を用意して、それで判定する。または、例外用のテストメソッドを用意してそちらにするというのもある。複雑なら後者、テストケースがそんなに多くなくてシンプルなら前者でいいと思う。

/**
 * @dataProvider provideCases
 */
public function testSomething($input, $expected, $throwsException)
{
    if ($throwsException) {
        $this->expectException(\InvalidArgumentException::class);
    }

    $result = $this->target->doSomething($input);
    
    if (!$throwsException) {
        $this->assertSame($expected, $result);
    }
}

public static function provideCases(): array
{
    return [
        '正常系' => ['input' => 10, 'expected' => 100, 'throwsException' => false],
        '異常系' => ['input' => -1, 'expected' => null, 'throwsException' => true],
    ];
}
Naming

data providerメソッドの命名。「複数メソッド共通: provideXxx」にする。

「特定メソッド専用: testXxxDataProvider」にすると、testメソッドと対応が同じでわかりやすいのだが、testから始まると、testメソッド扱いされるので、assertがないと警告が出る。命名規則的にはxxxxTest/xxxProviderがきれいなんだけど、test/provideで使う場所と隣同士にすれば、そんなに困ることはないだろう。

他に、返却する配列の順番。公式だと、引数→期待の順番。だが、引数は複数あり得るのだから、期待→引数の順番がわかりやすい。assertの順番ともあうし。

メソッドは、test→provideの順番に書くといいみたい。testメソッドに対して、テストケースのデータを流すから。メソッドが先で、データが後。どういう試験をするかが、メソッドで先にわかるイメージの模様。

テストメソッドの名前は、test<nethod name><テスト観点> みたいな感じにすると良い。次のResolutionのように、1メソッドに対して、違うテスト観点で複数テストすることがあるから。

Resolution

https://chatgpt.com/share/68390cd8-5cc4-800b-afce-36c2abdc4b83

data providerを使って渡すテストデータは、同じ観点にする。

例えば、表示の試験は、表示のバリエーション。表示の他に、ボタンの動作試験をしたい場合、違うテストメソッド、data providerにしたらしい。

1個のdata providerで複数の観点の試験もできなくはないが、引数とテストメソッドが複雑になって、わかりにくくなる。

3. The Command-Line Test Runner

3. The Command-Line Test Runner — PHPUnit 9.6 Manual

phpunitのコマンド自体の使用方法。

phpunit <file>
phpunit <directory>

引数にテスト対象ファイルの相対パスか、ディレクトリーを指定する。ディレクトリーを指定した場合、配下の全*Test.phpを実行する。

基本はphpunit testsを実行すればいいと思う。

Fixtures

出典: 4. Fixtures — PHPUnit 9.6 Manual

テストメソッドの実行前に、テスト対象のインスタンスの生成や、DB接続など準備がいろいろある。これをFixturesと呼んでいる。この準備がけっこう手間になる。これを省力できるのがテストフレームワークの利点。

テストメソッド実行前後に共通で行える処理がある。

  • setUp/tearDown: テストメソッド単位の前後処理。テスト対象インスタンスの生成など。tearDownは何もしなくてもいいことが多い。
  • setUpBeforeClass/tearDownAfterClass: クラス単位の前後処理。 DB接続など。
共通クラスの初期化処理

https://chatgpt.com/share/6836d32d-e9b8-800b-9433-444ff1a1e2bf

PHPUnitのTestCaseを継承した独自の共通処理クラスを作る場合。初期化処理は__constructでやってはいけないらしい。

テスト用にいろいろ特別な初期化をしているから。

代わりに、setUpで共通で使うインスタンス生成などをする。子クラスではparent::setUp()が毎回必要になるがしかたない。

XML Configuration File

出典:

基本はコマンドでテスト対象クラス・ファイルを指定してテストを実行する。他に、XMLの設定ファイル (phpunit.xml) でもテスト対象を指定できる。

ただし、phpunit.xmlかphpunit.xml.distがあって、かつ--configurationの指定がない場合、これらのファイルを自動的に読み込む。

testsディレクトリーの全*Test.phpを対象にする最小限の例は以下。

<phpunit bootstrap="src/autoload.php">
  <testsuites>
    <testsuite name="money">
      <directory>tests</directory>
    </testsuite>
  </testsuites>
</phpunit>

phpunit.xmlがあれば、単にphpunitコマンドを実行するだけでいい。

phpunit --bootstrap src/autoload.php --testsuite money

上記のコマンド相当になる。

<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
    <testsuites>
        <testsuite name="tests">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
</phpunit>

https://chatgpt.com/share/6821bc52-3324-800b-a98c-cbefe6c42324

element attribute default
phpunit bootstrap - --bootstrap相当。テスト実行前の読込スクリプト。
colors false true=--colors=auto相当、false=--colors=never相当。
verbose
testsuites - - testsuiteの親要素。
testsuite - - name属性が必須で、テスト検索用の1以上のdirectoryかfile要素が必要。
testsuite name 必須。ディレクトリー名でいい気がする。
phpunit/php PHPの設定。
phpunit/php/includePath include_pathの先頭に追加するパス。
bootstrap

https://chatgpt.com/share/6821bc52-3324-800b-a98c-cbefe6c42324

autoloadを使っていないプロジェクトだと、手動でプロジェクトルートパスなどの指定が必要になる。

tests以下にbootstrap.phpを用意して、その中で必要なものを読み込む形にするといい。

<?php
// tests/bootstrap.php

// $additionalPath = PROJECT_ROOT . '/src'; set_include_path(get_include_path() . PATH_SEPARATOR . $additionalPath); set_include_path(implode(PATH_SEPARATOR, [ get_include_path(), PROJECT_ROOT . '/src', PROJECT_ROOT . '/lib' ]));

// Composerのオートローダー
require_once __DIR__ . '/../vendor/autoload.php';

// オートローダーでは読み込めない独自ファイル
require_once __DIR__ . '/manual-loads/special-loader.php';

// 必要なら定数定義
define('PROJECT_ROOT', dirname(__DIR__));
<phpunit bootstrap="tests/bootstrap.php">

Assertions

About

Ref:

基本はassertSameでテストすればいいのだが、それ以外にも例外とかいろいろ試験したいケースがあるので、メソッドを整理する。

Method

インスタンスメソッドとstaticメソッドの2種類がある。他に、グローバル関数もある。どれを使ってもいい。

  • アサーションメソッド: PHPUnit\Framework\Assert
  • PHPUnit\Framework\TestCaseはAssertを継承している。
  • phpunit/phpunit/src/Framework/Assert/Functions.php でグローバル関数。

入力文字数や好みの問題。グローバル関数は内部的にstaticメソッドを呼んでいる。

TestCaseのインスタンスメソッドの方が違和感がないという説がある。

$this->よりもself::のほうが短い。グローバル関数は名前空間がないのがまずい。self::のstaticメソッドでいい気がする。

Negate

assert系メソッドは、assertNotでNotを前置したら否定形になる。assertStringとかグループがある場合は、assertStringNotのように、グループの後にNotになる。

同値判定
  • assertSame/assertNotSame: 型と値の両方を検査 (===相当)。
  • assertEquals: 値を検査 (==相当)。

オブジェクトの属性値の値が同じかどうかをみたいなら、assertEqualsになる。assertSameは参照 (ポインター) のアドレス値の比較みたいなことをするから。

基本はassertSameでよいと思われる。

  • assertEqualsCanonicalizing()
  • assertEqualsIgnoringCase()
  • assertEqualsWithDelta()

他にこういうバリエーションがある。

論理判定
  • assertIsBool
  • assertFalse
  • assertTrue()
数値判定
  • assertGreaterThan
  • assertGreaterThanOrEqual
  • assertLessThan
  • assertLessThanOrEqual
  • assertInfinite
  • assertNan
  • assertIsInt
  • assertIsNumeric
  • assertIsFloat
文字列判定
  • assertIsString
  • assertMatchesRegularExpression
  • assertStringContainsString
  • assertStringContainsStringIgnoringCase()
  • assertStringMatchesFormat
  • assertStringMatchesFormatFile
  • assertStringEndsWith
  • assertStringEqualsFile
  • assertStringStartsWith
配列判定

配列の検査用のメソッドがある。

  • 要素数
    • assertEmpty
    • assertSameSize(): 2個の配列の要素数が同じか。
    • assertCount(): 指定した配列の要素数が指定数か。データの取得数などでこちらをよく使いそう。
  • 包含
    • assertContains
    • assertContainsOnly()
    • assertContainsOnlyInstancesOf()
    • assertArrayHasKey
  • assertIsArray
  • assertIsIterable
クラス
  • assertClassHasAttribute
  • assertClassHasStaticAttribute
  • assertObjectEquals
  • assertInstanceOf
  • assertIsCallable
  • assertIsObject
  • assertIsResource
  • assertIsScalar
  • assertNull
  • assertObjectHasProperty
ファイル
  • assertDirectoryExists
  • assertDirectoryIsReadable
  • assertDirectoryIsWritable
  • assertFileEquals
  • assertFileExists
  • assertFileIsReadable
  • assertFileIsWritable
  • assertIsReadable
  • assertIsWritable
その他
  • assertThat
  • assertJsonFileEqualsJsonFile
  • assertJsonStringEqualsJsonFile
  • assertJsonStringEqualsJsonString
  • assertXmlFileEqualsXmlFile
  • assertXmlStringEqualsXmlFile
  • assertXmlStringEqualsXmlString
Exception

2. Writing Tests for PHPUnit — PHPUnit 9.6 Manual

特に例外の試験がイレギュラー。

<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;

final class ExceptionTest extends TestCase
{
    public function testException(): void
    {
        $this->expectException(InvalidArgumentException::class);
        // Run test target code following.
    }
}

上記のようにexpectExceptionを使う。

  • expectException:
  • expectExceptionCode:
  • expectExceptionMessage:
  • expectExceptionMessageMatches:

例外が発生する処理の前に記述しておく。

Testing Output

2. Writing Tests for PHPUnit — PHPUnit 9.6 Manual

echoなど標準出力を試験する際も専用のメソッドがある。

  • void expectOutputRegex(string $regularExpression)
  • void expectOutputString(string $expectedString)
  • bool setOutputCallback(callable $callback)
  • string getActualOutput()

expectExceptionと同様に事前にセットしておく。

Constraint

1. Assertions — PHPUnit 9.6 Manual assertThatとwithで使えるConstraint。通常のassertと似たような名前のものも多いので注意する。equalToとかはConstraintにしかない。PHPUnit\Framework\Constraintの名前空間の各種クラスのメソッドになる。

  • 論理
    • isFalse
    • isTrue
    • logicalAnd
    • logicalNot
    • logicalOr
    • logicalXor
  • 数値
    • greaterThan
    • greaterThanOrEqual
    • lessThan
    • lessThanOrEqual
  • 文字列
    • matchesRegularExpression
    • stringContains
    • stringEndsWith
    • stringStartsWith
  • 配列
    • arrayHasKey
    • contains
    • containsOnly
    • containsOnlyInstanceOf
  • クラス
    • classHasAttribute
    • classHasStaticAttribute
    • objectHasAttribute
    • isInstanceOf
    • isNull
  • ファイル
    • directorExists
    • fileExists
    • isReadable
    • isWritable
  • その他
    • isType
    • anything
    • equalTo
    • identicalTo

上記にない制約は$this->callbackで自分で定義する。引数に引数がきて、true/falseを返す。

Table 1.1 Constraints
Constraint Meaning
PHPUnit\Framework\Constraint\IsAnything anything() Constraint that accepts any input value.
PHPUnit\Framework\Constraint\ArrayHasKey arrayHasKey(mixed $key) Constraint that asserts that the array has a given key.
PHPUnit\Framework\Constraint\TraversableContains contains(mixed $value) Constraint that asserts that the array or object that implements the Iterator interface contains a given value.
PHPUnit\Framework\Constraint\TraversableContainsOnly containsOnly(string $type) Constraint that asserts that the array or object that implements the Iterator interface contains only values of a given type.
PHPUnit\Framework\Constraint\TraversableContainsOnly containsOnlyInstancesOf(string $classname) Constraint that asserts that the array or object that implements the Iterator interface contains only instances of a given classname.
PHPUnit\Framework\Constraint\IsEqual equalTo($value, $delta = 0, $maxDepth = 10) Constraint that checks if one value is equal to another.
PHPUnit\Framework\Constraint\DirectoryExists directoryExists() Constraint that checks if the directory exists.
PHPUnit\Framework\Constraint\FileExists fileExists() Constraint that checks if the file(name) exists.
PHPUnit\Framework\Constraint\IsReadable isReadable() Constraint that checks if the file(name) is readable.
PHPUnit\Framework\Constraint\IsWritable isWritable() Constraint that checks if the file(name) is writable.
PHPUnit\Framework\Constraint\GreaterThan greaterThan(mixed $value) Constraint that asserts that the value is greater than a given value.
PHPUnit\Framework\Constraint\LogicalOr greaterThanOrEqual(mixed $value) Constraint that asserts that the value is greater than or equal to a given value.
PHPUnit\Framework\Constraint\ClassHasAttribute classHasAttribute(string $attributeName) Constraint that asserts that the class has a given attribute.
PHPUnit\Framework\Constraint\ClassHasStaticAttribute classHasStaticAttribute(string $attributeName) Constraint that asserts that the class has a given static attribute.
PHPUnit\Framework\Constraint\ObjectHasAttribute objectHasAttribute(string $attributeName) Constraint that asserts that the object has a given attribute.
PHPUnit\Framework\Constraint\IsIdentical identicalTo(mixed $value) Constraint that asserts that one value is identical to another.
PHPUnit\Framework\Constraint\IsFalse isFalse() Constraint that asserts that the value is false.
PHPUnit\Framework\Constraint\IsInstanceOf isInstanceOf(string $className) Constraint that asserts that the object is an instance of a given class.
PHPUnit\Framework\Constraint\IsNull isNull() Constraint that asserts that the value is null.
PHPUnit\Framework\Constraint\IsTrue isTrue() Constraint that asserts that the value is true.
PHPUnit\Framework\Constraint\IsType isType(string $type) Constraint that asserts that the value is of a specified type.
PHPUnit\Framework\Constraint\LessThan lessThan(mixed $value) Constraint that asserts that the value is smaller than a given value.
PHPUnit\Framework\Constraint\LogicalOr lessThanOrEqual(mixed $value) Constraint that asserts that the value is smaller than or equal to a given value.
logicalAnd() Logical AND.
logicalNot(PHPUnit\Framework\Constraint $constraint) Logical NOT.
logicalOr() Logical OR.
logicalXor() Logical XOR.
PHPUnit\Framework\Constraint\PCREMatch matchesRegularExpression(string $pattern) Constraint that asserts that the string matches a regular expression.
PHPUnit\Framework\Constraint\StringContains stringContains(string $string, bool $case) Constraint that asserts that the string contains a given string.
PHPUnit\Framework\Constraint\StringEndsWith stringEndsWith(string $suffix) Constraint that asserts that the string ends with a given suffix.
PHPUnit\Framework\Constraint\StringStartsWith stringStartsWith(string $prefix) Constraint that asserts that the string starts with a given prefix.

Command-Line

Ref: 3. The Command-Line Test Runner — PHPUnit 9.6 Manual.

phpunitコマンドでいろいろできる。いくつか重要なオプション、使用方法がある。

  • phpunit file.php: 指定したファイルのテストを実行。
  • --testsuite <name>: テストを指定。

8. Test Doubles

Ref: 8. Test Doubles — PHPUnit 9.6 Manual.

About

テスト時に、依存関係を模擬したもので置換したいことがある。PHPUnitにそういう仕組が用意されている。

テストダブル - Wikipedia」Doubleは代役、影武者を意味する。ロックマンX4のダブルはたぶんこの英単語が由来だろう。

テスト対象クラスのプロパティーのクラスやメソッドの代用品をテストダブルと呼ぶ。テストダブルで置換するというような言葉の使い方をすると思われる。

テストダブルには、stubとmockがある。

  • Stub: SUT内メソッド戻り値の検証
  • Mock: SUT内メソッド引数の検証。

メソッド内で他のクラス・メソッドを使う場合はmockで対象クラスを模擬させる。

PHPUnitではテストダブル用に3の基本APIがある。

  • createStub
  • createMock
  • getMockBuilder

引数に置換対象のクラスを指定してインスタンスを作成する。createStubとcreateMockは対象を全部置換する。getMockBuilderはこれら2種のメソッドを含んでいて、置換するメソッド・プロパティーを自分で選択できる。特定メソッドだけを置換して、他はそのまま使いたい場合、これを使うしかない。

Stub

オブジェクトのメソッドの戻り値を、テストダブルに置換する手法をスタブと呼ぶ。メソッド内の特定メソッドの戻り値を模擬することで、テストと関係ない処理の影響を無視できる。

実際に使う際は、テスト対象クラスのインスタンス作成後に、インスタンスがのプロパティーに設定して、インスタンス内の別クラスメソッド呼び出しを模擬する。

<?php declare(strict_types=1);
class SomeClass
{
    public function doSomething()
    {
        // Do something.
    }
}
<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;

final class StubTest extends TestCase
{
    public function testStub(): void
    {
        // Create a stub for the SomeClass class.
        $stub = $this->createStub(SomeClass::class);

        // Configure the stub.
        $stub->method('doSomething')
             ->willReturn('foo');

        // Calling $stub->doSomething() will now return
        // 'foo'.
        $this->assertSame('foo', $stub->doSomething());
    }
}

単に、メソッドの戻り値を模擬したいだけなら、expectsとwithは不要。method()->willReturnだけで十分。

Table 8.1 Stubbing short hands
short hand longer syntax
willReturn($value) will($this->returnValue($value))
willReturnArgument($argumentIndex) will($this->returnArgument($argumentIndex))
willReturnCallback($callback) will($this->returnCallback($callback))
willReturnMap($valueMap) will($this->returnValueMap($valueMap))
willReturnOnConsecutiveCalls($value1, $value2) will($this->onConsecutiveCalls($value1, $value2))
willReturnSelf() will($this->returnSelf())
willThrowException($exception) will($this->throwException($exception))

willReturnCallbackで呼び出し関数をまるごと別のものに置換できる。これが非常に便利。

引数を指定したい場合、with(引数)->willReturn()がシンプル。ただし、この方法は最後に指定した引数1個しか対応できない。引数違いで戻り値を模擬したいなら、willReturnMapかwillReturnCallbackで、引数に応じた戻り値を設定する。

willReturnCallbackのcallbackに渡される引数の記載はないが、モック対象メソッドに渡される引数が、その順番で渡される動きになっている。

Mock Objects

https://chatgpt.com/share/683e72cc-461c-800b-a7fc-5e8b417a44aa

メソッドが呼び出されたかどうかを検証するための、テストダブルへの置換をモッキングと呼ぶ。

テスト対象メソッド内で、特定メソッドの呼び出しを検査できる。MVCでViewへの値渡しなんかの検査にうってつけ。

モックオブジェクトにはテストスタブの機能も含んでいる。が、モッキングのための専用処理があるので、インスタンス生成時などのコストがやや大きい。理由がなければ、Stubを使った方がいい。

判断基準として、expects/withを使うならMock、そうじゃなければStubのようなイメージ。

<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;

class Subject
{
    protected $observers = [];
    protected $name;

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

    public function getName()
    {
        return $this->name;
    }

    public function attach(Observer $observer)
    {
        $this->observers[] = $observer;
    }

    public function doSomething()
    {
        // Do something.
        // ...

        // Notify observers that we did something.
        $this->notify('something');
    }

    public function doSomethingBad()
    {
        foreach ($this->observers as $observer) {
            $observer->reportError(42, 'Something bad happened', $this);
        }
    }

    protected function notify($argument)
    {
        foreach ($this->observers as $observer) {
            $observer->update($argument);
        }
    }

    // Other methods.
}

class Observer
{
    public function update($argument)
    {
        // Do something.
    }

    public function reportError($errorCode, $errorMessage, Subject $subject)
    {
        // Do something
    }

    // Other methods.
}
<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;

final class SubjectTest extends TestCase
{
    public function testObserversAreUpdated(): void
    {
        // Create a mock for the Observer class,
        // only mock the update() method.
        $observer = $this->createMock(Observer::class);

        // Set up the expectation for the update() method
        // to be called only once and with the string 'something'
        // as its parameter.
        $observer->expects($this->once())
                 ->method('update')
                 ->with($this->equalTo('something'));

        // Create a Subject object and attach the mocked
        // Observer object to it.
        $subject = new Subject('My subject');
        $subject->attach($observer);

        // Call the doSomething() method on the $subject object
        // which we expect to call the mocked Observer object's
        // update() method with the string 'something'.
        $subject->doSomething();
    }
}

基本的な作り。

  1. createMock(<class>::class)で該当クラスのモックを作成。
  2. expectsに呼出回数条件のオブジェクトをセット。これはなくてもいい。
  3. methodで対象メソッドを指定。
  4. withで該当メソッドの引数の検証条件を指定。
  5. willReturnなどで戻り値を指定。

デフォルトで模擬実装はnullを返す。戻り値を変更したければ、will($this->returnValue())などを指定する。よく使うので短縮記法もある。

Matcher

使えるメソッド。[https://github.com/sebastianbergmann/phpunit/blob/main/src/Framework/TestCase.php#L959] あたりからの一連のメソッドと思われる。

  • any
  • never
  • atLeast
  • once
  • exactly (旧at)
  • atMost
Table 8.2 Matchers
Matcher Meaning
PHPUnit\Framework\MockObject\Matcher\AnyInvokedCount any() Returns a matcher that matches when the method it is evaluated for is executed zero or more times.
PHPUnit\Framework\MockObject\Matcher\InvokedCount never() Returns a matcher that matches when the method it is evaluated for is never executed.
PHPUnit\Framework\MockObject\Matcher\InvokedAtLeastOnce atLeastOnce() Returns a matcher that matches when the method it is evaluated for is executed at least once.
PHPUnit\Framework\MockObject\Matcher\InvokedCount once() Returns a matcher that matches when the method it is evaluated for is executed exactly once.
PHPUnit\Framework\MockObject\Matcher\InvokedCount exactly(int $count) Returns a matcher that matches when the method it is evaluated for is executed exactly $count times.
PHPUnit\Framework\MockObject\Matcher\InvokedAtIndex at(int $index) Returns a matcher that matches when the method it is evaluated for is invoked at the given $index.
Mock builder

https://chatgpt.com/share/68424beb-e544-800b-9292-b79f89cf412d

createStubやcreateMockは対象クラスを全部置換する。一部だけ置換したり、もともと存在しないメソッドを追加したり、複雑なことをしたい場合、getMockBuilderで取得する、Mock Builderを使う必要がある。メソッド一覧は以下となる。

  • onlyMethods(array $methods)Mock Builderオブジェクトで呼び出すことで、設定可能なテストダブルに置き換えるメソッドを指定できます。他のメソッドの動作は変更されません。各メソッドは、指定されたモッククラス内に存在している必要があります。
  • addMethods(array $methods)Mock Builderオブジェクトで呼び出すことで、指定されたモッククラスに(まだ)存在しないメソッドを指定できます。他のメソッドの動作は同じです。
  • setMethodsExcept(array $methods)Mock Builderオブジェクトで を呼び出すことで、他のすべてのパブリックメソッドを置き換えながら、設定可能なテストダブルに置き換えないメソッドを指定できます。これは の逆の動作をしますonlyMethods()
  • setConstructorArgs(array $args)元のクラスのコンストラクターに渡されるパラメーター配列を提供するために呼び出すことができます (デフォルトではダミー実装に置き換えられません)。
  • setMockClassName($name)生成されたテストダブルクラスのクラス名を指定するために使用できます。
  • disableOriginalConstructor()元のクラスのコンストラクターへの呼び出しを無効にするために使用できます。
  • disableOriginalClone()元のクラスのクローンコンストラクターの呼び出しを無効にするために使用できます。
  • disableAutoload()__autoload()テストダブルクラスの生成中に無効にするために使用できます。
use PHPUnit\Framework\TestCase;

class MyClassTest extends TestCase
{
    public function testAddColumnAddsNewColumn()
    {
        // fetchDataだけをスタブ化する
        $stub = $this->getMockBuilder(MyClass::class)
                     ->onlyMethods(['fetchData']) // ← ここでfetchDataだけモック化
                     ->getMock();

        // fetchDataが返す想定の配列をセット
        $stub->method('fetchData')->willReturn([
            ['id' => 1, 'name' => 'Alice'],
            ['id' => 2, 'name' => 'Bob'],
        ]);

        // addColumnを呼び出してテスト
        $result = $stub->addColumn();

        // 検証
        $this->assertEquals('value', $result[0]['new_column']);
        $this->assertEquals('value', $result[1]['new_column']);
    }
}

上記のように、最終的にgetMock()でインスタンスを取得して、methodなどで戻り値を設定する。

MVC

https://chatgpt.com/share/68395ad2-678c-800b-b27a-19c58e3a3a0a

MVC系のアプリで、Viewにセットする値・変数をチェックしたいことがある。

この場合、ControllerでViewにセットしているメソッドを試験する。

$controller-><view>->assign()
$controller->setApp()

これらのメソッドを、expects/withで引数を検査する。willReturnはいらない。assert系のメソッドも呼ばなくていい。expects/withで引数の検査をするイメージ。

expects

https://chatgpt.com/share/682e8fd6-376c-800b-a01e-faa832b0fd07

8. Test Doubles — PHPUnit 9.6 Manual」でモックオブジェクトを作成時に、模擬したメソッドの設定時に、expectsを使っている。

このexpectsは該当メソッドが呼ばれたか、呼ばれなかったかを試験するためのもの。例えば、if文で条件分岐していて、その中で該当メソッドが呼ばれたかどうかをチェックできる。

一本道で、呼び出し有無を気にしなくていいなら、expectsは不要。if文の条件を気にしなくて、一緒に試験できるのが便利。

なお、expectsを使わない場合、同名で競合するので、methodという名前のメソッドが、モックオブジェクトにあってはいけない。ある場合、expects($this->any())でexpectsを挟む必要がある。

ただ、methodという名前のメソッドがあることは普通ないと思う。

このexpectsは「phpunit/src/Framework/MockObject/Runtime/Interface/MockObject.php at 5226e323514a73151c1c7909af224c01a1bbe9aa · sebastianbergmann/phpunit

interface MockObject extends Stub
{
    public function expects(InvocationOrder $invocationRule): InvocationStubber;
}

これが定義な模様。引数にはInvocationOrderをとる。InvocationOrderは「phpunit/src/Framework/MockObject/Runtime/Rule/InvokedCount.php at main · sebastianbergmann/phpunit」などで継承されている。

with

モックオブジェクトのメソッドの引数の検証に使うメソッド。ソースコードは以下。

https://chatgpt.com/share/683d657e-8578-800b-8e71-b09dbce969dd

8. Test Doubles — PHPUnit 9.6 Manual」に記載がある。

The with() method can take any number of arguments, corresponding to the number of arguments to the method being mocked. You can specify more advanced constraints on the method’s arguments than a simple match.

モック対象メソッドの個数と同じ引数を受け取れる。

それぞれの引数の位置で、該当引数の検証条件を指定する。指定可能な内容は以下の3種類。

  1. リテラル値
  2. Constraint: 指定可能なConstraints (制約) は「1. Assertions — PHPUnit 9.6 Manual」にある。
  3. callback: callbackの引数は、検証対象の引数で、OKならtrueを返す。

配列要素数や、複数のConstraintを組み合わせたい場合などは、callbackを使うしかない。

setAppやassignなどで、同じメソッドで1番目の引数に応じて、2番目の内容が変わる場合、工夫が必要。

expectやwithでは検証しないで、willReturnCallbackを使うしかない。連想配列に、実際に渡ってきたキー・バリューのセットを格納して、実行後にまとめてassertするか、コールバック内でassertしてチェックする。

$mock->method('assign')
     ->willReturnCallback(function ($key, $val) {
         switch ($key) {
             case 'title':
                 PHPUnit\Framework\Assert::assertSame('マイページ', $val);
                 break;
             case 'user':
                 PHPUnit\Framework\Assert::assertInstanceOf(User::class, $val);
                 break;
             default:
                 PHPUnit\Framework\Assert::fail("Unexpected key: $key");
         }
     });
$assigned = [];

$mock->method('assign')
     ->willReturnCallback(function ($key, $val) use (&$assigned) {
         $assigned[$key] = $val;
     });

// テスト対象実行後にチェック
$this->assertSame('マイページ', $assigned['title'] ?? null);
$this->assertInstanceOf(User::class, $assigned['user'] ?? null);

willReturnCallback内のifでいい気がする。複雑なら後者のスパイ風で。

willReturnCallbackでやる場合、Viewに渡すメソッドのもともとの戻り値がnullだから問題ないが、そうでない場合は、ちゃんとreturnでダミーの値を返さないと、後続の処理で不都合出るので注意する。

withで検査する場合、self::assertSame()でメソッドの検証はせずに、単にテスト対象メソッドを呼び出す。呼び出すと、withで仕込んだものが呼ばれて、中で検証するイメージになる。

DI

https://chatgpt.com/share/683faf08-f374-800b-ac40-b96b427d7365

テスト対象クラスのプロパティーのインスタンスをテストダブルに置換する場合、注意が必要。だいたい、privateになっているから、DIで渡す前に、置換対象のメソッドの模擬を設定してから、コンストラクターに渡す必要がある。

面倒くさかったら、テストダブルインスタンスの作成+メソッド設定を共通メソッドにしてもよいかもしれない。が、テストダブルの戻り値は、DB取得結果とかだと大事なので、手間だが1個ずつやった方がいいかもしれない。

protected function prepareLoggerWithLogReturn($value): LoggerInterface
{
    $logger = $this->createLoggerMock();
    $logger->method('log')->willReturn($value);
    return $logger;
}

$logger = $this->prepareLoggerWithLogReturn(true);
外部static/グローバル関数の模擬

https://chatgpt.com/share/68424beb-e544-800b-9292-b79f89cf412d

例えば、ログインユーザーIDなど、引数に渡すまでもない共通値の取得が、SUTのメソッド内にあったりする。ただ、こういうstaticやグローバルなメソッド・関数は、PHPUnitで置換が難しい。

回避方法がいくつかある。

  1. DIで対象staticメソッドクラスをプロパティーに持たせる。
  2. 1と似た考えで、ラッパークラスを作る。
  3. 対象クラス内で、ラッパーメソッド (protected) を用意する。

1や2が望ましいようだが、3のラッパーメソッド用意は簡単。ひとまず3でいい。

テストのためだけに意味ない関数を追加するように見える。が、「テストできないコードはそれだけで設計に問題がある」とも言える。そういうものと思っておくと良いらしい。

Other

Test private/protected

クラスのprivate/protectedメソッドのテストには工夫が必要となる。

    /**
     * privateメソッドを実行する.
     * @param object $sut テスト対象のインスタンス。
     * @param string $method_name privateメソッドの名前。
     * @param array $param privateメソッドに渡す引数。
     * @return mixed 実行結果。
     * @throws \ReflectionException 引数のクラスがない場合に発生.
     */
    private function doMethod(object $sut, string $method_name, array $param): mixed
    {
        // ReflectionClassをテスト対象のクラスをもとに作る.
        $reflection = new \ReflectionClass($sut);
        // メソッドを取得する.
        $method = $reflection->getMethod($method_name);
        // アクセス許可をする.
        $method->setAccessible(true);
        // メソッドを実行して返却値をそのまま返す.
        return $method->invokeArgs($sut, $param);
    }

ReflectionClassを使って取得できる。上記の関数のFormController部分を試験対象のクラスに差し替えればOK。getProperty/getValueでprivateプロパティーも取得可能。

https://chatgpt.com/c/673ebadb-1638-800b-89f0-b3ed131317da

基本はpublicメソッドのみテストすべきという考え方。

class MyClass {
    private function myPrivateMethod($a, $b) {
        return $a + $b;
    }
}

class MyClassTest extends PHPUnit\Framework\TestCase {
    public function testMyPrivateMethod() {
        $object = new MyClass();

        // Reflectionを使ってprivateメソッドにアクセス
        $reflection = new ReflectionClass($object);
        $method = $reflection->getMethod('myPrivateMethod');
        $method->setAccessible(true);

        // メソッドを呼び出してテスト
        $result = $method->invoke($object, 2, 3);
        $this->assertEquals(5, $result);
    }
}
Test header

Ref: unit testing - Test PHP headers with PHPUnit - Stack Overflow.

header関数を使用する場合、phpunitの標準出力と干渉して以下のエラーが出て試験できない。

Cannot modify header information - headers already sent by (output started at .../vendor/phpunit/phpunit/src/Util/Printer.php:138)

回避方法が2種類ある。

  1. @runInSeparateProcess
  2. phpunit --stderr

1個目のアノテーションをテストメソッドに指定すると別プロセスでの実行になる。ただ、プロセス生成は時間がかかるため、試験が多いと効率が悪い。

2個目のphpunitの出力を標準エラーに出力させる方法がシンプルで効率もいい。phpunit.xmlに stderr="true"を指定するとキー入力を省略できる。こちらで対応しよう。

Test exit

Ref:

header()後のexit()など、exit/dieを使用するコードがある。phpunit内でこれらがあると、テストも強制終了になる。

上記の別プロセスで実行していた場合、以下のエラーになる。

Test was run in child process and ended unexpectedly

対処方法がいくつかある。

  1. exitを使わないコードに変更。
  2. isTestのようなフラグを元コードに入れてテスト可否で分岐してexitを回避。
  3. execで外部プロセスで実行してexitCodeを試験。
  4. exit/die部分だけ別関数に抽出してmockで置換?

<https://notabug.org/gnusocialjp/gnusocial/src/main/actions/apiaccountregister.php> のclientErrorが内部でexitする。

このclientErrorをwillなどで置換すればよさそう?

sut

https://chatgpt.com/share/682ada62-d3e0-800b-9444-db245dc44183

試験対象のクラスをメンバー変数・プロパティーに格納する。その際の名前を何にするか?

instance/classとかが思いつく。対話AIによると、sutというのがいいらしい。system under testの略。テスト対象の意味。試験の専門用語っぽい。短いのでこれでいいと思う。

表示内容の試験

画面UIに該当文字列があるかどうかなどを試験したいことがある。

LaravelにはassertSeeというのがあるのでこれを使える。

PHPUnit自体にはない。assertStringContainsStringなど、文字列試験メソッドを使って、自分でresponseを何かで取得して評価する。こういうのは基本は機能試験で行う内容。

単体試験と機能試験

https://chatgpt.com/share/682ada62-d3e0-800b-9444-db245dc44183

単体試験と機能試験がある。

単体試験は、基本的にクラス単位。クラスのメソッドを試験するイメージ。

機能試験は、特定機能に関する、クラス・メソッドを試験する。1個の試験で、複数クラスを試験する違いがある。

実際のアプリ開発では、ユーザー動作や仕様の動作が重要だから、機能試験中心で問題ない気がする。

重要なところ、複雑なところ、バグの多いところをUnitTestで試験するのがいいと思う。メンテ不能なテストができてしまうのは避けたい。

  • tests
    • Unit
    • Feature

Unitは元ファイルのディレクトリー構成にして、Featureは機能単位。Featureで機能単位の試験を入れる。で、基本はFeatureを拡充させる。

ファイル名・クラス名。Featureの方はXXFreatureTest.phpとかにすることが多いらしい。が、せっかくディレクトリー分けている意味がないので、Test.phpでいいでしょう。

result cache

phpunitを実行すると、.phpunit.result.cacheファイルが作成される。

phpunit.xmlのcacheResultがデフォルトtrueになっており作成されている。

  • phpunit.xml
    • phpunit.cacheResult: 初期値true。結果キャッシュ作成有無。
    • phpunit.cacheResultFile: 初期値.phpunit.result.cache。ファイルパス。
  • option
    • --cache-result: キャッシュ結果を出力する (既定)。
    • --do-not-cache-result: キャッシュ結果を出力しない。
    • --cache-result-file <file>: キャッシュ結果のパスを指定する (既定: ./.phpunit.result.cache)。

cacheResultFileは他にPHPUNIT_RESULT_CACHE環境変数でも設定できる模様。

phpstanの結果キャッシュをvar/tmp/phpstanに配置しているので、これにならってvar/tmp/phpunit/.phpunit.result.cacheにするといいかも。

<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="tests/bootstrap.php" cacheResultFile="var/tmp/phpunit/.phpunit.result.cache" colors="true">
    <testsuites>
        <testsuite name="tests">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
</phpunit>
getallheaders()
Error: Call to undefined function getallheaders()

apache_request_headers()のエイリアス。サーバー固有のAPIはPHPUnit実行時は使えないので、bootstrap.phpに、代替関数を用意する。

if (!function_exists('getallheaders')) {
    function getallheaders() {
    $headers = [];
    foreach ($_SERVER as $name => $value) {
        if (substr($name, 0, 5) == 'HTTP_') {
            $headers[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value;
        }
    }
    return $headers;
    }
}
PHP Fatal error:  Cannot declare interface PHPUnit_Framework_SelfDescribing, because the name is already in use in
     PHP Fatal error:  Cannot declare interface PHPUnit_Framework_SelfDescribing, because the name is already in use in /common/php/src/PHPUnit/Framework/SelfDescribing.php on line 58                                  
     Fatal error: Cannot declare interface PHPUnit_Framework_SelfDescribing, because the name is already in use in /common/php/src/PHPUnit/Framework/SelfDescribing.php on line 58                                       
      while running parallel worker

古いPHPUnitがある環境に、新しめのPHPUnitがある状態で、PHPStanを実行すると上記のエラーが出てしまった。

https://chatgpt.com/share/6837b3e4-01ac-800b-a547-164fcca8dbf8

古いPHPUnitと新しいPHPUnitとで、同じクラスでも指定方法が変わっている。その都合で、両方がinclude_pathにあると競合する。include_pathから除外する必要がある。

テストのグループ化

https://chatgpt.com/share/68390cd8-5cc4-800b-afce-36c2abdc4b83

Jestにはdescribe()内にit()を入れるような、テストメソッドのネスト構造ができた。PHPUnitにはそれがない。

@groupアノテーションがあるが、これは--groupや--exclude-groupで指定したグループに属するテストを対象にしたり、除外したりするためのもの。ネストとは概念が異なる。

ネスト構造はクラスとメソッドの親子関係での表現になる。分けたかったら、テストメソッドの命名規則などになる。