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

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

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

依存性解決の実装

前回までの内容で、CodeIgniterではControllerインスタンスを共通化しやすい構造になっていると記載しましたが、
CodeIgniterではController以外にもLibraryやModel等のインスタンス(依存性)も共通化しやすい構造になっています。

その構造に大きく関係するのがload_class関数とLoaderクラスです。

load_class関数の実装

load_class関数はLibraryやsystem/coreディレクトリのクラスを読み込んでそのインスタンスを返します。
このとき、読み込んだクラスのインスタンスは共通化しやすい様に関数内部に保持されます。

つまり、次のようになります。

1
2
3
4
5
6
7
8
<?php

// 最初の読み込みでロードされて、以降のload_classでは同じインスタンスが返却される。
$first_config =& load_class('Config', 'core');
$second_config =& load_class('Config', 'core');

// load_classで同じインスタンスを返すため同一である。
assert($first_config === $second_config);

読み込みのロジックはLoader::modelやLoader::libraryとは異なり、Libraryやsystem/coreディレクトリのクラスを読み込むために利用されています。
そのため、フレームワーク利用者が触ることはほとんどありません。
またsystem/core内のLoaderクラスもこのload_class関数を利用して読み込まれます。

Loader::modelとLoader::libraryの実装

Loader::modelとLoader::libraryも同様にLibraryやModelクラスのインスタンスを共通化しやすい様に作られています。
何度ロードしてもControllerのプロパティに定義されているインスタンスは同じインスタンスです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php

class Sample extends CI_Controller
{
public function test()
{
$this->load->model('todo_model');
$first = $this->todo_model;
$this->load->model('todo_model');
$second = $this->todo_model;

// 何度ロードしても同じインスタンス
assert($first === $second);
}
}

ただし、既にロード済みの依存性をLoader::libraryやLoader::modelでロードした場合プロパティ名とインスタンス名が異なるとエラーが発生します。
プロパティ名とインスタンス名とは、正確にはinstanceof演算子でチェックしています。
そのため、継承関係にある場合はエラーは発生しません。

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

class Sample extends CI_Controller
{
public $todo;

public function __construct()
{
parent::__construct();
$this->todo = new CI_Form_validation();
}

public function test()
{
// CI_Form_validationがtodoプロパティに代入されているが、Todoクラスをロードしようとすると
// インスタンスが異なるためエラーになる。
$this->load->library('todo');
}
}

また、Loader::modelの場合は、Loader::modelを利用してプロパティに依存性を注入していない限り同じインスタンス名であってもエラーが発生します。

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

class Sample extends CI_Controller
{
public $todo;
public $todo_model;

public function __construct()
{
parent::__construct();
$this->todo = new Todo();
$this->todo_model = new Todo_model();
}

public function test()
{
// Todoインスタンスがプロパティに定義されている状態で
// 同じTodoクラスをロードした場合はエラーが発生しない。
$this->load->library('todo');
// Todo_modelインスタンスがプロパティに定義されている状態であっても
// Loader::modelを利用していなければ同じTodo_modelクラスをロードした場合はエラーが発生する。
$this->load->model('todo_model');
}
}

ここまでのまとめ

  • load_class関数はLibraryやsystem/coreディレクトリのクラスを読み込むために利用されている。
  • Loader::model、Loader::library実行時はinstanceofでインスタンス名がチェックされている。
  • Loader::modelはLoader::modelを利用せずにプロパティに代入されているとエラーが発生する。

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

load_class関数はLibraryやsystem/coreディレクトリのクラスを読み込むために利用されている

load_class関数はLibraryやsystem/coreディレクトリのクラスを読み込むために利用されています。

ci-phpunit-testにはload_class_instance関数があります。
そのため、CodeIgniterのsystem/coreに定義されたクラスファイルのモック化もある程度は可能です。

CodeIgniterの中でload_class関数を利用して読み込まれているクラスは以下の通りです。

  • Benchmark
  • Hooks
  • Config
  • Utf8
  • URI
  • Router
  • Output
  • Security
  • Input
  • Lang
  • Loader

これらのcoreクラスをMockオブジェクトに書き換えているテストが以下です。

テスト対象のコントローラ

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

class Sample extends CI_Controller
{
const APP_KEY = '42qj0j@vpq33-[29hgvaegh38';

public function __construct()
{
parent::__construct();
$app_key = $this->input->cookie('app-key');

if ($app_key !== self::APP_KEY) {
show_error('APP KEYが誤っています。');
}
}

public function index()
{
$this->load->view('sample');
}
}

テストコード

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

class Sample_test extends TestCase
{
public function test_app_key()
{
$mock = $this->getDouble(Input::class, [
'cookie' => '42qj0j@vpq33-[29hgvaegh38',
]);
$this->request->setCallablePreConstructor(function() use ($mock){
// Inputクラスをmockに差し替えることができる
load_class_instance('Input', $mock);
});

$output = $this->request('GET', 'sample/index');
$this->assertContains('sample', $output);
}
}

Loader::model、Loader::library実行時はinstanceofでインスタンス名がチェックされている

Loader::model、Loader::library実行時はinstanceofでインスタンス名がチェックされます。
そのため、以下の様なテストをするときは注意が必要です。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php

class Sample extends CI_Controller
{
public function __construct()
{
parent::__construct();
// MY_Form_validationが定義されているシステムと仮定
$this->load->library('form_validation');
if (!$this->form_validation->run()) {
show_404();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php

class Sample_test extends TestCase
{
public function test_instanceof()
{
// 継承元のCI_Form_validationのmockを作成している。
$mock = $this->getDouble(CI_Form_validation::class, [
'run' => true,
]);
$this->request->setCallablePreConstructor(function() use ($mock){
load_class_instance('form_validation', $mock);
});

// Controllerの$this->load->libraryの箇所でエラーが発生する。
// なぜならば、$mock instanceof MY_Form_validationはfalseだからだ。
$this->request('GET', 'sample/index');
}
}

上の例では、正しくは、MY_Form_validationのmockオブジェクトを作成する様にします。

Loader::modelはLoader::modelを利用せずにプロパティに代入されているとエラーが発生する

さきほどと打って変わって、Loader::modelでは同じインスタンスであっても注入の経路によってエラーが発生します。

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php

class Sample extends CI_Controller
{
public function __construct()
{
parent::__construct();
$this->load->model('auth_model');
if (!$this->auth_model->is_loggedin()) {
redirect('login/index');
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php

class Sample_test extends TestCase
{
public function test_instanceof()
{
// Mock化したいModelと同じクラスのMockを作成。
$mock = $this->getDouble(Auth_model::class, [
'is_loggedin' => true,
]);
$this->request->setCallablePreConstructor(function() use ($mock){
// load_classを用いて読み込んだことにするのでLoader::modelを経由したことにはならない。
load_class_instance('auth_model', $mock);
});

// Controllerの$this->load->modelの箇所でエラーが発生する。
// $mock instanceof Auth_modelはtrueだが、Loader::modelを経由していないためエラーが発生する。
$this->request('GET', 'sample/index');
}
}