mcpField
D
drupal-testing

drupal-testing

github.com/grasmashテスト・品質82108 回インストール4 週間前に更新
リポジトリ

drupal-testing について

Drupal 開発者やコーディングエージェント向けのテストスキルです。価値のある回帰テストの選定、バグ修正時の TDD、テストコストの抑制を扱い、テスト基底クラスや ExistingSite の実践も説明します。

主な機能

  1. 01リスクに基づくテスト選定
  2. 02バグ修正で必須の Red-Green TDD
  3. 03ネガティブテストの指針
  4. 04軽量な Drupal テスト基底クラスの選択
  5. 05ExistingSite とテスト高速化の実践

活用シーン

  1. 01アサーションが現実的な回帰を防ぐか判断
  2. 02本番コード変更前に Drupal の不具合を再現
  3. 03サービス・レンダリング・HTTP の挙動に合うテストを選択

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/grasmash/drupal-claude-skills --skill drupal-testing

インストール後、Claude Code で/drupal-testingを入力して読み込まれたか確認します。

詳細

Drupal テスト(TDD)

Drupal の経験に裏打ちされたテスト規律です。test-writer エージェントおよび test-runner エージェントと組み合わせて使います。このスキルは「方法」、これらのエージェントは「担当者」です。

テストする価値があるもの(最初に読む)

テストを書く前に、必ずそのテストを追加する価値があるか判断してください。テストをコミットする価値があるのは、次の両方を満たす場合のみです。

  1. 将来の変更によって、もっともらしい形で、しかも気付かれずに壊れる可能性がある。(「誰かが理論上削除できる」ではなく、通常の編集によって気付かないうちに不具合が再発し得る、現実的なコードパスがあること。)
  2. **不具合が重大である。**ユーザー向けの動作、データの整合性、金銭、セキュリティ、または API/クライアント間の契約に影響すること。

どちらか一方でも満たさない場合、そのテストは保護効果のない純粋なコストなので、**追加しないでください。**現実的には起こり得ない不具合を防ぐために、永続的に保守しなければならないアサーションは、安全網ではなく保守負担です。

バグ修正では、引き続き TDD が必須です(次のセクションを参照)。ここでの基準は、新たに追加するカバレッジに適用されるものであり、バグ修正の規律に適用されるものではありません。

価値が高い — 必ずテストする
  • ビジネスロジック/アルゴリズム、データ変換、スコアリング、アトリビューション。
  • 金銭/サブスクリプション/利用権。
  • セキュリティと権限のゲート — ルートレベルで固定します(下記「匿名ユーザーに対する 403 権限の落とし穴」を参照)。
  • キャッシュの無効化と鮮度 — 実際の経路で変更し、表示される内容が更新されることを検証します。
  • 外部クライアントが依存する API/JSON レスポンスの形状(コントラクトテスト)。実体験から言うと、ある本番コードベースでは、デバイス向けレスポンスの契約からキーが削除されたことで、接続中のすべてのデバイスのデータが一度消失しました。レスポンス形状の契約は、永続的なテストに値します。
  • 繰り返し発生した履歴のある回帰。
価値が低い — コミットするアサーションを書かない
  • **設計修正による、一度限りの UI 要素の有無。**典型例は、モーダルから削除したボタンが存在しないことを検証するテストです。1 つのテンプレートから削除すればそれで完了です。現実的に再導入されるコードパスがないため、このアサーションは何も防ぎません。
  • 正確なコピー/ラベル文言 — その文言自体が契約である場合(例:法的文言)を除きます。
  • ビジュアルリグレッションテストですでにカバーされているピクセル位置/間隔。
  • Drupal コアまたはコントリビュートモジュールがすでにテストしているもの。
  • 実装の詳細を言い換えるだけのもの — テスト対象のコードをそのまま反映したテスト(コードを変更するとテストも同じように変わり、何も検出しません)。
「本当に再発するか?」の判定基準

不具合を気付かれないうちに再発させるもっともらしいコード変更を挙げられない場合、そのテストは、保守負担が発生する一方で保護効果はほぼゼロです。確認方法:そのアサーション行を削除したら、ほかに何か失敗しますか?「そのまま通る」なら、何もテストしていません。

