【问题标题】:How to write a PHP unit test for a method that uses live database data?如何为使用实时数据库数据的方法编写 PHP 单元测试?
【发布时间】:2018-07-31 10:32:06
【问题描述】:

究竟如何为使用实时数据库的方法编写测试?

考虑这段代码:

class PricingRepository extends GenericRepository
{
    public function getOptionPrice(int $productId, int $quantity, float $productPrice = 0.0): float
    {
        //retrieves option record for a given product
        $row = $this->getMySql()->paramQuery("
            select * from pricing
            where product_id = ?", array(
            $productId
        ))->getSingleArray();

        //based on pricing type computes appropriate value
        if ($row['pricing_type'] === 'Quantity-based')
            return $row['base'] + $row['amount_per_quantity'] * $quantity;
        if ($row['pricing_type'] === 'Percentage-based')
            return $productPrice * $row['percentage'];

        throw new \InvalidArgumentException("invalid pricing type detected");
    }
}

我有很多类似上面的方法,所以我想确保我的测试是可靠的,并且不会随着数据库数据的变化而改变。我正在寻找针对一流单元测试方法的建议/解决方案,并且可能不依赖于数据库中数据的更改。

我现在可以编写一个简单的单元测试的方式可能是这样的:

use PHPUnit\Framework\TestCase;
class OptionPricingTest extends TestCase
{    
    function setUp()
    {
        $this->pricingRepository = new PricingRepository();
    }

    function testOptionPricing()
    { 
        $actual_option_price = $this->pricingRepository->getOptionPrice(111, 1, 100);
        $this->assertEquals(10.0, $actual_option_price);
    }
}

但如果数据或定价类型发生变化,我的测试也必须改变。

