编写一个简单的测试

测试PHP代码的主要手段是使用PHPUnit,它是基于一种叫做单元测试的方法论。单元测试背后的理念非常简单:将代码分解成尽可能小的逻辑单元。然后对每个单元进行隔离测试,以确认它的性能符合预期。这些预期被编纂成一系列的断言。如果所有的断言都返回 "true",那么这个单元就通过了测试。

在程序化PHP中,单元是一个函数。对于OOP PHP来说,单元是一个类中的方法。

如何做...

1.首先要做的是直接将 PHPUnit 安装到你的开发服务器上,或者下载源码,源码以单个 phar(PHP 档案)文件的形式存在。快速访问PHPUnit的官方网站(https://phpunit.de/),我们可以直接从主页下载。

2. 然而,最好的做法是使用一个包管理器来安装和维护 PHPUnit。为此,我们将使用一个名为 Composer 的软件包管理程序。要安装 Composer,请访问主网站 https://getcomposer.org/,并按照下载页面的说明进行安装。目前的程序,在写这篇文章的时候,如下所示。注意,你需要用当前版本的哈希值代替<hash>

php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
php -r "if (hash_file('SHA384', 'composer-setup.php') === '<hash>') { 
    echo 'Installer verified'; 
} else { 
    echo 'Installer corrupt'; unlink('composer-setup.php'); 
} echo PHP_EOL;"
php composer-setup.php
php -r "unlink('composer-setup.php');"

最佳实践

使用Composer这样的软件包管理程序的好处是,它不仅可以安装,还可以用来更新你的应用程序所使用的任何外部软件(如PHPUnit)。

3. 接下来,我们使用Composer来安装PHPUnit。这是通过创建一个composer.json文件来完成的,该文件包含一系列概述项目参数和依赖关系的指令。对这些指令的完整描述超出了本书的范围;然而,为了本实例的目的,我们使用关键参数 require 创建了一组最小的指令。你还会注意到,文件的内容是JavaScript对象符号(JSON)格式。

{
  "require-dev": {
    "phpunit/phpunit": "*"
  }
}

4. 要从命令行进行安装,我们运行以下命令。后面的输出就会显示出来。

php composer.phar install

5. PHPUnit 和它的依赖项被放置在一个 vendor 文件夹中,如果该文件夹不存在,Composer 将会创建它。然后,调用 PHPUnit 的主要命令被符号化地链接到 vendor/bin 文件夹中。如果你把这个文件夹放在你的 PATH 中,你所需要做的就是运行这个命令,它将检查版本并顺便确认安装。

phpunit --version

运行简单的测试

1.为了便于说明,我们假设我们有一个包含add()函数的chap_13_unit_test_simple.php文件。

<?php
function add($a = NULL, $b = NULL)
{
  return $a + $b;
}

2. 然后将测试写成扩展PHPUnit\Framework\TestCase的类。如果你要测试一个函数库,在测试类的开头,包括包含函数定义的文件。然后,你会写出以test开头的方法,通常后面是你要测试的函数的名称,可能还有一些额外的CamelCase词来进一步描述测试。在本示例中,我们将定义一个SimpleTest测试类。

<?php
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/chap_13_unit_test_simple.php';
class SimpleTest extends TestCase
{
  // testXXX() methods go here
}

3. 断言是任何测试集的核心。一个断言是一个PHPUnit方法,它将一个已知的值和你想测试的值进行比较。一个例子是 assertEquals(),它检查第一个参数是否等于第二个参数。下面的例子测试了一个名为 add() 的方法,并确认 2 是 add(1,1) 的返回值。

public function testAdd()
{
  $this->assertEquals(2, add(1,1));
}

4. 你也可以测试一下某件事情是否不真实。这个例子断言1+1不等于3。

$this->assertNotEquals(3, add(1,1));

5. assertRegExp()是一个在测试字符串时非常有用的断言。在这个例子中,假设我们正在测试一个从一个多维数组中生成一个HTML表格的函数。