見た目/コピー/レイアウトの修正 → 検証するのはアサーションではなくスクリーンショット

デザインの微調整を検証するには、コミットする DOM の存在確認テストではなく、ビジュアルチェック(例:Playwright によるスクリーンショット)を使います。TDD の「最初に失敗するテストを書く」は、見た目の修正ではなく動作の不具合に適用されます。デザイン修正で TDD を満たすために、expect(locator).toBeHidden() のような失敗するアサーションを無理に作らないでください。目で確認して、問題なければ次に進みます。

証明に必要な最小コストのテスト

テストのコストを、価値と回帰リスクに見合うものにしてください。次の順に優先します:**Unit > Kernel > renderInIsolation > HTTP smoke > Playwright.**レンダーテストが適切な場合や、テスト自体が不要な場合に、DOM 要素の存在を検証するためだけに、数秒かかる HTTP drupalGet や数分かかる Playwright の実行を選ばないでください。

バグ修正の TDD — RED のステップは必須です

すべてのバグ修正で、次の順序を厳守してください。ステップ 2 を省略すると、「修正済み」のバグが再発します。

  1. バグを再現するテストを書く — 疑わしいロジックを言い換えるのではなく、ユーザーが実際に通る本物のコードパスを実行します。
  2. テストを実行し、失敗することを確認します。報告された症状と一致している必要があります。 失敗しない場合は、バグを再現できていません。正しい理由で失敗するまで調査を続けてください。RED から GREEN への移行だけが、そのテストが実際にバグを実行していることを証明します。これがなければ、テストがバグを検出しているのか、単に GREEN の状態でたまたま通るのか判断できません。
  3. 本番コードを修正します。
  4. 同じテストを再実行し、今度は成功することを確認します。
ネガティブテストケース(バグの経路をテストする)

バグを修正する際は、失敗モードと成功モードの両方を示すテストを書いてください。

  1. テストがバグを検出することを証明します — 修正前にテストが成功する場合、適切な対象をテストできていません。
  2. 失敗モードを記録します — 今後の開発者が、なぜそのようにコードが書かれているのかを理解できます。
  3. リグレッションを防ぎます — 誰かがコードを壊れた方法に「簡略化」し直した場合、テストがその理由を説明します。
  4. 根本原因を検証します — 修正が症状だけでなく、実際の問題に対処していることを確認します。
public function testNestedVsFlatFormValues(): void {
  // NEGATIVE CASE: demonstrate how nested values FAIL.
  // The consumer's array_intersect_key expects flat keys like 'margin_top'.
  $nested_input = ['margin' => ['margin_top' => 'medium']];  // WRONG: nested
  $preserved_keys = ['margin_top', 'margin_bottom', 'padding_top'];
  $preserved_nested = array_intersect_key($nested_input, array_flip($preserved_keys));
  $this->assertEmpty($preserved_nested,
    'NEGATIVE CASE: nested values are NOT found — this is the bug.');

  // POSITIVE CASE: demonstrate how flat values WORK.
  $flat_input = ['margin_top' => 'medium'];  // CORRECT: flat
  $preserved_flat = array_intersect_key($flat_input, array_flip($preserved_keys));
  $this->assertEquals('medium', $preserved_flat['margin_top'],
    'POSITIVE CASE: flat values ARE correctly found — this is the fix.');
}

次の場合はネガティブテストを使用してください:バグ修正、セキュリティ修正、エッジケース(無効な入力の拒否)、統合の問題(互換性のない方法が失敗する)。「これは〜なので失敗すべき」という意図が明確になるように名前を付けてください。

適合する最も軽量なブートストラップを選ぶ — コストを大きく左右します

Drupal のテストベースクラスは、ブートストラップのコストが桁違いに異なります。アサーションを表現できる最も低コストのものをデフォルトとして選んでください。

ベースクラスブートストラップ用途
UnitTestCaseなし(純粋な PHP)Drupal サービスを使わない、分離されたロジック
KernelTestBase最小限。依存関係を protected static $modules で宣言分離されたサービス/ビジネスロジック
ExistingSite(DTT の ExistingSiteBase)稼働中のサイトに対して実行(実際の設定、コントリビュート、フィールドストレージ)インストール済みサイト全体を必要とするフロー
Functional/BrowserTestBaseテストクラスごとに完全な再インストール最終手段。非常に低速

