メインコンテンツに移動

Drupal9 最小の力で用意するテストコード戦略

2021-12-06

はじめに

Drupal Advent Calendar 2021 の6日目の記事です。

今回はDrupalのテストコードをいかに楽に用意するか、面倒くさいところを飛ばすかということを語ります。

Drupal開発でモジュールは書くようになったけどテストコードは作ったことがない人、
そろそろCI/CD のための自動テストも用意してみたいよね、と思い始めた人向けの内容です。
そしてテストコードで挫折経験ありの人もぜひ。戦友です。

DrupalのテストコードはPHPUnitベースで、
そもそも実行方法が分からないという方は次の記事で具体例を載せますので、そちらを参照してください。

Drupalのテストコードはつらい気がする

いきなりですが、Drupalのテストコードはコツが必要だと感じています。

Drupalのテストコードのノウハウは PHPUnit in Drupalに網羅されていますが、
自分が用意したいテストコードを作成すると、アサーションが到達するまでにエラー多発で挫折しやすいです。

それはなぜか。主な理由は2点です。

1. DIの嵐

DrupalはSymfonyベースなので、ソースコードを動かすにはとにかくDI(Dependency injection)されていることが必要です。
Aというクラスのメソッドのテストしたいと思った時に、Bクラスを注入し、、
BクラスのオブジェクトにはCクラスを注入し、Cクラスには・・・と1つのクラスをテストするために必要な依存がどんどん増えていきます。

ブラウザのリクエストやdrushからDrupalを実行する場合はよいようにDIされたクラス群が立ち上がりアプリケーションが動きますが、
純粋にPHPUnitから実行する場合、自分でDIしたクラス群を意識して用意する必要があります。辛い。

2. Config(構成) の存在

Drupalというアプリケーションを構成する重要な要素にConfig(構成)があります。

DrupalはPHPコードだけでは巨大なフレームワークに過ぎません。
管理画面からコンテンツタイプやタクソノミーを定義する = Configを定義し、
Config定義を元に記事コンテンツやタグタクソノミーなどを作成することでアプリケーションとして動作することができます。
Configとコンテンツはデータベースに保存され、Drupalの構成を簡略図にすると以下のようになります。

Drupalの構成図

純粋にPHPUnitを動かすとデータベースにあるConfigとコンテンツは0なので、
セットアップでConfigとコンテンツを再現した上でテストを実行する必要があります。

例えば、よくある記事コンテンツをテストしたいなと思ったら、
アサーションの前に以下のようなテストコードを書く必要があります。

Configのセットアップ

  • コンテンツタイプ:記事を登録
  • タグの参照先のボキャブラリー:タグを登録
  • 本文とタグのフィールドストレージ、フィールドを追加

コンテンツのセットアップ

  • タグタクソノミーを登録
  • 記事コンテンツを登録

コードだとこんな感じです。Gistはこちら

<?php

namespace Drupal\Tests\example_module\Kernel;

use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\KernelTests\KernelTestBase;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\taxonomy\Entity\Term;
use Drupal\taxonomy\Entity\Vocabulary;
use Drupal\Tests\ConfigTestTrait;
use Drupal\Tests\node\Traits\NodeCreationTrait;
use Drupal\Tests\user\Traits\UserCreationTrait;

/**
 * Provides example test.
 */
class ExampleTaihennaTest extends KernelTestBase {

  use ConfigTestTrait;
  use NodeCreationTrait;
  use UserCreationTrait;

  /**
   * Modules to enable.
   *
   * @var array
   */
  protected static $modules = [
    'system',
    'node',
    'taxonomy',
    'user',
    'text',
    'field',
    'filter',
  ];

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();
    $this->installEntitySchema('node');
    $this->installEntitySchema('taxonomy_term');
    $this->installConfig('filter');
  }

  /**
   * Tests example.
   */
  public function testExample() {
    // タクソノミー作成.
    $vocabulary = Vocabulary::create(['vid' => 'tags']);
    $vocabulary->save();

    // Node type作成.
    $content_type = NodeType::create([
      'type' => 'article',
      'name' => 'Article test',
    ]);
    $content_type->save();
    // bodyフィールド追加.
    FieldStorageConfig::create([
      'field_name' => 'field_body',
      'entity_type' => 'node',
      'type' => 'text_with_summary',
    ])->save();

    $field_body = FieldConfig::create([
      'entity_type' => 'node',
      'field_name' => 'field_body',
      'bundle' => 'article',
    ]);
    $field_body->save();

    // tagsフィールド追加.
    FieldStorageConfig::create([
      'field_name' => 'field_tags',
      'entity_type' => 'node',
      'type' => 'entity_reference',
      'settings' => [
        'target_type' => 'taxonomy_term',
      ],
    ])->save();

    $field_tags = FieldConfig::create([
      'entity_type' => 'node',
      'field_name' => 'field_tags',
      'bundle' => 'article',
    ]);
    $field_tags->save();

    // ユーザの作成.
    $user = $this->setUpCurrentUser();
    // タグのターム作成.
    $term = Term::create(['vid' => 'tags', 'name' => 'タグテスト']);
    $term->save();
    $node = Node::create([
      'type' => 'article',
      'field_tags' => $term->id(),
      'title' => 'test',
      'uid' => $user->id(),
    ]);
    $node->save();
    $result_terms = $node->get('field_tags')->referencedEntities();
    // テストはここだけ!
    $this->assertEquals('タグテスト', reset($result_terms)->label());
  }

}

