2026年7月7日

2026年7月7日

WordPressのDI(依存性注入)でテストしやすいコードを設計する方法

WordPress のプラグインやテーマのコードをテストしやすくするには、処理の中で get_option() や wp_remote_get() を直接呼ぶのをやめ、必要な部品を外から渡す「依存性注入(DI)」の形に組み替えるのが近道です。この記事では、小さなプラグインを例に、WordPress に依存する部分と純粋なロジックを分け、WordPress を起動しなくても PHPUnit で動くユニットテストを書けるところまでを説明します。DI コンテナを入れなくても、コンストラクタで部品を受け取るだけで十分に効果があります。

なぜ WordPress のコードはテストしにくいのか

典型的なプラグインのコードは、1つの関数の中で設定を読み、外部 API を呼び、結果を整形して HTML を返します。この関数をテストしようとすると、get_option() を呼ぶためにデータベースが、wp_remote_get() を呼ぶためにネットワークが必要になります。つまり「計算が正しいか」を確かめたいだけなのに、WordPress 全体と外部サービスを用意しなければなりません。

原因は、処理の中で依存先を自分で取りに行っていることです。グローバル関数、global $wpdb、静的メソッドの呼び出しはどれも差し替えが難しく、テストのときだけ偽物に置き換えることができません。

DI の考え方は単純で、「依存先は自分で作らず、外から受け取る」というものです。受け取る側はインターフェースだけを知っていればよいので、本番では WordPress の関数を使う実装を、テストでは決まった値を返す偽物を渡せます。

設計の方針:WordPress に触る層を薄くする

最初に、コードを次の3つに分けて考えます。

  • アダプター層:get_option() や wp_remote_get() など WordPress の関数を呼ぶクラス。中身は薄く、判断をほとんど持たせない
  • ドメイン層:入力を受け取って結果を返すロジック。WordPress の関数を一切呼ばない
  • 組み立てとフック登録:プラグインの起点ファイルで部品を組み立て、add_action() や add_shortcode() を登録する

ポイントは、テストしたいロジックをドメイン層に寄せ、アダプター層には「呼んで返すだけ」の処理しか残さないことです。アダプター層は統合テストで確認し、ドメイン層は高速なユニットテストで細かく確認する、という役割分担になります。

もう1つの方針は、コンストラクタの中で add_action() を呼ばないことです。オブジェクトを作っただけでフックが登録されると、テストでインスタンスを作るたびに WordPress の関数が必要になります。フック登録は register() のような専用メソッドに分け、起点ファイルから明示的に呼びます。

実装例:外部 API から気温を表示するショートコード

外部の天気 API から気温を取得し、ショートコードで表示する小さなプラグインを例にします。Composer の PSR-4 オートロードを使う前提で、名前空間は MyWeather とします。コンストラクタでのプロパティ宣言(コンストラクタ・プロモーション)を使うため PHP 8.0 以上が前提です。

まず、依存先をインターフェースとして定義します。

<?php
// src/SettingsRepository.php
namespace MyWeather;

interface SettingsRepository {
	public function get_api_key(): string;
}
<?php
// src/HttpClient.php
namespace MyWeather;

interface HttpClient {
	/** 成功時は連想配列、失敗時は null を返す */
	public function get_json( string $url ): ?array;
}

次に、ロジックを持つドメイン層のクラスです。WordPress の関数を呼んでいない点に注目してください。

<?php
// src/WeatherService.php
namespace MyWeather;

final class WeatherService {
	public function __construct(
		private SettingsRepository $settings,
		private HttpClient $http
	) {}

	public function current_temperature( string $city ): ?float {
		$key = $this->settings->get_api_key();
		if ( '' === $key ) {
			return null;
		}

		$url  = 'https://api.example.com/weather?' . http_build_query( [ 'city' => $city, 'key' => $key ] );
		$data = $this->http->get_json( $url );

		if ( ! is_array( $data ) || ! isset( $data['temp'] ) || ! is_numeric( $data['temp'] ) ) {
			return null;
		}
		return (float) $data['temp'];
	}
}

WordPress に触るのはアダプター層だけです。wp_remote_get() は失敗時に WP_Error を返すので、is_wp_error() で分岐してから本文を取り出します。

<?php
// src/WpHttpClient.php
namespace MyWeather;

final class WpHttpClient implements HttpClient {
	public function get_json( string $url ): ?array {
		$response = wp_remote_get( $url, [ 'timeout' => 5 ] );
		if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
			return null;
		}
		$data = json_decode( wp_remote_retrieve_body( $response ), true );
		return is_array( $data ) ? $data : null;
	}
}
<?php
// src/OptionsSettingsRepository.php
namespace MyWeather;

final class OptionsSettingsRepository implements SettingsRepository {
	public function get_api_key(): string {
		$value = get_option( 'myweather_api_key', '' );
		return is_string( $value ) ? $value : '';
	}
}

フックの登録と出力は Plugin クラスにまとめます。ショートコードの属性はユーザー入力なのでサニタイズし、出力は esc_html() でエスケープします。

<?php
// src/Plugin.php
namespace MyWeather;

final class Plugin {
	public function __construct( private WeatherService $weather ) {}

	public function register(): void {
		add_shortcode( 'my_weather', [ $this, 'render_shortcode' ] );
	}

	public function render_shortcode( $atts ): string {
		$atts = shortcode_atts( [ 'city' => 'Tokyo' ], $atts, 'my_weather' );
		$city = sanitize_text_field( $atts['city'] );
		$temp = $this->weather->current_temperature( $city );
		if ( null === $temp ) {
			return '';
		}
		return '<p class="my-weather">' . esc_html( sprintf( '%s: %.1f℃', $city, $temp ) ) . '</p>';
	}
}