ロジックがサイト全体を必要としない場合は、Unit と Kernel を優先してください。はるかに高速です。標準的な BrowserTestBase クラスでは、setUp() でサイト全体のインストール(プロファイル+設定のインポート)が発生し、テストクラスごとに通常 20~60 秒以上かかります。DTT の ExistingSiteBase では、そのコストがサイトのインストール/シード時に一度だけ発生し、その後の各テストは HTTP の往復だけです。デフォルトでは、BrowserTestBase より ExistingSiteBase を優先してください。

ExistingSite 内でのコストはブートストラップではなく、drupalLogin() + drupalGet()(実際の HTTP 通信で、各操作に数秒かかります)です。これらを追加する前に、アサーションが HTTP/認証/リダイレクトについてのものか、レンダリングされた出力についてのものかを確認してください。

  • サービスロジック → \Drupal::service(...)->method() を直接呼び出します。HTTP は不要です。
  • テンプレートのレンダリング → レンダー配列を構築し、\Drupal::service('renderer')->renderInIsolation($build) を呼び出します。HTTP は不要です。
  • エンティティのレンダリング → DTT の EntityCrawlerTrait::getRenderedEntityCrawler($entity, $view_mode) を使います。HTTP は不要です。
  • コントローラーの配線、認証、リダイレクト → drupalLogin + drupalGet を使います。機能ごとにスモークテストは 1 つに絞ってください。
高速なマトリクスのパターン(データプロバイダー)

データプロバイダーを使うとコストが増大します。11 行のマトリクス × drupalLogin + drupalGet では、約 5 分かかります。#[DataProvider] に N 行ある場合、テストメソッド内で HTTP を実行しないでください。高速なサービス/レンダリングのマトリクスと、単一の HTTP スモークテストに分けます。

class MyMatrixTest extends SomeTestBase {

  #[DataProvider('cases')]
  public function testServiceOutput(...): void {
    // Assert the service directly — no login, no HTTP.
    $result = \Drupal::service('my_module.calculator')->score($input);
    $this->assertSame($expected, $result);
  }

  #[DataProvider('cases')]
  public function testTemplateRender(...): void {
    $build = [
      '#theme' => 'my_component',
      // ... same variables the controller would pass
    ];
    $html = (string) \Drupal::service('renderer')->renderInIsolation($build);
    $this->assertStringContainsString($expected_marker, $html);
  }

  public function testControllerSmoke(): void {
    // One HTTP test to prove controller → template wiring works end-to-end.
    $this->drupalGet('/my-page');
    $this->assertSession()->statusCodeEquals(200);
  }
}
高速化のポイント
  1. サイト全体をインストールする必要のないロジックには、Unit/Kernel を使用します。
  2. テストが実際のリモートサービスへの接続でハングしないよう、設定フラグやデバッグフラグを使って setUp() 内の実際の外部呼び出しを停止します。
  3. sleep() は避けます。ほとんどの場合、アサーションの不足か、誤った基底クラスを使用している兆候です。
  4. 特にデータプロバイダーの各行で drupalLogin/drupalGet を呼び出すことは、最小限に抑えます。
  5. XDEBUG_MODE=off を指定して実行します。Xdebug を有効にしたままにするより、3~5 倍高速です。
  6. プロセスレベルでの高速化:プロジェクト固有の PHPUnit ブートストラップを用意し、コアの再帰的な *.info.yml スキャンを明示的な PSR-4 登録に置き換えます(drupal-ddev スキルの「PHPUnit Test Performance」を参照)。また、DDEV では、コンパイル済みオペコードをネイティブファイルシステムのキャッシュマウントに永続化する opcache のファイルキャッシュ ini を使用します(validate_timestamps は有効のままなので、編集したファイルは引き続き再コンパイルされます。追加後は ddev restart が必要です)。カスタムブートストラップを変更する場合は、変更前後で phpunit --list-tests の出力がバイト単位で完全に一致することを確認してください。