function table(array $a)
{
  $table = '<table>';
  foreach ($a as $row) {
    $table .= '<tr><td>';
    $table .= implode('</td><td>', $row);
    $table .= '</td></tr>';
  }
  $table .= '</table>';
  return $table;
}

6. 我们可以构造一个简单的测试,以确认输出包含<table>,一个或多个字符,然后是</table>。此外,我们希望确认元素<td>B</td>存在。在编写测试时,我们建立一个由三个子数组组成的测试数组,其中包含字母A-C、D-F和G-I。然后我们将测试数组传递给函数,并针对结果运行断言。

public function testTable()
{
  $a = [range('A', 'C'),range('D', 'F'),range('G','I')];
  $table = table($a);
  $this->assertRegExp('!^<table>.+</table>$!', $table);
  $this->assertRegExp('!<td>B</td>!', $table);
}

7. 要测试一个类,不需要包含一个函数库,只需要包含定义要测试的类的文件。为了便于说明,让我们把前面显示的函数库移到Demo类中。

<?php
class Demo
{
  public function add($a, $b)
  {
    return $a + $b;
  }

  public function sub($a, $b)
  {
    return $a - $b;
  }
  // etc.
}

8. 在我们的SimpleClassTest测试类中,我们不包含库文件,而是包含代表Demo类的文件。为了运行测试,我们需要一个Demo的实例。为此,我们使用了一个专门设计的setup()方法,它在每次测试之前都会运行。另外,你会注意到一个 teardown()方法,它是在每次测试后立即运行的。

<?php
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/Demo.php';
class SimpleClassTest extends TestCase
{
  protected $demo;
  public function setup()
  {
    $this->demo = new Demo();
  }
  public function teardown()
  {
    unset($this->demo);
  }
  public function testAdd()
  {
    $this->assertEquals(2, $this->demo->add(1,1));
  }
  public function testSub()
  {
    $this->assertEquals(0, $this->demo->sub(1,1));
  }
  // etc.
}

之所以在每次测试前后运行setup()trapdown(),是为了保证一个新鲜的测试环境。这样,一个测试的结果就不会影响另一个测试的结果。

测试数据库模型类

1.当测试一个有数据库访问权限的类(如Model类)时,其他的考虑因素也在发挥作用。主要的考虑是,你应该针对测试数据库,而不是生产中使用的真实数据库来运行测试。最后一点是,通过使用测试数据库,你可以事先用适当的、受控的数据填充它,setup()teardown()也可以用来添加或删除测试数据。

2. 作为一个使用数据库的类的例子,我们将定义一个类 VisitorOps。这个新类将包括添加、删除和查找访客的方法。请注意,我们还添加了一个方法来返回最新执行的SQL语句。

<?php
require __DIR__ . '/../Application/Database/Connection.php';
use Application\Database\Connection;
class VisitorOps
{

const TABLE_NAME = 'visitors';
protected $connection;
protected $sql;

public function __construct(array $config)
{
  $this->connection = new Connection($config);
}

public function getSql()
{
  return $this->sql;
}

public function findAll()
{
  $sql = 'SELECT * FROM ' . self::TABLE_NAME;
  $stmt = $this->runSql($sql);
  while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
    yield $row;
  }
}

public function findById($id)
{
  $sql = 'SELECT * FROM ' . self::TABLE_NAME;
  $sql .= ' WHERE id = ?';
  $stmt = $this->runSql($sql, [$id]);
  return $stmt->fetch(PDO::FETCH_ASSOC);
}

public function removeById($id)
{
  $sql = 'DELETE FROM ' . self::TABLE_NAME;
  $sql .= ' WHERE id = ?';
  return $this->runSql($sql, [$id]);
}

public function addVisitor($data)
{
  $sql = 'INSERT INTO ' . self::TABLE_NAME;
  $sql .= ' (' . implode(',',array_keys($data)) . ') ';
  $sql .= ' VALUES ';
  $sql .= ' ( :' . implode(',:',array_keys($data)) . ') ';
  $this->runSql($sql, $data);
  return $this->connection->pdo->lastInsertId();
}