最後に、プラグインの起点ファイルで部品を組み立てます。この「組み立てる場所」を1か所に決めておくのが DI の要です。

<?php
/**
 * Plugin Name: My Weather
 * Requires PHP: 8.0
 */
defined( 'ABSPATH' ) || exit;

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

add_action( 'plugins_loaded', static function () {
	$plugin = new \MyWeather\Plugin(
		new \MyWeather\WeatherService(
			new \MyWeather\OptionsSettingsRepository(),
			new \MyWeather\WpHttpClient()
		)
	);
	$plugin->register();
} );

WordPress なしで動くユニットテストを書く

WeatherService は WordPress の関数を呼ばないので、PHPUnit だけでテストできます。composer require --dev phpunit/phpunit で PHPUnit を入れ、インターフェースのスタブとモックを渡します。

<?php
// tests/WeatherServiceTest.php
use MyWeather\HttpClient;
use MyWeather\SettingsRepository;
use MyWeather\WeatherService;
use PHPUnit\Framework\TestCase;

final class WeatherServiceTest extends TestCase {
	public function test_returns_temperature_from_api(): void {
		$settings = $this->createStub( SettingsRepository::class );
		$settings->method( 'get_api_key' )->willReturn( 'test-key' );

		$http = $this->createMock( HttpClient::class );
		$http->expects( $this->once() )
			->method( 'get_json' )
			->with( $this->stringContains( 'key=test-key' ) )
			->willReturn( [ 'temp' => 21.5 ] );

		$service = new WeatherService( $settings, $http );
		$this->assertSame( 21.5, $service->current_temperature( 'Tokyo' ) );
	}

	public function test_returns_null_without_api_key(): void {
		$settings = $this->createStub( SettingsRepository::class );
		$settings->method( 'get_api_key' )->willReturn( '' );

		$http = $this->createMock( HttpClient::class );
		$http->expects( $this->never() )->method( 'get_json' );

		$service = new WeatherService( $settings, $http );
		$this->assertNull( $service->current_temperature( 'Tokyo' ) );
	}
}

2つ目のテストは「API キーが空なら通信しない」ことを確かめています。こうした分岐の確認は、実際の API を呼ぶテストでは書きにくい部分です。テストは数ミリ秒で終わるので、保存のたびに回せます。

確認方法としては、vendor/bin/phpunit tests を実行して2件とも成功すること、続いて WeatherService の中にわざと get_option() を書き足すとテストが「未定義の関数」で落ちることを見てみてください。後者は、ドメイン層に WordPress への依存が紛れ込んだときに気づける仕組みが働いている、という確認になります。

アダプター層はどうテストするか

アダプター層は WordPress の関数そのものを呼ぶので、次のどちらかで確認します。

  • 統合テスト:WordPress のテストスイートを読み込んだ環境(wp-env や wp scaffold plugin-tests で作る雛形など)で動かす。HTTP 通信は pre_http_request フィルターで偽のレスポンスを返せば、外部 API に接続せずに分岐を確かめられる
  • 関数のモック:Brain Monkey などのライブラリで WordPress 関数を差し替える。手軽な反面、モックの書き方が実装の細部に寄りやすく、コードの変更に弱くなりがち

アダプター層を薄く保っていれば、ここで確認すべきことは「エラー時に null を返すか」程度に絞られます。複雑な判断をアダプターに書き始めたら、それはドメイン層へ移すサインです。

つまずきやすいポイント

DI コンテナは最初から必要ではありません。 PHP-DI や League Container のようなコンテナは、クラス数が増えて組み立てコードが長くなってから検討すれば十分です。小さなプラグインでは、起点ファイルで new を並べるほうが読みやすく、依存関係も一目で追えます。

同梱ライブラリの衝突に注意します。 プラグインに Composer のライブラリを同梱すると、別のプラグインが同じライブラリの別バージョンを読み込んだときに衝突することがあります。配布するプラグインでは、PHP-Scoper や Strauss のような名前空間を書き換えるツールの利用を検討してください。

フックのコールバックはメソッドで登録します。 無名関数で登録したフックは、あとから remove_action() で外すのが困難です。[ $this, 'method' ] の形にしておけば、テーマや別プラグインからの調整もしやすくなります。

シングルトンに頼りすぎない。 Plugin::instance() のような静的なアクセスは、どこからでも呼べる便利さと引き換えに差し替えが難しくなります。どうしても必要な場合も、中身のサービスはコンストラクタで受け取る形にしておくとテストが書けます。

既存のプラグインを組み替える場合は、いきなり全体を書き直さず、テストしたいロジックを1つ選んでドメイン層に切り出すところから始めるのが安全です。作業前にバックアップを取り、ステージング環境で動作を確認してから本番に反映してください。PHPUnit や PHP のバージョン要件は更新で変わるため、導入時点の公式ドキュメントを確認してください(2026年10月現在)。

まとめ

  • テストしにくさの原因は、処理の中で get_option() や wp_remote_get() を直接呼び、依存先を自分で取りに行っていること
  • 依存先をインターフェースにして、コンストラクタで受け取る形にすると、テストで偽物を渡せる
  • WordPress に触るアダプター層は薄くし、ロジックはドメイン層に寄せる。ドメイン層は WordPress なしで PHPUnit を回せる
  • フック登録はコンストラクタではなく register() に分け、部品の組み立ては起点ファイル1か所にまとめる
  • DI コンテナは必要になってから。配布プラグインでは同梱ライブラリの衝突対策も検討する

お気軽にご相談ください

お見積りへ お問い合わせへ
▲