DTT の ExistingSite パターン

  • 一度だけシードし、共有フィクスチャを読み取ります。 これが中心的な高速化のポイントです。サイトへのシードは一度だけ行い(drush の php-script によるフィクスチャシーダーが効果的です)、テストごとにエンティティを作成するのではなく、その共有フィクスチャを読み取る ExistingSite テストを作成します。DTT のテストごとのエンティティ作成/クリーンアップ($this->createNode()、$this->createUser()。テスト後に自動削除されます)は、共有シードでは表現できない、本当にテスト固有のフィクスチャに限って使用します。
  • メタリフレッシュの再帰に上限を設けます。 DTT のベンダー既定値では maximumMetaRefreshCount は NULL(無制限)です。<meta http-equiv="Refresh"> のループを含むページ(たとえば、big_pipe の JavaScript 無効検出と、タグを出し続けるリダイレクト先が組み合わさった場合)があると、実行がハングします。プロジェクト共通の基底クラスで、小さな値(たとえば 3)に設定してください。
  • setUp() がスキップされた場合は tearDown() を保護します。 ExistingSite の基底クラスで DTT_BASE_URL が未設定のときに setUp() をスキップする場合(parent::setUp() より前に $this->markTestSkipped(...) を呼び出します)、tearDown() にもガードを追加してください。parent::tearDown() の前に if (!\Drupal::hasContainer()) { return; } を置きます。PHPUnit は setUp() 内でスキップされた場合も finally ブロックで tearDown() を呼び出します。また、DTT 自身の tearDown() は無条件に \Drupal::database() にアクセスするため、setupDrupal() が実行されていないと ContainerNotInitializedException で致命的エラーになります。このガードがないと、「Skipped: 9」が、気付かないうちに「Skipped: 9, Errors: 9」になります。
  • 匿名ユーザーの CSRF トークンは、リクエストをまたぐと検証に失敗する場合があります。 _csrf_token: TRUE のルートは現在のセッションからトークンを生成します。純粋な匿名リクエストではセッションを永続化しないサイト(匿名 GET で Set-Cookie を返さないサイトなど)では、ある匿名レスポンスに埋め込まれたトークンが、後続の匿名 POST で有効だとは限りません。テストでは、_csrf_token: TRUE のフローを実行する前に $this->drupalLogin($this->createUser()) を呼び出して解決します。このログインは、安定した永続セッションを確立する目的だけで行います。その後は、手作業で組み立てた URL ではなく、実際のフォームを使ってください(レンダリングされたページで $form->submit() を呼び出します)。
  • 鮮度テストでは、実際の HTTP リクエストを通じて変更を加えます。 phpunit プロセス内で $entity->save() を実行しても、HTTP 経由でウォームアップしたレスポンスのキャッシュが無効化されないことが確認されています。そのため、実際のリクエスト境界をまたげば製品は正しく動作するにもかかわらず、鮮度のアサーションに失敗します。原因となる仕組みは特定されていません(コアはルートトランザクションのコミット時に、プロセス内で遅延タグ無効化を実行するため、「コールバックが一度も実行されない」だけではありません。また、cachetags テーブルはデータベースのチェックサムバックエンドを使う場合にのみ存在します)。アプリケーションの実際の書き込み経路(たとえば、セッション Cookie と X-CSRF-Token を付けた HTTP 経由の JSON:API POST/PATCH)を通じて変更し、その後、キャッシュされた対象を再取得してください。

ExistingSite:ロールバックなし、データベース共有、長時間稼働するプロセス

ExistingSiteBase はテストをトランザクションで囲みません。また、スイート全体(または CI シャード)は、1 つの PHP プロセス内で、1 つの実データベースを使って実行されます。すべての副作用はテスト終了後も残ります。

フィクスチャの衛生管理 — 公開済みフィクスチャが実際の一覧に漏れ出します