public function runSql($sql, $params = NULL)
{
  $this->sql = $sql;
  try {
      $stmt = $this->connection->pdo->prepare($sql);
      $result = $stmt->execute($params);
  } catch (Throwable $e) {
      error_log(__METHOD__ . ':' . $e->getMessage());
      return FALSE;
  }
  return $stmt;
}
}

3. 对于涉及数据库的测试,建议使用测试数据库而不是实时生产数据库。相应地,你将需要一组额外的数据库连接参数,可以用来在setup()方法中建立数据库连接。

4. 有可能你希望建立一个一致的样本数据块。这可以在setup()方法中插入到测试数据库中。

5. 最后,你可能希望在每次测试后重置测试数据库,这在 teardown() 方法中完成。

使用MOCK类

1.在某些情况下,测试将访问需要外部资源的复杂组件。一个例子是需要访问数据库的服务类。在测试套件中尽量减少数据库访问是一个最佳实践。另一个考虑因素是,我们不是在测试数据库访问;我们只是在测试一个特定类的功能。因此,有时有必要定义模拟类,模仿其父类的行为,但限制对外部资源的访问。

最佳实践

在你的测试中,将实际的数据库访问限制在Model(或同等的)类中。否则,运行整套测试所需的时间可能会过长。

2. 在这种情况下,为了说明问题,定义一个服务类VisitorService,它使用了前面讨论的VisitorOps类。

<?php
require_once __DIR__ . '/VisitorOps.php';
require_once __DIR__ . '/../Application/Database/Connection.php';
use Application\Database\Connection;
class VisitorService
{
  protected $visitorOps;
  public function __construct(array $config)
  {
    $this->visitorOps = new VisitorOps($config);
  }
  public function showAllVisitors()
  {
    $table = '<table>';
    foreach ($this->visitorOps->findAll() as $row) {
      $table .= '<tr><td>';
      $table .= implode('</td><td>', $row);
      $table .= '</td></tr>';
    }
    $table .= '</table>';
    return $table;
  }

3. 为了测试的目的,我们为$visitorOps属性添加一个getter和setter。这允许我们插入一个模拟类来代替真正的VisitorOps类。

public function getVisitorOps()
{
  return $this->visitorOps;
}

public function setVisitorOps(VisitorOps $visitorOps)
{
  $this->visitorOps = $visitorOps;
}
} // closing brace for VisitorService

4. 接下来,我们定义一个VisitorOpsMock模拟类,模仿其父类的功能。类的常量和属性都是继承的。然后我们添加模拟测试数据,以及一个getter,以备以后需要访问测试数据时使用。

<?php
require_once __DIR__ . '/VisitorOps.php';
class VisitorOpsMock extends VisitorOps
{
  protected $testData;
  public function __construct()
  {
    $data = array();
    for ($x = 1; $x <= 3; $x++) {
      $data[$x]['id'] = $x;
      $data[$x]['email'] = $x . 'test@unlikelysource.com';
      $data[$x]['visit_date'] = 
        '2000-0' . $x . '-0' . $x . ' 00:00:00';
      $data[$x]['comments'] = 'TEST ' . $x;
      $data[$x]['name'] = 'TEST ' . $x;
    }
    $this->testData = $data;
  }
  public function getTestData()
  {
    return $this->testData;
  }

5. 接下来,我们重写findAll()来使用yield返回测试数据,就像在父类中一样。请注意,我们仍然构建SQL字符串,因为这是父类的工作。

public function findAll()
{
  $sql = 'SELECT * FROM ' . self::TABLE_NAME;
  foreach ($this->testData as $row) {
    yield $row;
  }
}

6. 为了模拟findById(),我们简单地从$this->testData返回数组键。对于removeById(),我们取消设置从$this->testData中提供的数组键作为参数。

public function findById($id)
{
  $sql = 'SELECT * FROM ' . self::TABLE_NAME;
  $sql .= ' WHERE id = ?';
  return $this->testData[$id] ?? FALSE;
}
public function removeById($id)
{
  $sql = 'DELETE FROM ' . self::TABLE_NAME;
  $sql .= ' WHERE id = ?';
  if (empty($this->testData[$id])) {
      return 0;
  } else {
      unset($this->testData[$id]);
      return 1;
  }
}

7. 添加数据稍微复杂一些,因为我们需要模拟一个事实,即id参数可能没有被提供,因为数据库通常会自动为我们生成这个参数。为了解决这个问题,我们检查id参数。如果没有设置,我们找到最大的数组键,然后递增。

public function addVisitor($data)
{
  $sql = 'INSERT INTO ' . self::TABLE_NAME;
  $sql .= ' (' . implode(',',array_keys($data)) . ') ';
  $sql .= ' VALUES ';
  $sql .= ' ( :' . implode(',:',array_keys($data)) . ') ';
  if (!empty($data['id'])) {
      $id = $data['id'];
  } else {
      $keys = array_keys($this->testData);
      sort($keys);
      $id = end($keys) + 1;
      $data['id'] = $id;
  }
    $this->testData[$id] = $data;
    return 1;
  }

} // ending brace for the class VisitorOpsMock

