框架提供了一个工具可以快速的开发应用,框架允许你快速的创建功能,但是通常你会获得技术债务。当可维护性不是作为开发人员的主要目的,技术债务就产生了。由于缺少单元测试和构架,未来的修改和调试就变得非常昂贵了。
这里我们将讨论如何构建可测试和可维护的代码。
本文会涉及到:
- DRY
- 依赖注入
- 接口
- 容器
- phpunit单元测试
让我们从一段故意设计,但是非常典型的代码开始。在很多框架中,这可能是一个模型类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class User
{
public function getCurrentUser()
{
$user_id = $_SESSION['user_id'];
$user = App::db->select('id, username')
->where('id', $user_id)
->limit(1)
->get();
if ($user->num_results() > 0) {
return $user->row();
}
return false;
}
}
这段代码虽然可以工作,但是需要改进:
-
这段代码不具备可测试性。我们依赖$_SESSION全局变量,像phpunit这类测试框架是在命令行模式下运行的,$_SESSION和其它一些全局变量可能并不会生效。
-
这段代码的可维护性不是很好。例如:我们修改了数据源,应用中每一处使用了App::db实例的地方都需要修改。此外,如果我们不仅仅是需要当前用户信息(需要获取别的用户的信息)怎么办呢?
一次单元测试的尝试
这里我们为上面的功能尝试创建一个单元测试。
1
2
3
4
5
6
7
8
9
class UserModelTest extends PHPUnit_Framework_TestCase
{
public function testGetUser()
{
$user = new User();
$currentUser = $user->getCurrentUser();
$this->assertEquals(1, $currentUser->id);
}
}
让我们检查下,首先,这个测试会失败。在User对象中使用的$_SESSION 并不存在于单元测试中,因为是在命令行运行的php。
其次,这里没有设置数据库连接,这意味着,想让这个测试单元工作,我们需要启动应用来获取App对象和它的db对象,我们还需要一个数据库连接来进行测试。
为了让这个单元测试工作,我们需要:
- 在我们的应用中为 命令行界面(PHPUnit)设置配置文件
- 依赖一个数据库连接,这样做意味着我们依靠一个独立于单元测试的数据源,如果我们的测试数据库没有期望的数据会怎样?如果我们的数据库连接很慢会怎样?
- 依赖一个已经启动的应用会增加测试的负担,会明显降低单元测试的速度。理想情况下,我们的大部分的可被测试的代码独立于我们所使用的框架。
那么,让我们着手考虑如何改善这些问题。
保持代码DRY(don’t repeat yourself)
在这个简单的环境中检索当前用户信息的功能是不必要的,这是一个人为设计的例子,但本着DRY原则精神,我首先选择先优化“getUser”方法,使其更通用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class User
{
public function getUser($user_id)
{
$user = App::db->select('user')
->where('id', $user_id)
->limit(1)
->get();
if ($user->num_results() > 0) {
return $user->row();
}
return false;
}
}
现在”getUser”方法可以在整个应用中使用了。我们可以通过用户id来调用到当前用户,而不是把这个功能(返回当前用户信息)封装到模型里面。当代码不再依赖其它功能(比如:session全局变量)时,就变得模块化和可维护。
但是,这依然没有实现代码本应有的可测试性和可维护性,我们仍然依赖数据库连接。
依赖注入
我们通过引入“依赖注入”来改善情况。我们的模型类看起来可能是这样的,当我们传入数据库连接到这个类:
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
class User
{
protected $_db;
public function __construct($db_connection)
{
$this->_db = $db_connection;
}
public function getUser($user_id)
{
$user = $this->_db->select('user')
->where('id', $user_id)
->limit(1)
->get();
if ($user->num_results() > 0) {
return $user->row();
}
return false;
}
}
现在,我们User模型的依赖被提供,我们的类不再使用一个确定的数据库连接,也不依赖全局变量。
从这点讲,我们的类基本上可测试的。我们可以传递我们选择的数据源和用户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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
use Mockery as m;
use Fideloper\User;
class SecondUserTest extends PHPUnit_Framework_TestCase
{
public function testGetCurrentUserMock()
{
$db_connection = $this->_mockDb();
$user = new User($db_connection);
$result = $user->getUser(1);
$expected = new StdClass();
$expected->id = 1;
$expected->username = 'fideloper';
$this->assertEquals($result->id, $expected->id, 'User ID set correctly');
$this->assertEquals($result->username, $expected->username, 'Username set correctly');
}
protected function _mockDb()
{
// "Mock" (stub) database row result object
$returnResult = new StdClass();
$returnResult->id = 1;
$returnResult->username = 'fideloper';
// Mock database result object
$result = m::mock('DbResult');
$result->shouldReceive('num_results')->once()->andReturn(1);
$result->shouldReceive('row')->once()->andReturn($returnResult);
// Mock database connection object
$db = m::mock('DbConnection');
$db->shouldReceive('select')->once()->andReturn($db);
$db->shouldReceive('where')->once()->andReturn($db);
$db->shouldReceive('limit')->once()->andReturn($db);
$db->shouldReceive('get')->once()->andReturn($result);
return $db;
}
}
我在单元测试中加入了新东西:Mockery,Mockery让你可以模拟php对象。在这个例子中,我们模拟了数据连接,使用模拟,我们可以跳过测试数据连接,从而简化模型测试。
想了解Mockery?
这个例子中,我们模拟了一个sql连接。我们告诉模拟对象它有select、where、limit、和get方法会被调用。我返回模拟对象本身,sql连接对象返回它本身($this),这就是所谓的“链式”方法。注意,对于get方法,我返回的是数据库调用结果——一个用户数据填充的stdClass对象。
这样解决了几个问题:
-
我们只测试我们的模型类,不需要同时测试数据库链接。
-
我们可以控制数据库连接对象的输入和输出,因此,我们可以对数据库的调用结果进行可靠的测试。我知道我会获得userid为1的数据库调用结果。
-
我们不需要为测试启动我们的整个应用,也不需要任何配置和数据库。
我们仍然可以做得更好,这里会变得很有趣。
接口
为了进一步改进,我们定义和实现了一个接口,考虑下面的代码:
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
interface UserRepositoryInterface
{
public function getUser($user_id);
}
class MysqlUserRepository implements UserRepositoryInterface
{
protected $_db;
public function __construct($db_conn)
{
$this->_db = $db_conn;
}
public function getUser($user_id)
{
$user = $this->_db->select('user')
->where('id', $user_id)
->limit(1)
->get();
if ($user->num_results() > 0) {
return $user->row();
}
return false;
}
}
class User
{
protected $userStore;
public function __construct(UserRepositoryInterface $user)
{
$this->userStore = $user;
}
public function getUser($user_id)
{
return $this->userStore->getUser($user_id);
}
}
这里我们做了几件事:
-
首先,我们为user数据源定义了一个接口。其中定义了getUser方法。
-
接下来,我们实现了这个接口。这个例子中,我们创建了mysql实现,我们接收一个数据库连接对象,通过它来从数据库抓取一个用户。
-
最后我们强制,user模型类中使用的类必须实现UserInterface。这样保证了数据源始终有getUser方法,不管实现UserInterface时使用了哪种数据源。
记住我们的user对象的类型提示“UserInterface”是在它的构造函数,这意味着一个实现UserInterface的类必须被传递到User对象,这样保证了我们需要的getUser方法始终是有效的。
这样做的结果是什么?
-
我们的代码现在是完全可测试的,对于User类,我们可以简单的模拟数据源(数据源的测试将会是独立的单元测试工作)。
-
我们的代码有很好的可维护性。我们可以切换不同的数据源,而不用到处改变应用的代码。
-
我们可以创建任何的数据源:ArrayUser, MongoDbUser, CouchDbUser, MemoryUser等。
-
我们可以根据需要传递任意数据源到User对象。如果你决定丢弃Mysql,你只需要创建一个不同的实现(比如:MongoDbUser),把它传递给User模型。
同样,我们简化了单元测试。
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
30
31
32
33
34
35
36
37
use Mockery as m;
use Fideloper\User;
class ThirdUserTest extends PHPUnit_Framework_TestCase {
public function testGetCurrentUserMock()
{
$userRepo = $this->_mockUserRepo();
$user = new User( $userRepo );
$result = $user->getUser( 1 );
$expected = new StdClass();
$expected->id = 1;
$expected->username = 'fideloper';
$this->assertEquals( $result->id, $expected->id, 'User ID set correctly' );
$this->assertEquals( $result->username, $expected->username, 'Username set correctly' );
}
protected function _mockUserRepo()
{
// Mock expected result
$result = new StdClass();
$result->id = 1;
$result->username = 'fideloper';
// Mock any user repository
$userRepo = m::mock('Fideloper\Third\Repository\UserRepositoryInterface');
$userRepo->shouldReceive('getUser')->once()->andReturn( $result );
return $userRepo;
}
}
我们完全省去了数据库连接的工作,现在我们很容易的模拟数据源,并且告诉它当getUser被调用时应该做什么?
但是,我们可以做得更好!
容器
考虑下我们当前代码的用法:
1
2
3
4
5
// In some controller
$user = new User( new MysqlUser( App:db->getConnection("mysql") ) );
$user_id = App::session("user->id");
$currentUser = $user->getUser($user_id);
我们的最后一步是介绍容器。在上面的代码中,我们需要使用一堆对象,仅仅只是为了获取我们的当前用户。这些代码可能会散落在你的整个应用中。如果你需要从mysql切换到MongoDB,你仍然需要修改以上代码出现的每个地方。这几乎不可能DRY。容器可以解决这个问题。
一个容器仅仅包含一个对象或功能。它很像你应用中的注册表。我们可以通过容器(使用所有需要的依赖)自动初始化一个User对象。下面,我使用了Pimple,一个流行的容器类。
1
2
3
4
5
6
7
8
// Somewhere in a configuration file
$container = new Pimple();
$container["user"] = function() {
return new User( new MysqlUser( App:db->getConnection('mysql') ) );
}
// Now, in all of our controllers, we can simply write:
$currentUser = $container['user']->getUser( App::session('user_id') );
我把创建User对象移动到了应用中的某个地方,结果是:
- 我们保持我们的代码DRY,User对象和数据源的选择在应用中的一个地方定义。
- 我们修改User对象使用的Mysql修改成其它数据源时,只在一个地方修改。这样极大的提高了可维护性。
总结
在本教程中,我们实现了以下几点:
- 保持代码DRY和可重复使用
- 创建可维护的代码——如果我们需要,我们可以在一个地方为整个应用切换数据源。
- 使我们的代码可测试——我们可以简单的模拟对象,而不需要启动整个应用和创建测试数据库。
- 为了创建可测试和可维护代码,学习了使用依赖注入和接口。
- 看见了容器是如何帮助我们的应用更具可维护性
我敢肯定,你注意到了,我们以可维护性和可测试性的名义增加了更多的代码。反对这种实施一个强有力的依据是:我们增加了复杂性。确实,这需要项目参与者更深入的了解代码。
但是我们的付出,完全被技术债务抵消掉了:
- 代码获得极大的可维护性,在一个地方修改变成了可能,而不是在多个地方修改。
- 单元测试能够(快速)大幅度的减少代码中的bug,尤其是在那些长期或社区主导型(开源)的项目中。
- 预先做些额外的工作,可以节约以后的时间和避免头疼。
资源
你可以使用Composer方便的在你的项目中引入phpunit和Mockery。composer.json文件中的”require-dev”项:
1
2
3
4
"require-dev": {
"mockery/mockery": "0.8.*",
"phpunit/phpunit": "3.7.*"
}
安装
1
$ php composer.phar install --dev
很多PHP框架(比如laravel)也用到了容器和上面写到的一些其它概念。
英文原文:How to Write Testable and Maintainable Code in PHP
转自:http://www.tangmanong.com/article/2.html