1. PHPUnit Hello World

1.1 安装LAMP环境

在Ubuntu上安装LAMP环境非常简单,两条命令即可。

1 bob@Bob-VirtualBox:~$ sudo apt install tasksel
2 bob@Bob-VirtualBox:~$ sudo tasksel install lamp-server

验证 Apache

浏览器打开 http://localhost

验证 MySQL

1 bob@Bob-VirtualBox:~$ sudo mysql

验证 PHP

为了方便后续开发,我们将 Apache 默认目录 /var/www 的权限修从 root 修改为当前用户。

1 bob@Bob-VirtualBox:~$ ls -ld /var/www/
2 drwxr-xr-x 3 root root 4096 Jun 29 15:56 /var/www/
3 bob@Bob-VirtualBox:~$ sudo chown -R $USER:$USER /var/www/
4 bob@Bob-VirtualBox:~$ ls -ld /var/www/
5 drwxr-xr-x 3 hgy hgy 4096 Jun 29 15:56 /var/www/

Apache 默认的 VirtualHost 配置把 DocumentRoot 指向了 /var/www/html ,我们把它修改成 /var/www ,这样我们可以在 /var/www 目录下建不同的目录,把不同项目的代码区分开。

1 bob@Bob-VirtualBox:~$ sudo replace /var/www/html /var/www -- /etc/apache2/sites-avai\
2 lable/000-default.conf
3 bob@Bob-VirtualBox:~$ sudo systemctl reload apache2.service

我们使用 phpinfo() 函数来验证 PHP 是否安装好了。

1 bob@Bob-VirtualBox:~$ echo '<?php phpinfo();' > /var/www/phpinfo.php

浏览器打开 http://localhost/phpinfo.php

验证 PHP CLI

1 bob@Bob-VirtualBox:~$ php -v

1.2 安装PHPUnit

PHPUnit的安装也非常简单,同样两条命令就安装好了。

1 bob@Bob-VirtualBox:~$ wget -O phpunit https://phar.phpunit.de/phpunit-8.phar
2 bob@Bob-VirtualBox:~$ chmod +x phpunit

验证 phpunit

1 ./phpunit --version

现在使用 phpunit 必须输入相对路径或绝对路径,这样有点麻烦,因为我们会很多次地执行 phpunit 。我们将其加入到当前用户的 ~/bin 目录里,这样直接输入 phpunit 就可以了。

1 bob@Bob-VirtualBox:~$ mkdir bin
2 bob@Bob-VirtualBox:~$ mv phpunit bin/

退出(Logout)当前用户,再登录进来,或者直接重启一下系统。现在直接输入 phpunit 就可以了。

1 bob@Bob-VirtualBox:~$ phpunit --version

1.3 Hello World

现在,让我们写一个简单的 Hello World 程序。

我们在 /var/www 目录下新建一个名为 hello-world 的子目录。

1 bob@Bob-VirtualBox:~$ mkdir /var/www/hello-world

然后用你喜欢的编辑器编写如下代码,保存到 /var/www/hello-world/hello.php

1 <?php
2 
3 echo 'Hello World';

浏览器打开 http://localhost/hello-world/hello.php

我们让这个 hello world 程序稍微复杂一点点。

 1 <html>
 2 <head>
 3 <meta charset="utf-8">
 4 </head>
 5 <body>
 6 
 7 <?php
 8 
 9 if ($_GET) {
10     echo 'Hello ', isset($_GET['username']) && $_GET['username'] ? $_GET['username']\
11  : 'World';
12 }
13 
14 ?>
15 
16 <form>
17     Input your name: <input type="text" name="username">
18     <input type="submit" value="Submit">
19 </form>
20 
21 </body>
22 </html>

再次打开 http://localhost/hello-world/hello.php

直接点击“Submit”按钮

输入你的名字再次点击“Submit”按钮

OK,我们的 Hello World 程序运行地很好。

那么,怎么使用PHPUnit对其进行自动化测试呢?