【问题讨论】:

    标签: php unit-testing phpunit php-7


    【解决方案1】:

    我是测试的初学者,所以这个答案可能不是(非常)有用,甚至可能是错误的。如果是这样,请纠正我,这样我可以从中学到一些东西:)

    我首先想到的是用已知参数将条目插入pricing 表(将插入行的id 存储到$insertedRowID),然后获取使用:

    $actual_option_price = $this->pricingRepository->getOptionPrice($insertedRowID, 1, 100);
    

    然后像现在一样进行比较,但这样您就可以确定定价的类型和其他相关值。对所有可能的(已知)场景重复相同的操作,以便验证所有案例都按预期工作。并且在测试之后从数据库中删除条目(使用创建时存储的 id)。

    我看到这种方法的问题是,在测试期间(在添加新行之后和删除之前),可能会抛出异常或发生其他错误,这会导致在不会被删除的数据库条目中。如果是这种情况,我想更好的方法是以允许您使用模拟或间谍“模拟”数据库查询方法并始终返回您希望接收的值的方式重写代码(并且根据需要进行尽可能多的测试,以涵盖可能来自数据库的所有变化)。

    我还不习惯使用模拟和间谍到我可以很好地解释它们的程度,所以希望有人能提供更多经验并阐明这个主题。

    【讨论】:

      【解决方案2】:

      存储库的设计使其难以测试。

      使用依赖注入

      考虑不要在存储库类中创建数据库连接,而是通过构造函数注入它。

      interface DBInterface
      {
          public function paramQuery($query, array $params = []): DBInterface;
          public function getSingleArray(): array;
          // ...
      }
      
      class GenericRepository
      {
          /** @var DBInterface */
          private $mysql;
      
          public function __construct(DBInterface $mysql)
          {
              $this->mysql = $mysql;
          }
      
          protected function getMySql(): DBInterface
          {
              return $this->mysql;
          }
      
          // ...
      }
      

      那么注入模拟对象就很容易了。

      模拟依赖

      对于上面的测试用例,模拟可能如下所示:

      class MysqlMock implements DBInterface
      {
          private $resultSet = [];
          private $currentQuery;
          private $currentParams;
      
          public function paramQuery($query, array $params = []): DBInterface
          {
              $this->currentId = array_shift($params);
          }
      
          public function getSingleArray(): array
          {
              return $this->resultSet[$this->currentId];
          }
      
          public function setResultSet($array records)
          {
              $this->resultSet = $records;
          }
      
          // ...
      }
      

      这样,您就可以独立于价格的实际变化和产品的移除。如果您的数据的结构发生变化,您只需更改测试即可。

      use PHPUnit\Framework\TestCase;
      class OptionPricingTest extends TestCase
      {    
          private $pricingRepository;
          private $mysqlMock;
      
          public function setUp()
          {
              $this->mysqlMock         = new MysqlMock;
              $this->pricingRepository = new PricingRepository($this->mysqlMock);
          }
      
          public function testOptionPricing()
          { 
              $this->mysqlMock->setResultSet([
                  111 => [
                      'pricing_type'        => 'Quantity-based',
                      'base'                => 6,
                      'amount_per_quantity' => 4,
                  ]
              ]);
      
              $actual_option_price = $this->pricingRepository->getOptionPrice(111, 1, 100);
              $this->assertEquals(10.0, $actual_option_price);
          }
      }
      

      【讨论】:

        【解决方案3】:

        使用https://github.com/phpspec/prophecy 可以模拟一个类,而无需编写自己的模拟类。

        例如,如果我在使用真实数据库的测试中从这一行开始,我使用 DI 将 $mysql 注入到我的存储库类中。

        $this->pricingRepository = new PricingRepository($mysql);
        

        我可以像这样模拟$mysql,使用phpspec/prophecy:

        $sql = "
                select * from pricing
                where product_id = ?";
        $data = [...]; // as returned by the database
        $result = $this->prophesize(MySqlResult::class);
        $mysql = $this->prophesize(MySql::class);
        $mysql->paramQuery($sql, 111)->willReturn($result);
        
        $this->pricingRepository = new PricingRepository($mysql->reveal());
        

        确切的模拟取决于您的 $mysql 类的详细信息。您基本上构建了一个 $mysql 的模拟(包括任何支持类,例如在我的例子中的 MySqlResult,告诉它如何针对您的特定用例表现。模拟框架完成其余的工作。

        我在这里测试的是我的PricingRepository 类,它调用数据,并对数据进行一些计算。我测试这些计算,而我的模拟提供数据。实际上,我不是在测试数据或数据库,而是专门测试我的 PricingRepository 类。如果实时数据库中的数据发生变化,它不会改变我的测试。

        【讨论】:

        • 您也可以使用 PhpUnit 自己的 MockBuilder,它非常复杂。但是模拟框架往往会将您的注意力引向实现细节(在您的示例中,查询 - 测试依赖于表名和某种格式)。在您的示例中,更改 fx. SQL 查询中的换行符会破坏测试,尽管查询实际上不会改变。编写自己的模拟可以让您拥有更多控制权。
        • 很有趣,谢谢!我喜欢你的MysqlMock 实现,我明白你的意思。两种方式都有好的方面,即模拟构建器具有标准 API,而使用您自己的模拟,您可以拥有更多控制权,而且还有更多适合不同模具的“工作”编写类。例如,如果 SQL 查询依赖于两个参数,例如 $productId 和 $productOption,这将使 MysqlMock 无效,并且需要不同的实现。即$this->currentId需要重新设计以保存更多关键参数,例如对[111, 'X']、[111, 'Y']、[222, 'X']、[222, 'Y']
        • 没错。您可以根据您的确切需求定制您的模拟。对于其他测试,您可能希望使用具有更多参数的模拟。然而,这根本不需要改变这个测试和模拟(如果你需要改变它,你很可能有一个回归,这是你通常想要避免的)。
        • 好吧,如果每次测试运行的 product_ids 都不同,那么我不需要更改模拟。我有一个案例,我有相同的 product_id,但不同的 product_option,即使 product_id 相同,它也会返回不同的记录。因此,我需要一个能够为相同的 product_id 返回不同结果集的模拟。上面的那个不是为它设置的。但是,或者.. 我可以为我想要测试的 product_option 找到一组不同的数据,其中一个具有不同的 product_id,因此我可以按原样重用模拟。
        • 那 real id 并不重要。 id 仅存在于特定测试中。
        猜你喜欢
        • 1970-01-01
        • 2010-11-16
        • 1970-01-01
        • 2013-11-01
        • 1970-01-01
        • 2019-06-28
        • 2011-09-30
        • 2016-02-11
        • 2019-06-12
        相关资源
        最近更新 更多