この準備を乗り越えてやっとアサーションが実行されます。辛い。

普段コンテンツタイプやフィールドなどデータ定義を管理画面でできるところがDrupalの良いところだと思うですが、テストだとそのうまみが享受できないのもモヤっとします。

楽していい感じのテストコードを手に入れる

ここから本題です。2つの問題点を真っ向から立ち向かうことなくテストコードを用意する方針です。

DIの嵐が終わったところからテストコードを書く、他の嵐はよける


結論、Kernelテストだけ書きます。これだけだとまったく意味不明だと思うので、解説します。

Drupalコアが用意してくれているPHPUnit用のベースクラスには種類があります。
 

Types of tests

それぞれベースクラスで用意してくれるスタート状態、アサーションの対象、それぞれの感想を書くとこんな感じです。

  • Unit
    •    超高速、DIはほとんどモックが注入された状態でスタートできる。
    •    アサーションは関数、メソッドの戻り値に対して行う。
    •    モックでDIできるし良いかと思って作り始めると、ただのモック地獄に陥ります。途中で呼出されるすべての関数を全部モックで用意しても良いぜって人にはこれでも大丈夫。
  • Kernel
    •    コア + 指定のモジュールのサービスをDI完了したスタートできる。
    •    アサーションは関数、メソッドの戻り値に対して行う。
    •    $this->container->get('entity_type.manager')みたいな感じでServiceが呼び出し可能。とてもUnitテストっぽいことが素直にかける。
  • Functional
    •    テストDBにDrupalインストールが始まって、完了した状態からスタートできる。
    •    アサーションはURLからhtml出力結果に対して行う。
    •    インストールから始まるので遅い。アサーションがhtmlベースになり、またテーマ依存が入ってきてTwigが修正されるとテストが通らない、などの別の嵐が待っている。
  • *FunctionalJavascript
    •    スタート状態はFunctionalと一緒。chomeなどのWebドライバを利用したアサーションが実行できる。
    •    アサーションはURLからhtml出力結果をWebドライバ通して行う。
    •    Webドライバを用意する必要があっていきなり辛い。Webドライバを使うテストなら他のテストツールでも良い気がする(と勝手に思う)

以上により、テストを用意すると思い立ったらKernelテストの作り方だけ学習してサクサク作ってしまうのが良いように思います。

ただKernelテストだけで用意する場合、諦めたり工夫が必要なこともあります。

表示機能に近い部分は諦める

hook_form_alterFormBase継承したフォームなどはKernelテストで用意出来ますが、コスパが悪いです。

hook_form_alterそのものを呼出たり、
\Drupal::formBuilder()->getForm('Drupal\your_module\Form\YourForm'); とかで、
結果を取れたりしますが、Render array を確認するくらいなので、なんだか同じコードを2倍書いただけではという感覚に陥ります。

フォームの中で複雑な判定処理が多いのであれば、その部分だけサービスに逃した上でサービスのテストを用意すると良い感じです。


いろんなサービスから呼び出したり結果を取り出してテストする

直接テスト出来ないっぽく見える機能も工夫して呼び出すことで、テスト可能だったりします。

  • hookの処理をテストしたい時
    •    例 hook_entity_presave()hook_entity_insert() などは、$entity->save(); とかすれば通過するhookなので、テストできます。
  • * EventSubscriber の テスト
    •    dispatchを実行してしまう。$this->container->get('event_dispatcher')->dispatch(new Event(), 'test_event'); のように呼出すればテストできます。
    •    EventSubscriberに関しては、実は個別のDIがなければUnitテストでもいけます。
  • Block pluginなどのプラグインにあるメソッドをテストしたい時
    •    Plugin系もPlugin Manager経由で生成すれば、メソッドを呼べるのでテストできます。
    •    $this->container->get('plugin.manager.block')->createInstance('test_block', $configuration); みたいな感じ。

Config(構成) は登録の負荷を下げる

Drupalでテストする以上configの登録は避けられませんでした。逃げられないので楽に登録していく方法を考えました。

Config(構成)はTraitで楽をする

Configの登録は避けて通れないのですが、Drupalコアの中に楽できる Trait がいくつか存在します。
有効活用するとテストコードがぐんと減って楽ちんです。使い方はコアの各種モジュールのテストが参考になります。

  • ContentTypeCreationTrait
  • TaxonomyTestTrait
  • UserCreationTrait
  • NodeCreationTrait

ただ残念なことにフィールド系のTraitがなく、いつも自前でTrait用意したりしなかったりしています。


Config(構成)はyamlからインポートしてしまう

多くのアプリケーションではConfigをdrushなどでエクスポートしたファイルをgitで管理されていたりすると思います。
ファイルになったconfigをそのままテストのセットアップでインポートして使ってしまうこともできます。