作者确实看到过有人使用一些工具类库,通过写代码启动了一个浏览器,访问要测试的网址,然后判断打开的页面里是否有期望的内容。比如:

1 from selenium import webdriver
2 
3 browser = webdriver.Firefox()
4 browser.get('http://localhost')
5 
6 assert 'Hello' in browser.title

作者也看到一些MVC开发框架提供的测试方法,比如 Zend Framework 1 的 Zend_Test 类,在测试代码里直接驱动MVC框架运行,模拟浏览器的请求过程,最终获取到页面输出,然后同样判断页面里是否有期望的内容。

 1 class UserControllerTest extends Zend_Test_PHPUnit_ControllerTestCase
 2 {
 3     public function setUp()
 4     {
 5         $this->bootstrap = array($this, 'appBootstrap');
 6         parent::setUp();
 7     }
 8  
 9     public function appBootstrap()
10     {
11         $this->frontController
12              ->registerPlugin(new Bugapp_Plugin_Initialize('development'));
13     }
14  
15     public function testValidLoginShouldGoToProfilePage()
16     {
17         $this->request->setMethod('POST')
18               ->setPost(array(
19                   'username' => 'foobar',
20                   'password' => 'foobar'
21               ));
22         $this->dispatch('/user/login');
23         $this->assertRedirectTo('/user/view');
24  
25         $this->resetRequest()
26              ->resetResponse();
27  
28         $this->request->setMethod('GET')
29              ->setPost(array());
30         $this->dispatch('/user/view');
31         $this->assertRoute('default');
32         $this->assertModule('default');
33         $this->assertController('user');
34         $this->assertAction('view');
35         $this->assertNotRedirect();
36         $this->assertQuery('dl');
37         $this->assertQueryContentContains('h2', 'User: foobar');
38     }
39 }

这些方法给人的第一印象是 沉重 。我们不打算这样做。

作者在2012年刚开始探索TDD时,就使用过Zend_Test,并且不只是写个Hello World,而是真实地应用在了OurATS招聘管理系统的一个功能上。

这种测试方法看起来简单,实战时能把开发者累死(心累)。

主要在三方面:

  1. 开发者要了解MVC框架运行的细节,这样才能把浏览器请求转换为驱动MVC框架运行的代码。
  2. 开发者需要查看最终生成的html代码,然后才能精确定位期望的内容在哪里。并且如果页面改版,虽然呈现的主要内容还在,但显示的位置、样式都已经发生了变化,测试代码就跑不过了,要重写。
  3. 测试写起来很繁琐。因为期望的内容往往不是一个点,而每个点除了本身的内容外,还需要考虑如何定位到这个点。

我们的做法是,通过重新组织代码,提炼出核心逻辑,让其易于测试。然后只测试这个核心逻辑,而核心逻辑之外的代码,并不通过PHPUnit测试。

这种做法看似不严谨,因为我们没有测试完整的功能,但实际上却是测试得以落地的关键。具体为什么,我们先不展开分析,本书将通过实战项目让读者领会这一点。

1.4 Hello World 重构

hello.php 核心逻辑是:

接收用户的输入,如果用户输入了username,就返回 Hello username ,否则,返回 Hello World

新建一个文件 /var/www/hello-world/func.php

 1 <?php
 2 
 3 function sayHello(array $data)
 4 {
 5     if (isset($data['username']) && $data['username']) {
 6         return 'Hello ' . $data['username'];
 7     }
 8     
 9     return 'Hello World';
10 }

hello.php 中调用 sayHello

 1 <html>
 2 <head>
 3 <meta charset="utf-8">
 4 </head>
 5 <body>
 6 
 7 <?php
 8 
 9 if ($_GET) {
10     include __DIR__ . '/func.php';
11     echo sayHello($_GET);
12 }
13 
14 ?>
15 
16 <form>
17     Input your name: <input type="text" name="username">
18     <input type="submit" value="Submit">
19 </form>
20 
21 </body>
22 </html>

再次访问 http://localhost/hello-world/hello.php ,直接点击“Submit”,输入名字再次点击”Submit“,和重构前运行的一样,完全没问题。