使用匿名类作为模拟对象

1.关于mock对象的一个很好的变化是使用新的PHP 7匿名类来代替创建一个定义mock功能的正式类。使用匿名类的好处是可以扩展一个现有的类,这使得对象看起来合法。如果你只需要覆盖一两个方法,这种方法特别有用。

2. 在这个例子中,我们将修改之前介绍的VisitorServiceTest.php,将其称为VisitorServiceTestAnonClass.php

<?php
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/VisitorService.php';
require_once __DIR__ . '/VisitorOps.php';
class VisitorServiceTestAnonClass extends TestCase
{
  protected $visitorService;
  protected $dbConfig = [
    'driver'   => 'mysql',
    'host'     => 'localhost',
    'dbname'   => 'php7cookbook_test',
    'user'     => 'cook',
    'password' => 'book',
    'errmode'  => PDO::ERRMODE_EXCEPTION,
  ];
    protected $testData;

3. 您会注意到,在setup()中,我们定义了一个匿名类,该类扩展了VisitorOps。我们只需要重写findAll()方法。

public function setup()
{
  $data = array();
  for ($x = 1; $x <= 3; $x++) {
    $data[$x]['id'] = $x;
    $data[$x]['email'] = $x . 'test@unlikelysource.com';
    $data[$x]['visit_date'] = 
      '2000-0' . $x . '-0' . $x . ' 00:00:00';
    $data[$x]['comments'] = 'TEST ' . $x;
    $data[$x]['name'] = 'TEST ' . $x;
  }
  $this->testData = $data;
  $this->visitorService = 
    new VisitorService($this->dbConfig);
  $opsMock = 
    new class ($this->testData) extends VisitorOps {
      protected $testData;
      public function __construct($testData)
      {
        $this->testData = $testData;
      }
      public function findAll()
      {
        return $this->testData;
      }
    };
    $this->visitorService->setVisitorOps($opsMock);
}

4. 请注意,在testShowAllVisitors()中,当$this->visitorService->showAllVisitors()被执行时,匿名类会被访问者服务调用,而访问者服务又会调用重写的findAll()

public function teardown()
{
  unset($this->visitorService);
}
public function testShowAllVisitors()
{
  $result = $this->visitorService->showAllVisitors();
  $this->assertRegExp('!^<table>.+</table>$!', $result);
  foreach ($this->testData as $key => $value) {
    $dataWeWant = '!<td>' . $key . '</td>!';
    $this->assertRegExp($dataWeWant, $result);
  }
}
}

使用MOCK BUILDER

1.另一种技术是使用getMockBuilder()。虽然这种方法不允许对产生的mock对象进行大量的有限控制,但在你只需要确认返回某个类的对象,并且当运行指定的方法时,这个方法会返回一些预期的值的情况下,这种方法是非常有用的。