こちらは解説コメント付きのサンプルコードをご覧ください。GIstはこちら

<?php

namespace Drupal\Tests\example_module\Kernel;

use Drupal\Core\Config\FileStorage;
use Drupal\KernelTests\KernelTestBase;
use Drupal\node\Entity\Node;
use Drupal\taxonomy\Entity\Term;
use Drupal\Tests\ConfigTestTrait;
use Drupal\Tests\node\Traits\NodeCreationTrait;
use Drupal\Tests\user\Traits\UserCreationTrait;

/**
 * Provides example test.
 */
class ExampleTest extends KernelTestBase {

  use ConfigTestTrait;
  use NodeCreationTrait;
  use UserCreationTrait;

  /**
   * Modules to enable.
   *
   * @var array
   */
  protected static $modules = [
    'system',
    'node',
    'taxonomy',
    'user',
    'text',
    'field',
    'filter',  // 本文にテキストフォーマットが必要なので指定しています.
    'menu_ui', // コンテンツからメニューを登録する機能を有効にしているコンテンツなので、dependencies があり指定しています.
  ];

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();
    // Kernelテストでは、$modulesに指定しても config/install や config/schema は投入されないため、
    // setUpで投入します.
    // filterはbodyフィールドのテキストフォーマット用で、Plain Textを投入します.
    $this->installEntitySchema('node');
    $this->installEntitySchema('taxonomy_term');
    $this->installConfig('filter');

    // configのインポートには system.siteのuuidの一致が必要なので、先に更新します.
    $site_uuid = '0937caa0-2ea0-4494-8022-55f210a49c75';
    $this->container->get('config.factory')->getEditable('system.site')
      ->set('uuid', $site_uuid)
      ->save();

    // インポート準備.
    $this->configImporter();
    $active = $this->container->get('config.storage');
    $sync = $this->container->get('config.storage.sync');

    // ConfigTestTraitのメソッド.
    // DBのconfig = $active を $sync にコピー.
    $this->copyConfig($active, $sync);

    // configのyamlファイルがあるディレクトリを絶対パスで指定します.
    $config_path = DRUPAL_ROOT . '/../sync';
    $storage = new FileStorage($config_path);
    $config_list = [
      'node.type.article', // 記事コンテンツのconfig.
      'field.storage.node.body', // 記事コンテンツの本文.
      'field.storage.node.field_tags', // 記事コンテンツのタグ(タクソノミーターム参照).
      'field.field.node.article.body', // 記事コンテンツの本文のストレージ設定.
      'field.field.node.article.field_tags', // 記事コンテンツのタグのストレージ設定.
      'taxonomy.vocabulary.tags', // タグ タクソノミータームの設定.
    ];
    // $sync に yaml を読み込みながら書き込みします.
    // drush config:import 実行時の差分表示は、
    // 書き込み後の$sync と $active を比較して表示されています.
    foreach ($storage->listAll() as $config) {
      /** @var \Drupal\Core\Config\StorageInterface $sync */
      if (in_array($config, $config_list)) {
        $sync->write($config, $storage->read($config));
      }
    }
    // ここで$syncの内容がDBに入ります.
    // drush config:import で yes した時に走る処理だと思ってください.
    $this->configImporter->import();
  }

  /**
   * Tests example.
   */
  public function testExample() {
    // UserCreationTraitのメソッド、createNode()がユーザを要求するので呼んでいます.
    $this->setUpCurrentUser();

    // タグのターム作成.
    $term = Term::create(['vid' => 'tags', 'name' => 'タグテスト']);
    $term->save();
    // NodeCreationTrait のメソッド.
    $node = $this->createNode(['type' => 'article', 'field_tags' => $term->id()]);
    $this->assertTrue($node instanceof Node);
    $result_terms = $node->get('field_tags')->referencedEntities();
    $this->assertEquals('タグテスト', reset($result_terms)->label());
  }

}

 

正直記事コンテンツの再現くらいではコード量はあまり変わらなくて残念なのですが、
フィールド数が増えたりエンティティ参照先が増えるとyamlファイルのインポートの方が圧倒的に楽でした。

実はこの記事を書こうと思った時は、前段のTraitを紹介をメインにしようと思っていました。
思いつきで試したconfigインポートも結構簡単にテストが実行できることが分かったので絶賛こちらを推します。
管理画面の変更点も定義が増減する時だけyamlの指定を変えるだけなのでめっちゃ楽です。

実際の仕事でも何本かこの方法でテストコードを書いています。

おしまいに

個人的にはカスタムモジュールでもhookをちょっと記述するくらいならばテストコードまで書かなくてもいいかな、と思います。
hookで100行書いてしまったという時やPluginで独自メソッド作った時とかモジュールの中に services.yml を定義し始めたら、テストコードの出番のように思います。
(hookで100行〜はたぶん書きすぎ、外に切り出すタイミングという意味で)

Drupalのテストコードを書くとDrupalの内部構造に詳しくなれる効用もついてきます。
ぜひそういう仲間が増えたら良いなーと思っています。そしてぜひ私に知識を分けてください(笑) Issueのパッチをシュパッと出せるようになりたい。

それでは良きDrupalテストコードライフを。