公開済みノードを保存するテストは、ユーザーに表示されるコンテンツを実際のフィードや一覧に残します(フィードの先頭に表示される空または壊れた項目、実体のないカードなど)。こうしたものが後にバグ報告として届くことになります。

  • テスト対象のロジックで可能な場合は、フィクスチャを非公開(status => 0)で作成します。
  • それ以外の場合は、save() の直後に $this->markEntityForCleanup($entity) を呼び出します。メソッドの末尾で呼び出すと、途中で失敗した場合に実行されません。
  • テストやエージェントのスウォームの後、照合ルールを慎重に設定した、デフォルトでドライランとなるクリーンアップコマンドで残存データを一掃します(タイトルのパターン AND 最近の作成日時 AND バンドル)。
drupalLogin() には永続的な副作用があります

実際の drupalLogin() HTTP フローでは、すべてのログインサブスクライバーが実行され、その書き込みはロールバックされません。例(tfa 2.0.0-alpha5 の動作を参照):TFA が有効になっていて、TFA を設定していないアカウント(TfaLoginContext::isReady() が false)でも、TFA なしでログインできる場合、ログインが成功するたびにスキップが記録されます(hasSkipped() はユーザーデータに永続化される validation_skipped の件数を増やします)。許容回数を使い切ると、同じアカウントでログインする別の無関係なテストが失敗し始めます。

  • 別の ID が必要なプロセス内コードでは、current_user->setAccount() を直接使うのではなく、\Drupal::service('account_switcher')->switchTo($account)/switchBack() を(finally 内で)使用します。tfa はアカウントスイッチャーをデコレートし、バイパスフラグを設定するため、ユーザー設定サブスクライバーは切り替えを許可します。認証済みアカウントに対する直接の setAccount() ではバイパスされません。アカウントの TFA の状態によって、スキップが記録されるか、TfaAccessDenied がスローされます。
  • drupalLogin() する既存のアカウントを選ぶ際は、TFA が必須のロールに属し、TFA を設定していないアカウントを除外します。または、ベースクラスでログイン前後の validation_skipped を保存して復元します。
共有の設定シングルトンには書き込まないでください

setUp() でグローバルな設定オブジェクトに書き込み、tearDown() で元に戻すテストは、その間にプロセスが終了すると値を残したままにします(強制終了、タイムアウト、OOM)。honeypot の time_limit が残ったことで、honeypot がサイト全体で高速なフォーム送信を拒否するようになり、ログインフォームも含めて、後続のすべてのシャードで drupalLogin() が失敗しました(約700件の失敗)。テスト専用のコンテナ(DTT はメソッドごとに新しいコンテナを起動します)にスコープを限定した ConfigFactoryOverrideInterface を使用すれば、プロセスがどのように終了しても値は残りません。register_shutdown_function() で復元する方法では解決しません。シャットダウン中に遅れて実行されるため、破棄済みのリクエスト上で設定保存サブスクライバーが例外をスローする可能性があります。

メモリーの増加:プロセス存続期間の静的変数が破棄済みのカーネルを保持する

DTT はテストメソッドごとに新しい DrupalKernel を起動し、古いカーネルを破棄しません。プロセス存続期間の静的変数から参照されているものは、破棄済みのコンテナーとエンティティキャッシュのグラフ全体を到達可能な状態に保つため、テスト数に比例してメモリーが増加します。あるシャードでは約 150MB から約 7.8GB に増加して SIGKILL されました(終了コード 137)。これはメモリーのバグではなく、シャードが途中で切れているように見えます。主な参照元は、drupal_static()、Symfony の MimeTypes::$default(Drupal は起動のたびに、コンテナーをラップする推測器をここに再登録します)、そして drupal_register_shutdown_function() のリスト(起動ごとに追加され、実際のプロセス終了時にしか消費されません)です。これとは別に、「すべてのアクティブユーザーを読み込む」といったフィクスチャだけで約 1GB 増えることもあります。フィクスチャとしてテーブル全体を読み込まないでください。

  • 基底クラスの #[After] フック(tearDown() チェーン全体の実行後に呼び出されます)で解放します。drupal_static_reset()、MIME 推測器のデフォルトのリセット、シャットダウン関数リストのクリアを行い、その後に gc_collect_cycles() と gc_mem_caches() を実行します。
  • 環境変数で有効化できる CSV にテストごとのメモリー使用量を記録し、再発時に増加を再測定できるようにします。
  • このフックに \Drupal::unsetContainer() を追加しないでください。 Database::startLog() を呼び出すテスト(クエリ予算のテストなど)は、プロセス静的な DB 接続でステートメントイベントを有効にしたままにします。DTT が次の setupDrupal() を実行すると、\Drupal::setContainer() より前に DrupalKernel::boot() 内で cache_container クエリが実行されるため、コンテナーが解除されていると、そのクエリは例外(「イベントディスパッチャーサービスが利用できません」)をスローし、その後のすべてのテストが起動時に失敗します。startLog() を使うテストのない小規模な範囲で試すと、テストが成功するように「証明」されてしまいます。コンテナーを解放する必要がある場合は、先に DB 接続を閉じ、オプトインにしてください。