2. 在下面的示例中,我们复制了VisitorServiceTestAnonClass;唯一的区别在于如何在setup()中提供VisitorOps的实例,在本例中,使用getMockBuilder()。请注意,虽然我们在这个例子中没有使用with(),但它是用来将受控参数馈送到模拟方法的。

<?php
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/VisitorService.php';
require_once __DIR__ . '/VisitorOps.php';
class VisitorServiceTestAnonMockBuilder extends TestCase
{
  // code is identical to VisitorServiceTestAnon
  public function setup()
  {
    $data = array();
    for ($x = 1; $x <= 3; $x++) {
      $data[$x]['id'] = $x;
      $data[$x]['email'] = $x . 'test@unlikelysource.com';
      $data[$x]['visit_date'] = 
        '2000-0' . $x . '-0' . $x . ' 00:00:00';
      $data[$x]['comments'] = 'TEST ' . $x;
      $data[$x]['name'] = 'TEST ' . $x;
  }
  $this->testData = $data;
    $this->visitorService = 
      new VisitorService($this->dbConfig);
    $opsMock = $this->getMockBuilder(VisitorOps::class)
                    ->setMethods(['findAll'])
                    ->disableOriginalConstructor()
                    ->getMock();
                    $opsMock->expects($this->once())
                    ->method('findAll')
                    ->with()
                    ->will($this->returnValue($this->testData));
                    $this->visitorService->setVisitorOps($opsMock);
  }
  // remaining code is the same
}

我们已经展示了如何创建简单的一次性测试。然而,在大多数情况下,你会有许多需要测试的类,最好是一次全部测试。这可以通过开发一个测试套件来实现,在下一个事例中会有更详细的讨论。

如何运行...

首先,你需要安装 PHPUnit,如步骤 1 至 5 所述。 确保在 PATH 中包含 vendor/bin,这样你就可以从命令行运行 PHPUnit。

运行简单的测试

接下来,定义一个chap_13_unit_test_simple.php程序文件,其中包含一系列简单的函数,如步骤1中讨论的add()sub()等。然后你可以定义一个简单的测试类,包含在SimpleTest.php中,如步骤2和步骤3中提到的。

假设phpunit在你的PATH中,从终端窗口,改变到包含为这个配方开发的代码的目录,并运行以下命令。

phpunit SimpleTest SimpleTest.php

你应该看到以下输出。

SimpleTest.php中进行修改,使测试失败(第4步)。

public function testDiv()
{
  $this->assertEquals(2, div(4, 2));
  $this->assertEquals(99, div(4, 0));
}

这是修订后的产出。

接下来,在chap_13_unit_test_simple.php中添加table()函数(步骤5),在SimpleTest.php中添加testTable()(步骤6)。重新运行单元测试,观察结果。

要测试一个类,将在chap_13_unit_test_simple.php中开发的函数复制到Demo类中(步骤7)。在对步骤8中建议的SimpleTest.php进行修改后,重新运行简单测试并观察结果。

测试数据库模型类

首先,创建一个要测试的示例类,VisitorOps,如本小节步骤2所示。现在你可以定义一个类,我们将调用SimpleDatabaseTest来测试VisitorOps。首先,使用require_once来加载要测试的类。(我们将在下一个实例中讨论如何加入自动加载!)然后定义关键属性,包括测试数据库配置和测试数据。你可以使用php7cookbook_test作为测试数据库。

<?php
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/VisitorOps.php';
class SimpleDatabaseTest extends TestCase
{
  protected $visitorOps;
  protected $dbConfig = [
    'driver'   => 'mysql',
    'host'     => 'localhost',
    'dbname'   => 'php7cookbook_test',
    'user'     => 'cook',
    'password' => 'book',
    'errmode'  => PDO::ERRMODE_EXCEPTION,
  ];
  protected $testData = [
    'id' => 1,
    'email' => 'test@unlikelysource.com',
    'visit_date' => '2000-01-01 00:00:00',
    'comments' => 'TEST',
    'name' => 'TEST'
  ];
}

接下来,定义setup(),插入测试数据,并确认最后一条SQL语句是INSERT。还应该检查返回值是否为正值。