1.5 Hello World Test

现在,我们可以为这个 Hello World 程序写测试了。

新建一个文件 /var/www/hello-world/sayHelloTest.php

 1 <?php
 2 
 3 class sayHelloTest extends PHPUnit\Framework\TestCase
 4 {
 5     public function testEmptyInput()
 6     {
 7         $data = array();
 8         $this->assertEquals('Hello World', sayHello($data));
 9     }
10     
11     public function testEmptyUsername()
12     {
13         $data = array('username' => '');
14         $this->assertEquals('Hello World', sayHello($data));
15     }
16     
17     public function testSayHello()
18     {
19         $data = array('username' => 'Bob'); 
20         $this->assertEquals('Hello Bob', sayHello($data));
21     }
22 }

然后执行这个测试

1 bob@Bob-VirtualBox:~$ cd /var/www/hello-world/
2 bob@Bob-VirtualBox:/var/www/hello-world$ phpunit sayHelloTest.php

报错了 Error: Call to undefined function sayHello()

这是因为我们的测试代码里没有 include 'func.php';

我们可以使用 phpunit 的参数 --bootstrap 来解决这个问题。

1 --bootstrap <file> A PHP script that is included before the tests run

/var/www/hello-world/bootstrap.php

1 <?php
2 
3 include __DIR__ . '/func.php';

加上 --bootstrap 参数再次执行

1 bob@Bob-VirtualBox:/var/www/hello-world$ phpunit --bootstrap=bootstrap.php sayHelloT\
2 est.php

1.6 phpunit.xml

在上一小节,我们要运行 sayHelloTest.php ,需要将其作为命令行参数传递给 phpunit,并且还要加上参数 --bootstrap=bootstrap.php ,如果要运行多次的话,就太不方便了。phpunit 可以通过配置文件 phpunit.xml 使得我们只需要在当前目录执行 phpunit 就可以运行该目录下的所有测试。

新建文件 /var/www/hello-world/phpunit.xml

1 <phpunit bootstrap="bootstrap.php" stopOnFailure="true" cacheResult="false">
2     <testsuites>
3         <testsuite name="All">
4             <directory>.</directory>
5         </testsuite>
6     </testsuites>
7 </phpunit>

stopOnFailure 的作用是如果遇到失败的测试 phpunit 是继续执行下去,还是立即停止执行,我们将其设为 true,这样方便我们修正错误。

cacheResult 的作用 phpunit 的使用文档里说明的有点模糊,默认值为 true ,导致运行 phpunit 后,会在当前目录生成一个隐藏文件 .phpunit.result.cache ,我们先把它设为 false ,如果后边有用到和 cache 相关的功能,再把它打开。

testsuite 是把多个测试组织在一块的一个办法,对于大多数项目,我们可以直接把所有测试都归到一个 testsuite 里去,这里我们把当前目录下的所有测试(实际上只有一个 sayHelloTest.php )归到一个名为 Alltestsuite 里。

phpunit 依赖 php-xml 扩展来读取 phpunit.xml,Ubuntu上执行下边的命令来安装 php-xml 扩展。

1 bob@Bob-VirtualBox:/var/www/hello-world$ sudo apt install php-xml

我们再次运行下测试,这次只用执行 phpunit 就好了,phpunit 会在当前目前下寻找 *Test.php 文件,然后自动执行里边的测试方法。

1.7 本章小结

本章我们准备好了基础的开发测试环境,并通过一个 Hello World 程序引出了作者的一个基础测试理论。

作者有一个英文名叫Bob,我们暂且把这个理论称作 Bob Test Theory 1 :

通过重新组织代码,提炼出核心逻辑,让其易于测试。然后只测试这个核心逻辑,而核心逻辑之外的代码,并不通过 PHPUnit 测试。

然后我们写了3个测试用例来测试 sayHello(array $data) ,并通过使用 phpunit.xml 简化了测试的执行。

本章代码

https://github.com/heguangyu5/PHPUnit-in-Action-Code/tree/chapter01/hello-world