匿名ユーザーへの 403 テストに潜む権限の落とし穴

「匿名ユーザーに 403 を返す」テストでは、ルートの _permission が適用されていることは証明できません。このテストが誤った結果を出すケースは 2 つあります。

  1. _user_is_logged_in: TRUE と _permission の両方で制限されたルートでは、匿名ユーザーは先にログイン条件で拒否されます。そのため、_permission を完全に削除しても匿名ユーザーのテストは成功します。
  2. 制限する権限が authenticated ロールに設定されていると、ログイン済みユーザーが拒否されることはありません。そのため、制限は事実上無効です。(これは机上の話ではありません。本番環境のコードベースで API 全体に影響し、「ログイン済みだが権限のないユーザー → 403」というテストが文字どおり書けなかった事例があります。)

_permission の構文:+ は OR、, は AND です。

制限を実際にテストするには、次のいずれかを行います。

  • 権限を実際に持たないユーザーでログインし、403 をアサートします。または
  • ルート定義レベルで制限を固定します。$route->getRequirement('_permission') が期待する文字列と一致することをアサートし、制限を緩めるとテストが失敗するようにします。
public function testRoutePermissionGate(): void {
  $route = \Drupal::service('router.route_provider')
    ->getRouteByName('my_module.my_route');

  $this->assertSame('expected permission+administer something', $route->getRequirement('_permission'));
  $this->assertSame('TRUE', $route->getRequirement('_user_is_logged_in'));
}

一連のルート全体を対象にする場合は、明示的な期待権限マップと集合の一致チェックを用いてルートをループ処理し、新しいルートが制限なしで追加されないようにします。

ガードを TDD で確認します。権限を削除 → RED になることを確認 → 元に戻す。失敗するところを一度も確認していないガードは、何の証明にもなりません。

形式的にパスするテスト(サイレントゼロの罠)

