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

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

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

Modelの実装

CodeIgniterのModelの実装は次の通りです。

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

class CI_Model {
public function __construct() {}

public function __get($key)
{
return get_instance()->$key;
}
}

本当に、これだけです。

マジックメソッドの__getでプロパティが存在しない場合はControllerインスタンスのプロパティを利用する様にしています。
ここで重要になるのがマジックメソッドの__getプロパティが存在しない場合に動きます。

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

次の様なテストコードを実行した場合、Todo_modelの$this->todo_daoはControllerインスタンスのtodo_daoプロパティを参照しているわけではないです。
テストコードでTodo_modelのtodo_daoプロパティに注入したMockオブジェクトを参照しています。
このとき、__getメソッドは呼び出されていません。

モデル

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

class Todo_model extends CI_Model
{
public function find(int $id)
{
// Loader::modelを用いているのでControllerインスタンスにtodo_daoプロパティが注入される。
$this->load->model('todo_dao');
// `__get`マジックメソッドによりControllerインスタンスのtodo_daoプロパティが利用される。
return $this->todo_dao->get($id);
}
}

テストコード

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_model_test extends UnitTestCase
{
private $obj;

public function setUp()
{
parent::setUp();
$this->obj = $this->newModel('todo_model');
}

public function test_inject_todo_dao_property()
{
// Todo_modelのtodo_daoプロパティにMockオブジェクトを注入している。
// このプロパティへのMockオブジェクトの注入により、
// Todo_modelからtodo_daoへの参照時に__getは動作しません。
$this->obj->todo_dao = $this->getDouble(Todo_dao::class, [
'get' => ['id' => 1]
]);

$actual = $this->obj->find(1);
$this->assertSame(1, $actual['id']);
}
}

このため、前回のLoader::modelの説明では

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

と記載しましたが、この場合は$this->load->model('todo_dao')を呼び出していますが、エラーは発生しないです。

なぜならば、Loader::modelではControllerインスタンスのプロパティに対して、存在やインスタンス名がチェックされるからです。

つまり、以下の様なテストを書いたとしてもTodo_dao内でdbのmockオブジェクトが参照されることはありません。

Todo_dao

1
2
3
4
5
6
7
8
9
10
class Todo_dao extends CI_Model
{
public function get(int $id): array
{
$this->db->where('id', $id);
$query = $this->db->get('todo', 1);

return $query->row(0, 'array') ?? [];
}
}

Todo_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
25
26
27
28
29
<?php

class Todo_model_test extends UnitTestCase
{
private $obj;

public function setUp()
{
parent::setUp();
$this->obj = $this->newModel('todo_model');
}

public function test_inject_todo_dao_property()
{
$query_mock = $this->getDouble(CI_DB_result::class, [
'row' => ['id' => 1]
])

// Todo_modelのdbプロパティにMockオブジェクトを注入している。
// 決してControllerのdbプロパティへインジェクトしているわけではないので、Todo_daoからmockは参照されない。
$this->obj->db = $this->getDouble(CI_DB_query_builder::class, [
'where' => $this->returnSelf(),
'get' => $query_mock
]);

$actual = $this->obj->find(1);
$this->assertSame(1, $actual['id']); // fail
}
}

総まとめ

Controllerの実装(設計)

  • システム内でget_instance関数を用いて、Controllerの同一なインスタンスを利用しやすい様になっています。
  • Controllerインスタンスは複数のインスタンスを生成することができます。

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

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

依存性解決の実装

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

Modelの実装

  • Modelはプロパティが存在しないときにControllerインスタンスのプロパティを参照する。

最後に

この中でも特に重要なのはCodeIgniter3というフレームワークは、Controllerインスタンスのプロパティへ依存性を注入して依存関係を解決しています。
そのフレームワークの機能を用いた依存性の注入が行われる経路は大きく分けてload_classLoaderがあります。

  • どのControllerインスタンスが利用されているか。
  • どの経路から注入することができるか。
  • どの様に注入することでテストが動くか。

これらの知識は、依存関係の解決が複雑なControllerのテスト時に役にたちます。
これらを覚えてモリモリci-phpunit-testでテストを書きましょう。

また、Loaderの存在しないCodeIgniter4では、Controllerのテスト時しやすいようにコンストラクタでの依存関係解決は行われていないです。
これは、また別の機会に。

現場からは以上でした。