WordPress独自テーマファイルを対象にPHPUnitでテストしたい

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426622/e6123a48-28c6-2ea8-8dd1-10a0fc89983f.png)

## ユニットテストについて

近年のプログラミングの現場ではユニットテストは当たり前のように求められ、コードのデプロイのサイクルにも取り入れられているものと思います。

これはプログラミング言語を問わず言えることでしょう。

## WordPressの場合はどうなのか

一方でWordPressを用いたWebサイト制作においては、ユニットテストの積極的にやっているケースは少ないように思います。
多くのWordpressサイト構築を引き継いできましたが、これまでの経験でテストコードが存在するようなものはありませんでした。

これまで社内でWordPressサイトを構築する場合も[theme-test-data-ja](https://github.com/jawordpressorg/theme-test-data-ja)やE2Eテストのようなものは状況に応じて実施するものの、ユニットテストのコード作成は行ってきませんでした。

それでは、WordPressサイト構築においても、PHPUnitでテストしたいとなった場合はどのようにすれば良いでしょうか。
有名どころの言語やフレームワークなどにおいてはユニットテストについて十分な情報が提供されていますが、WordPress独自テーマ作成によるWebサイト構築においては、具体的な最適解が分かりにくい傾向にあります。

そもそも、WordPressサイトの場合は中核となる業務領域を担う（すなわち複雑な業務ロジックの実装が）必要がなく、あくまでプレゼンテーション層レベルの話や単純なCRUDで完結するため、ユニットテストの重要性が求められないということもあるかもしれません。

どちらかというと、機能面よりもデザイン通りに実装されているかの確認の方が関心の対象となるでしょう。

しかし、WordPressと言えどもプログラムの塊であることには違いありませんし、日々の運用においてテストの仕組みが整備されていないと常に既存の機能を壊す心配を抱えることになり、デプロイの心理的負荷は上がります。

この文書では、WordPressの独自テーマ開発においてのPHPUnitの導入と具体的なテストコードについて記載したいと思います。

## 使用技術

調べたところ、2024年8月現在の標準的なWordPressでのユニットテストの手法としては下記が良さそうです。

* 動作環境には[WordPress標準制作フロー2024](https://qiita.com/koji-yamauchi/items/6a72ca8c2550f3433ce4)でも紹介した`@wordpress/env`の[テスト用インスタンス](https://ja.wordpress.org/team/handbook/block-editor/reference-guides/packages/packages-env/#tests-%E3%82%A4%E3%83%B3%E3%82%B9%E3%82%BF%E3%83%B3%E3%82%B9%E3%81%A7-wordpress-%E3%82%B7%E3%82%A7%E3%83%AB%E3%82%92%E9%96%8B%E3%81%8Dphp-%E3%82%B3%E3%83%9E%E3%83%B3%E3%83%89%E3%82%92%E5%AE%9F%E8%A1%8C)を利用
* テストケースには[Yoast/wp-test-utils](https://github.com/Yoast/wp-test-utils)を利用

## 導入手順

### @wordpress/env

まずは、`@wordpress/env`の導入します。

```bash
npm init
npm i @wordpress/env --save-dev	
```

`.wp-env.json`を設置します。
ここでは、`WP_DEBUG`を`true`にするのと、マッピングの設定のみを行いました。
（`data`ディレクトリは中にカバレッジレポートのHTMLを出力するために作成しています。）

```json
{
  "config": {
    "WP_DEBUG": true
  },
  "mappings": {
    "../data": "./data",
    "wp-content/themes": "./themes"
  }
} 
```

`wp-env`を開始しますが、XDebugを有効にするには[^1]下記のように`--xdebug`オプションを付加する必要があります。

```bash
npx wp-env start --xdebug
```

[^1]: XDebugを有効にしているのはコードカバレッジレポートを出力するためです。

### テーマファイルを作成

WP CLIを用いて[Underscores](https://underscores.me/)を雛形としたテーマを作成します。

```bash
npx wp-env run cli wp scaffold _s sample-theme
```

作成したテーマへの切り替えとComposerで定義されたライブラリのインストールとPHPUnitの導入を行いましょう。

```bash
npx wp-env run cli wp theme activate sample-theme
npx wp-env run cli --env-cwd=wp-content/themes/sample-theme/ composer update
npx wp-env run cli --env-cwd=wp-content/themes/sample-theme/ composer require --dev yoast/wp-test-utils phpunit/phpunit --with-dependencies
```

### PHPUnitの関連ファイル作成

`themes/sample-theme/phpunit.xml.dist` を設置します。

```xml
<?xml version="1.0"?>
<phpunit bootstrap="_tests/bootstrap.php" colors="true">
	<coverage>
		<include>
			<directory suffix=".php">./</directory>
		</include>
		<exclude>
			<directory suffix=".php">./vendor/</directory>
			<directory suffix=".php">./_tests/</directory>
		</exclude>
		<report>
			<text outputFile="php://stdout" showOnlySummary="true" />
		</report>
	</coverage>
	<testsuites>
		<testsuite name="testing">
			<directory suffix="-test.php">./_tests/</directory>
		</testsuite>
	</testsuites>
	<logging />
</phpunit>
```

`themes/sample-theme/_tests/bootstrap.php` を設置します。
`active_plugins`でテスト時に有効となっている必要があるプラグインを定義します。
ここでは[Advanced Custom Fields](https://ja.wordpress.org/plugins/advanced-custom-fields/)を追加しています。

```php
<?php
use Yoast\WPTestUtils\WPIntegration;

$GLOBALS['wp_tests_options'] = array(
	'active_plugins' => array(
		'advanced-custom-fields-pro/acf.php',
	),
);

require_once dirname( __DIR__ ) . '/vendor/yoast/wp-test-utils/src/WPIntegration/bootstrap-functions.php';

WPIntegration\bootstrap_it();

switch_theme( 'sample-theme' );
```


簡単にテストを実行できるように`composer.json`の`scripts`に下記を追加します。

```json
"test": "XDEBUG_MODE=coverage ./vendor/bin/phpunit --coverage-html /var/www/data/_coverage-html"
```

これにより、下記のようにテストを実行することができます。

```bash
npx wp-env run tests-cli --env-cwd=wp-content/themes/sample-theme/ composer test
```

:::note
**まだコマンドが長すぎますか？**
`composer.json`の`scripts`で定義したことで、`tests-cli`インスタンス上の`wp-content/themes/sample-theme/`へ移動して`composer test`するだけに簡略化できましたが、この処理を一つのコマンドにするとまだ長すぎると感じるかもしれません。
その場合は、`package.json`の`scripts`も併せて定義すると良いでしょう。

```json
"test": "wp-env run tests-cli --env-cwd=wp-content/themes/sample-theme/ composer test"
```

これでテストの実行は`npm run test`で呼び出せるようになりました。
:::

## テストコード例

このようなテストコードとなります。

`themes/sample-theme/_tests/class-basic-test.php`

```php
<?php
use Yoast\WPTestUtils\BrainMonkey\TestCase;

class BasicTest extends TestCase {
	protected function set_up() {
		parent::set_up();
	}

	public function testThemeName() {
		$my_theme = wp_get_theme();
		$this->assertEquals( 'Sample-theme', $my_theme->get( 'Name' ) );
	}
}
```

もし、`functions.php`でカスタマイズされた機能や関数をテストする場合は、`set_up`の中で読み込むと良いでしょう。

例えば、下記のようなコードの場合です。

**テスト対象となるコード:**

```php
// Yoast SEOのパンくずをカスタマイズ
function my_wpseo_breadcrumb_links( $breadcrumbs ) {
	// 具体的な実装は省略
}
add_filter( 'wpseo_breadcrumb_links', 'my_wpseo_breadcrumb_links', 10, 1 );
```

**テストコード:**

```php
<?php

use Yoast\WPTestUtils\BrainMonkey\TestCase;

class WpseoTest extends TestCase {
	protected function set_up() {
		parent::set_up();
		require_once get_template_directory() . '/functions.php';
	}
 
	public function testWpseo() {
		// ここでは簡易的な例示のため配列かどうかのみをテストしていますが
		// 実際にはもう少し複雑なテストになるでしょう。
		$after_breadcrumbs = my_wpseo_breadcrumb_links( array() );
		$this->assertIsArray( $after_breadcrumbs );
	}
}
```

### テンプレートファイルのテスト

`functions.php`で定義されているような関数のテストについては上記のやり方で十分でしょう。一方で、`archive.php`や`single.php`のようなテーマファイルのテストも自動化したくなります。
（厳密にいうと、機能単体のテストではなくなるので、統合テストに近いものとなりますが）

Underscoresの`archive.php`テンプレートを例にテストを記載してみましょう。

**テストコード:**

`themes/sample-theme/_tests/class-archive-test.php`

```php
<?php
use Yoast\WPTestUtils\BrainMonkey\TestCase;

class ArchiveTest extends TestCase {
	protected function set_up() {
		parent::set_up();
		require_once get_template_directory() . '/functions.php';
	}

	public function testEmptyPostsArchivePage() {
		global $wp_query;

		$wp_query = new WP_Query( array( 'post_type' => 'post' ) );

		ob_start();
		include get_template_directory() . '/archive.php';
		$output = ob_get_clean();
		$this->assertDoesNotMatchRegularExpression( '/error/i', $output );
	}

	public function testHasPostsArchivePage() {
		global $wp_query;

		// Generate dummy data.
		for ( $i = 1; $i <= 5; $i++ ) {
			wp_insert_post(
				array(
					'post_title'   => 'SamplePost' . $i,
					'post_type'    => 'post',
					'post_status'  => 'publish',
				)
			);
		}

		$wp_query = new WP_Query( array( 'post_type' => 'post' ) );

		ob_start();
		include get_template_directory() . '/archive.php';
		$output = ob_get_clean();
		$this->assertDoesNotMatchRegularExpression( '/error/i', $output );
	}
}
```

**カバレッジレポート:**

`data/_coverage-html/archive.php.html`

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426622/041f0f0c-d01b-e574-ce38-36a7f50f7d7d.png)

`WPTestUtils`は実行時にデータがリセットされるので必要に応じて`wp_insert_post`などでデータを追加してからテストを行っているのと、必要に応じて`$wp_query`などのグローバル変数の書き換えがポイントとなります。

`testEmptyPostsArchivePage`では`if ( have_posts() ) :`が`false`となるテスト、`testHasPostsArchivePage`では`true`となるテストを記載しています。

ここでは単純に出力HTMLに`error`という文字列が含まれていないか確認していますが、実際には想定した数の要素が出ているかなどをテストすると良いでしょう。

次に、`single.php`テンプレートのテスト例を記載します。

`themes/sample-theme/_tests/class-single-test.php`

```php
<?php
use Yoast\WPTestUtils\BrainMonkey\TestCase;

class SingleTest extends TestCase {
	protected function set_up() {
		parent::set_up();
		require_once get_template_directory() . '/functions.php';
	}

	public function testSinglePost() {
		global $wp_query;

		$insert_data = array(
			'post_title'   => 'SamplePost',
			'post_type'    => 'post',
			'post_status'  => 'publish',
		);
		$wp_insert_post = wp_insert_post( $insert_data );

		$wp_query = new WP_Query( array( 'post_id' => $wp_insert_post ) );

		ob_start();
		include get_template_directory() . '/single.php';
		$output = ob_get_clean();
		$this->assertDoesNotMatchRegularExpression( '/error/i', $output );

		$removed_line_breaks = str_replace( array( "\r\n", "\r", "\n" ), '', $output );
		$this->assertMatchesRegularExpression( '/<h2[^>]*>.*>' . $insert_data['post_title'] . '<.*<\/h2>/', $removed_line_breaks );
	}

	public function testSinglePage() {
		global $wp_query;

		$insert_data = array(
			'post_title'   => 'SamplePage',
			'post_type'    => 'page',
			'post_status'  => 'publish',
		);
		$wp_insert_post = wp_insert_post( $insert_data );

		$wp_query = new WP_Query( array( 'page_id' => $wp_insert_post ) );

		ob_start();
		include get_template_directory() . '/single.php';
		$output = ob_get_clean();
		$this->assertDoesNotMatchRegularExpression( '/error/i', $output );

		$removed_line_breaks = str_replace( array( "\r\n", "\r", "\n" ), '', $output );
		$this->assertMatchesRegularExpression( '/<h1[^>]*>\s*' . $insert_data['post_title'] . '\s*<\/h1>/', $removed_line_breaks );
	}
}
```

このテストでは、投稿と固定ページのダミーデータを作成し、HTML上に`error`という文字列が含まれていないかと、見出しタグに想定どおりのタイトルが入っているかを確認しています。

## まとめ

Symfonyなどのフレームワークでのユニットテストに慣れていると、グローバス変数を上書きしたり、投入データがフィクスチャとしてうまく分離できていなかったりと少し違和感がありますが、色々と手を動かした結果このような落としどころになりました。
もう少し良い方法が見つかればまた記事としてまとめたいと思います。

## 参考情報

https://github.com/Yoast/PHPUnit-Polyfills

https://github.com/Yoast/wp-test-utils

https://giuseppe-mazzapica.gitbook.io/brain-monkey