テストクラス全体が突然「パス」するのに、実行されたアサーションが 0 件の場合は、コードに問題がないのではなく、PHPUnit のバージョン不一致やテスト収集時の致命的エラーを疑ってください。

  • Drupal コアがサポートする PHPUnit のメジャーバージョンは決まっています。誤ったバージョンを固定すると(例:PHPUnit 11 のみをサポートするコアで PHPUnit 12 を使用)、Drupal の基底クラス(UnitTestCase/KernelTestBase)を継承するすべてのテストで テストが 0 件収集され、終了コード 0 で終了します。互換性のための仕組みはないため、収集時の致命的エラーが表面化しません。一方、プレーンな \PHPUnit\Framework\TestCase と DTT の ExistingSite テストは引き続き実行され、問題を覆い隠します。
  • setUp()/tearDown() の可視性は、親クラスと一致させる必要があります。 可視性を誤って宣言すると、ロード時に致命的エラーが発生します。これは php -l では検出されず、PHPUnit に黙って握りつぶされることがあり、テスト 0 件、終了コード 0 となります。
  • PHPUnit 10 以降では @group の DocBlock アノテーションではなく、PHP 8 の属性(#[Group('x')])を使用します。--exclude-group/--group は古いアノテーションに一致しないため、属性に移行してください。
  • 簡易チェックとして、テストがパスする実行では、ある程度の数のアサーションが報告されるはずです。テストがあると分かっているスイートで OK (0 tests, 0 assertions) と表示される場合、ランナーがテストを収集できていません。

便利なスモークテストのパターン

// Service exists and exposes the expected method.
$service = \Drupal::service('my_module.some_service');
$this->assertNotNull($service);
$this->assertTrue(method_exists($service, 'methodName'));

// Route exists.
$route = \Drupal::service('router.route_provider')
  ->getRouteByName('my_module.my_page');
$this->assertNotNull($route);

// Plugin registered + instantiable.
$definitions = \Drupal::service('plugin.manager.my_type')->getDefinitions();
$this->assertArrayHasKey('plugin_id', $definitions);

// Idempotency / duplicate handling: same identifier twice returns the
// same entity, not a duplicate.
$a = $service->createEntity($key);
$b = $service->createEntity($key);
$this->assertEquals($a->id(), $b->id());

設定の検証テスト

設定ベースのコンテンツ(設定、エクスポートされたテンプレート)は、実際の機能を実行しなくても、YAML のレベルで回帰テストできます。

public function testConfigPattern() {
  $config_factory = \Drupal::configFactory();
  $issues = [];
  foreach ($config_factory->listAll('my_prefix.') as $config_name) {
    $value = $config_factory->get($config_name)->get('some.key');
    if (strpos((string) $value, 'bad_pattern') !== FALSE) {
      $issues[] = "$config_name: contains bad_pattern";
    }
  }
  $this->assertEmpty($issues, "Config issues:\n" . implode("\n", $issues));
}

listAll() を使って一致する設定をすべてスキャンし、新しい設定オブジェクトにアンチパターンが含まれたままリリースされないようにします。

ビジュアルテスト(Playwright)

必須: CSS、テーマ、UI の変更。不要: 表示上の変更がないバックエンドのみの作業(免除する場合は、その旨を明記してください)。

うまく機能するプロジェクト内の規約として、デスクトップ(約 1280px)とモバイル(約 390px)のビューポート、ライトテーマとダークテーマ(初回描画の前に addInitScript でテーマクラスを強制)、状態ごとのスクリーンショット、そしてソースのパスではなくビルド済み(ハッシュ付き)アセットが添付されていることの検証があります。

ビジュアルテストのチェックリスト
  • テスト前にキャッシュをクリアする(キャッシュが無効な場合は省略)
  • モバイル(約 390px)幅とデスクトップ(約 1280px)幅でテストする
  • 該当する場合は、ログインユーザーと匿名ユーザーの両方でテストする
  • ライトテーマとダークテーマでテキストのコントラストを確認する
  • レスポンシブ動作を確認する(ドロワー/ハンバーガーメニューの状態)

実際のコードパスをローカルで検証する — ユニットテストがパスするだけでは不十分です

実行時の動作(cron ジョブ、drush コマンド、データ処理、API エンドポイント、サービスロジック)を変更した場合は、完了と宣言する前に、変更したコードパスをローカルで実行し、実際の結果を確認してください。ユニットテストがパスしただけで終わらせないでください。実際のコマンド/サービスを実行し(例:DDEV 経由で ddev drush <command>)、その結果の状態(DB の行、更新されたフィールド値、出力された内容)を確認します。これにより、テストでは見つからない環境の違い、データ依存のバグ、実際にインストールされたサイトでの統合エラーを検出できます。ユニットテスト/Kernel テストで証明できるのはロジックの独立した動作です。連携が正しく設定されていると証明できるのは、実際に実行した場合だけです。

CI:再プッシュせずに、失敗したテストをローカルで修正する

CI でテストエラーが発生した場合、コミットをプッシュしてフルスイートを再実行する方法を繰り返さないでください(1 回につき約 20 分かかることもあります)。

  1. CI のログから失敗したテストを特定します。
  2. ローカルで再現します(vendor/bin/phpunit --filter Class::method path/to/Test.php)。
  3. 各テストを個別に実行し、パスするまで修正します。
  4. コミットします。
  5. その後で初めて、CI のフルスイートを再実行します。

注意点(実体験から得た重要事項)

DDEV での実行
  • phpunit は必ず ddev exec 内で実行してください(DB 接続が必要です)。XDEBUG_MODE=off を設定し、出力のコンテキストを保つために絞り込みます。
    ddev exec "XDEBUG_MODE=off phpunit path/to/module/tests/ --testdox 2>&1" | grep -E '(✔|✘|OK|FAIL|Tests:|Assertions:)'
    
  • 長いコマンドを実行すると、ddev exec は標準出力を飲み込みます。 出力をファイルにリダイレクトし、同じ呼び出し内で末尾を表示してください。例:ddev exec "phpunit ... > /tmp/out.txt 2>&1; tail -n 40 /tmp/out.txt"。
  • Mutagen のマウントは非同期です(有効な場合)。ホスト上でファイルを編集した後、phpunit を実行する前に ddev mutagen sync を実行してください。そうしないと、古い内容が読み込まれます。
  • set -u とホスト変数/$(...) の展開を組み合わせると、インラインの ddev exec bash が壊れます。 スクリプトファイルを作成し、ddev exec bash /path/script.sh で実行してください。
drupalLogin() の不安定な動作(セッション依存のキャッシュコンテキスト)
  • モジュールのキャッシュコンテキストが、テストセッションバッグに含まれないセッションメタデータに依存している場合(masquerade モジュールで確認されています)、ログイン後のページレンダリング確認で drupalLogin() が断続的に失敗することがあります(ログイン自体は成功しますが、ログイン済みとしてページがレンダリングされたというアサーションが失敗します)。キャッシュ状態に左右される断続的な問題であり、テスト対象とは無関係です。
  • 修正方法: テストで確認するのがコントローラー/サービスの OUTPUT(HTTP 認証レイヤーではない)なら、drupalLogin は使わないでください。AccountSwitcher の下でコントローラーを直接呼び出します。\Drupal::service('account_switcher')->switchTo($user) → メソッドを呼び出す → finally で switchBack() を実行します。アクセス制御はルート定義レベルで個別に確認してください(「匿名ユーザーへの 403 権限の落とし穴」を参照)。
大量の drupalLogin() 失敗はコードではなく環境の問題

互いに共有するものがないモジュール間で数百件のテストがログイン時に失敗する場合は、コードをデバッグする前に環境を確認してください。

  • ベース URL/ホストの不一致。 SIMPLETEST_BASE_URL/DTT_BASE_URL が、実際にサイトを提供しているホストとは異なるホストを指定している場合、セッション Cookie 名が異なり、すべてのログインが同じ Failed asserting that false is true エラーで失敗します。環境で実際に使用しているプライマリ URL を両方に設定してください。
  • プライベートキーの不足(認証モジュールが必要とする private://keys/ 配下のキー ファイルなど)— 数百件のテストが「実行されませんでした」となります。
  • 共有設定シングルトンの漏出(上記を参照)。
モック内のアロー関数は値をキャプチャする

fn() => $cursor は定義時点の $cursor を固定し、その後の変更を反映しません。モックしたイテレーター(valid()/current()/next())では、このため valid() が永久に TRUE を返す無限ループが発生したり、expects($this->once()) が本番コードの二重呼び出しのバグに見える形で失敗したりします。通常のクロージャーを使用してください:function () use (&$cursor) { ... }。

テスト失敗のデバッグ
# Recent errors during a test run
ddev drush watchdog:show --severity=3 --count=10

# Service not found? Check registration:
ddev drush ev "print_r(\Drupal::getContainer()->getServiceIds());"

# Route not found? Rebuild the router:
ddev drush cr

テストファイル内の phpcs

  • ドックブロックの前にセクション区切りコメント(// ---)を置くと、CommentEmptyLine.SpacingAfter と FunctionSpacing.Before の両方に違反します。テストファイルでは使用しないでください。
  • 再コミットする前に vendor/bin/phpcbf <file> を実行して自動修正してください。
  • phpcs のゲートは出力の grep ではなく、終了コードで判定してください。 phpcs の出力には色が付くことがあり、ANSI カラー付きテキストに対して grep -c '| ERROR' を実行すると、実際の結果にかかわらず 0 件とカウントされ、ゲートが常に通過してしまいます。警告でゲートを失敗させない場合は、ignore_warnings_on_exit=1 を設定し、それでも終了コードを確認してください(または --report=json を解析し、パーサーがない場合は失敗として処理してください)。

コメント