public function setup()
{
  $this->visitorOps = new VisitorOps($this->dbConfig);
  $this->visitorOps->addVisitor($this->testData);
  $this->assertRegExp('/INSERT/', $this->visitorOps->getSql());
}

之后,定义 teardown(),删除测试数据,并确认查询 id = 1 的结果为 FALSE

public function teardown()
{
  $result = $this->visitorOps->removeById(1);
  $result = $this->visitorOps->findById(1);
  $this->assertEquals(FALSE, $result);
  unset($this->visitorOps);
}

首先测试的是findAll()。首先,确认结果的数据类型。你可以用current()取最上面的元素。我们确认有五个元素,其中有一个是name,并且其值与测试数据中的值相同。

public function testFindAll()
{
  $result = $this->visitorOps->findAll();
  $this->assertInstanceOf(Generator::class, $result);
  $top = $result->current();
  $this->assertCount(5, $top);
  $this->assertArrayHasKey('name', $top);
  $this->assertEquals($this->testData['name'], $top['name']);

下一个测试是针对findById()。它与testFindAll()几乎相同。

public function testFindById()
{
  $result = $this->visitorOps->findById(1);
  $this->assertCount(5, $result);
  $this->assertArrayHasKey('name', $result);
  $this->assertEquals($this->testData['name'], $result['name']);
}

你不需要费心去测试 removeById(),因为这已经在 teardown()中完成了。同样,也不需要测试runSql(),因为这已经作为其他测试的一部分完成了。

使用MOCK类

首先,定义一个 VisitorService 服务类,如本小节步骤 2 和 3 所述。接下来,定义一个VisitorOpsMock模拟类,这将在步骤4至7中讨论。

您现在可以为服务类开发一个测试,即 VisitorServiceTest。请注意,您需要提供您自己的数据库配置,因为最好的做法是使用测试数据库而不是生产版本。

<?php
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/VisitorService.php';
require_once __DIR__ . '/VisitorOpsMock.php';

class VisitorServiceTest extends TestCase
{
  protected $visitorService;
  protected $dbConfig = [
    'driver'   => 'mysql',
    'host'     => 'localhost',
    'dbname'   => 'php7cookbook_test',
    'user'     => 'cook',
    'password' => 'book',
    'errmode'  => PDO::ERRMODE_EXCEPTION,
  ];
}

setup()中,创建一个服务的实例,并在原类的位置插入VisitorOpsMock

public function setup()
{
  $this->visitorService = new VisitorService($this->dbConfig);
  $this->visitorService->setVisitorOps(new VisitorOpsMock());
}
public function teardown()
{
  unset($this->visitorService);
}

在我们的测试中,从访问者列表中产生一个HTML表格,然后你可以寻找某些元素,提前知道会发生什么,因为你可以控制测试数据。

public function testShowAllVisitors()
{
  $result = $this->visitorService->showAllVisitors();
  $this->assertRegExp('!^<table>.+</table>$!', $result);
  $testData = $this->visitorService->getVisitorOps()->getTestData();
  foreach ($testData as $key => $value) {
    $dataWeWant = '!<td>' . $key . '</td>!';
    $this->assertRegExp($dataWeWant, $result);
  }
}
}

然后,你可能会希望尝试最后两个小节中建议的变化,使用匿名类作为Mock对象,以及使用Mock Builder。

更多...

其他断言测试对数字、字符串、数组、对象、文件、JSON和XML的操作,如下表所示。

关于单元测试的精彩讨论,请看这里:https://en.wikipedia.org/wiki/Unit_testing。

关于 composer.json 文件指令的更多信息,请看 https://getcomposer.org/doc/04-schema.md。

关于完整的断言列表,请看一下PHPUnit文档页:https://phpunit.de/manual/current/en/phpunit-book.html#appendixes.assertions。

PHPUnit文档还在这里详细介绍了如何使用getMockBuilder():https://phpunit.de/manual/current/en/phpunit-book.html#test-doubles.mock-objects。

最后更新于