私はしばし、ci-phpunit-testを使ったテストの書き方で、CodeIgniter3自体の挙動を説明しなければならないことがあります。
私は、その説明が非常にめんどくさいと感じているのでここに記載します。

知っておいて欲しい実装は次の4つです。

  • Controllerの実装(設計)
  • Controllerの実装(コンストラクタ) <- イマココ
  • 依存性解決の実装
  • Modelの実装

Controllerの実装(コンストラクタ)

コンストラクタの中では次のことを行います。

  • 自身を静的変数に定義
  • load_class関数を用いてロードしたクラスファイルをプロパティとして定義
  • Loaderクラスをインスタンス化してプロパティとして定義
  • Loaderクラスの初期化処理を実行

自身を静的変数に定義

Controllerでは、最初に自身を静的変数に代入します。
つまりは、new CI_Controllerを実行したインスタンスがget_instance関数で取得できる様になります。

1
2
3
4
5
6
7
8
$first = new CI_Controller();

actual($first === get_instnace()); // true

$second = new CI_Controller();

actual($first === get_instnace()); // false
actual($second === get_instnace()); // true

この様に、最後にインスタンス化したControllerがget_instanceで取得できる様になります。

load_class関数を用いてロードしたクラスファイルをプロパティとして定義

このload_class関数については後述しますが、ここでは指定されたクラスをインスタンス化するnewのラップ関数と理解してください。

1
load_class('Input', 'core') == new CI_Input

CodeIgniterはControllerをインスタンス化するまでに様々なクラスをインスタンス化します。
その様々なクラスをインスタンス化する際は、load_class関数を用いてインスタンス化されます。

load_class関数を用いてインスタンス化されたオブジェクトをControllerのプロパティに代入します。
このような処理が、Constrollerのコンストラクタで行われています。

何もロードせずとも$this->inputなどが利用できるのはこのためです。

Loaderクラスをインスタンス化してプロパティとして定義

Controllerをインスタンス化するときにLoaderクラスをインスタンス化し、プロパティとして定義しています。
Controllerを実装するときにparent::__constructより前で$this->loadが利用できないのはこのためです。

また、非常に重要なポイントとしてLoaderクラスをインスタンス化するときはCommon.phpに定義されているload_class関数を利用しています。

Loaderクラスの初期化処理を実行

ここで、少しControllerと離れてしまいますが、Loaderクラスには初期化処理があります。
この初期化処理では、config/autoload.phpに記載されたModelやLibrary、Helperなどをロードします。
このautoloadに記載されたファイルをロードする際はLoaderに実装されているmodelメソッドやlibraryメソッドなどが利用されます。

ここまでのまとめ

  • load_class関数を用いてインスタンス化されたオブジェクトをControllerのプロパティに代入する。
  • parent::__constructより前で$this->loadは利用できない。
  • 最後にインスタンス化したControllerがget_instanceで取得できる様になる。

テスト時に知っておきたいこと

load_class関数を用いてインスタンス化されたオブジェクトをControllerのプロパティに代入する

ci-phpunit-testにはload_class_instance関数があります。
これは、load_classを用いてインスタンス化したことにする関数です。

つまり、load_class_instanceを用いてMockオブジェクトを挿入しておけば
Controllerのコンストラクタ内の処理で、Controllerのプロパティにmockオブジェクトが挿入されます。

1
2
// コンストラクタが動くときに、ControllerのsampleプロパティへSample_dummyクラスが注入される。
load_class_instance('sample', new Sample_dummy);

parent::__constructより前で$this->loadは利用できない

ci-phpunit-testにはrequest->setCallablePreConstructorメソッドがあります。
これは、Controllerのインスタンス化前に処理を挟むことのできるメソッドです。

多くの場合load_class_instance関数と併用して利用されます。
また、インスタンス化前なので引数として与える関数では、テスト対象のコントローラへアクセスすることはできません。
この点がsetCallableとは異なります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?php

class Todo_test extends TestCase
{
public function test_should_show_validation_error()
{
$mock = $this->getDouble(CI_Form_validation::class, [
'run' => false
]);

// 引数に$CIはない。
// なぜならば、Controllerのコンストラクタが動く前のフックだからだ。
$this->request->setCallablePreConstructor(function() use($mock) {
// form_validationがTodoコントローラがインスタンス化するときにプロパティに定義される。
load_class_instance('form_validation' $mock);
});

$output = $this->request('POST', 'todo/add', [
'title' => 'subject',
'body' => 'body'
]);

$this->assertContains('タイトルは必須です', $output);
}
}

また似たrequest->addCallablePreConstructorもありますが、このメソッドはsetCallablePreConstructorでセットした関数を上書きしないで追加するメソッドです。
詳細は割愛します。

最後にインスタンス化したControllerがget_instanceで取得できる様になる

次の様なコードのテストを書く際は、どのControllerインスタンスが利用されているか意識すると良いです。

ライブラリ

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php

class Todo_validator {

private $ci;

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

public function is_valid(array $value): bool
{
$this->ci->form_validation->set_data($value);
$this->ci->form_validation->set_rules('title', 'タイトル', 'trim|required|maxlength[100]');
$this->ci->form_validation->set_rules('body', '本文', 'trim|required|maxlength[255]');

return $this->ci->form_validation->run();
}
}

テストコード

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<?php

class Todo_test extends TestCase
{
public function test_controller_instance_not_equal()
{
$this->request->setCallablePreConstructor(function() {
// Todo_validatorがインスタンス化される時に呼び出されるget_instanceでは、
// テスト対象のコントローラインスタンス化前のフックなので
// Todo_test::classの$this->CIが取得される。
load_class_instance('todo_validator' new Todo_validator);
});

$this->request->setCallable(function($CI) {
// load_class_instanceで入れたTodo_validatorの$ciプロパティと
// setCallableの引数の$CIは違うインスタンスなのでこのMockオブジェクトは参照されない。
$this->form_validation = $this->getDouble(CI_Form_validation::class, [
'run' => true
]);
});
$output = $this->request('POST', 'todo/add', [
'title' => str_repeat('s', 1000),
'body' => 'body'
]);

// つまりこのテストは失敗する。
$this->assertRedirect('todo/complete');
}
}