自动化测试

在 Hyperf 里测试默认通过 phpunit 来实现,但由于 Hyperf 是一个协程框架,所以默认的 phpunit 并不能很好的工作,因此我们提供了一个 co-phpunit 脚本来进行适配,您可直接调用脚本或者使用对应的 composer 命令来运行。自动化测试没有特定的组件,但是在 Hyperf 提供的骨架包里都会有对应实现。

  1. composer require hyperf/testing
  1. "scripts": {
  2. "test": "co-phpunit -c phpunit.xml --colors=always"
  3. },

Bootstrap

Hyperf 提供了默认的 bootstrap.php 文件,它让用户在运行单元测试时,扫描并加载对应的库到内存里。

  1. <?php
  2. declare(strict_types=1);
  3. error_reporting(E_ALL);
  4. date_default_timezone_set('Asia/Shanghai');
  5. ! defined('BASE_PATH') && define('BASE_PATH', dirname(__DIR__, 1));
  6. ! defined('SWOOLE_HOOK_FLAGS') && define('SWOOLE_HOOK_FLAGS', SWOOLE_HOOK_ALL);
  7. Swoole\Runtime::enableCoroutine(true);
  8. require BASE_PATH . '/vendor/autoload.php';
  9. Hyperf\Di\ClassLoader::init();
  10. $container = require BASE_PATH . '/config/container.php';
  11. $container->get(Hyperf\Contract\ApplicationInterface::class);

运行单元测试

  1. composer test

模拟 HTTP 请求

在开发接口时,我们通常需要一段自动化测试脚本来保证我们提供的接口按预期在运行,Hyperf 框架下提供了 Hyperf\Testing\Client 类,可以让您在不启动 Server 的情况下,模拟 HTTP 服务的请求:

  1. <?php
  2. use Hyperf\Testing\Client;
  3. $client = make(Client::class);
  4. $result = $client->get('/');

因为 Hyperf 支持多端口配置,除了验证默认的端口接口外,如果验证其他端口的接口呢?

  1. <?php
  2. use Hyperf\Testing\Client;
  3. $client = make(Client::class, ['server' => 'adminHttp']);
  4. $result = $client->json('/user/0',[
  5. 'nickname' => 'Hyperf'
  6. ]);

默认情况下,框架使用 JsonPacker,会直接解析 Bodyarray,如果您直接返回 string,则需要设置对应 Packer

  1. <?php
  2. use Hyperf\Testing\Client;
  3. use Hyperf\Contract\PackerInterface;
  4. $client = make(Client::class, [
  5. 'packer' => new class() implements PackerInterface {
  6. public function pack($data): string
  7. {
  8. return $data;
  9. }
  10. public function unpack(string $data)
  11. {
  12. return $data;
  13. }
  14. },
  15. ]);
  16. $result = $client->json('/user/0',[
  17. 'nickname' => 'Hyperf'
  18. ]);

使用 Cookies

  1. <?php
  2. use Hyperf\Testing\Client;
  3. use Hyperf\Utils\Codec\Json;
  4. $client = make(Client::class);
  5. $response = $client->sendRequest($client->initRequest('POST', '/request')->withCookieParams([
  6. 'X-CODE' => $id = uniqid(),
  7. ]));
  8. $data = Json::decode((string) $response->getBody());

示例

让我们写个小 DEMO 来测试一下。

  1. <?php
  2. declare(strict_types=1);
  3. namespace HyperfTest\Cases;
  4. use Hyperf\Testing\Client;
  5. use PHPUnit\Framework\TestCase;
  6. /**
  7. * @internal
  8. * @coversNothing
  9. */
  10. class ExampleTest extends TestCase
  11. {
  12. protected Client $client;
  13. public function __construct($name = null, array $data = [], $dataName = '')
  14. {
  15. parent::__construct($name, $data, $dataName);
  16. $this->client = make(Client::class);
  17. }
  18. public function testExample()
  19. {
  20. $this->assertTrue(true);
  21. $res = $this->client->get('/');
  22. $this->assertSame(0, $res['code']);
  23. $this->assertSame('Hello Hyperf.', $res['data']['message']);
  24. $this->assertSame('GET', $res['data']['method']);
  25. $this->assertSame('Hyperf', $res['data']['user']);
  26. $res = $this->client->get('/', ['user' => 'developer']);
  27. $this->assertSame(0, $res['code']);
  28. $this->assertSame('developer', $res['data']['user']);
  29. $res = $this->client->post('/', [
  30. 'user' => 'developer',
  31. ]);
  32. $this->assertSame('Hello Hyperf.', $res['data']['message']);
  33. $this->assertSame('POST', $res['data']['method']);
  34. $this->assertSame('developer', $res['data']['user']);
  35. $res = $this->client->json('/', [
  36. 'user' => 'developer',
  37. ]);
  38. $this->assertSame('Hello Hyperf.', $res['data']['message']);
  39. $this->assertSame('POST', $res['data']['method']);
  40. $this->assertSame('developer', $res['data']['user']);
  41. $res = $this->client->file('/', ['name' => 'file', 'file' => BASE_PATH . '/README.md']);
  42. $this->assertSame('Hello Hyperf.', $res['data']['message']);
  43. $this->assertSame('POST', $res['data']['method']);
  44. $this->assertSame('README.md', $res['data']['file']);
  45. }
  46. }

调试代码

在 FPM 场景下,我们通常改完代码,然后打开浏览器访问对应接口,所以我们通常会需要两个函数 dddump,但 Hyperf 跑在 CLI 模式下,就算提供了这两个函数,也需要在 CLI 中重启 Server,然后再到浏览器中调用对应接口查看结果。这样其实并没有简化流程,反而更麻烦了。

接下来,我来介绍如何通过配合 testing,来快速调试代码,顺便完成单元测试。

假设我们在 UserDao 中实现了一个查询用户信息的函数

  1. namespace App\Service\Dao;
  2. use App\Constants\ErrorCode;
  3. use App\Exception\BusinessException;
  4. use App\Model\User;
  5. class UserDao extends Dao
  6. {
  7. /**
  8. * @param $id
  9. * @param bool $throw
  10. * @return
  11. */
  12. public function first($id, $throw = true)
  13. {
  14. $model = User::query()->find($id);
  15. if ($throw && empty($model)) {
  16. throw new BusinessException(ErrorCode::USRE_NOT_EXIST);
  17. }
  18. return $model;
  19. }
  20. }

那我们编写对应的单元测试

  1. namespace HyperfTest\Cases;
  2. use HyperfTest\HttpTestCase;
  3. use App\Service\Dao\UserDao;
  4. /**
  5. * @internal
  6. * @coversNothing
  7. */
  8. class UserTest extends HttpTestCase
  9. {
  10. public function testUserDaoFirst()
  11. {
  12. $model = \Hyperf\Utils\ApplicationContext::getContainer()->get(UserDao::class)->first(1);
  13. var_dump($model);
  14. $this->assertSame(1, $model->id);
  15. }
  16. }

然后执行我们的单测

  1. composer test -- --filter=testUserDaoFirst

测试替身

Gerard MeszarosMeszaros2007 中介绍了测试替身的概念:

有时候对 被测系统(SUT) 进行测试是很困难的,因为它依赖于其他无法在测试环境中使用的组件。这有可能是因为这些组件不可用,它们不会返回测试所需要的结果,或者执行它们会有不良副作用。在其他情况下,我们的测试策略要求对被测系统的内部行为有更多控制或更多可见性。

如果在编写测试时无法使用(或选择不使用)实际的依赖组件(DOC),可以用测试替身来代替。测试替身不需要和真正的依赖组件有完全一样的的行为方式;他只需要提供和真正的组件同样的 API 即可,这样被测系统就会以为它是真正的组件!

下面展示分别通过构造函数注入依赖、通过 @Inject 注释注入依赖的测试替身

构造函数注入依赖的测试替身

  1. <?php
  2. namespace App\Logic;
  3. use App\Api\DemoApi;
  4. class DemoLogic
  5. {
  6. private DemoApi $demoApi;
  7. public function __construct(DemoApi $demoApi)
  8. {
  9. $this->demoApi = $demoApi;
  10. }
  11. public function test()
  12. {
  13. $result = $this->demoApi->test();
  14. return $result;
  15. }
  16. }
  1. <?php
  2. namespace App\Api;
  3. class DemoApi
  4. {
  5. public function test()
  6. {
  7. return [
  8. 'status' => 1
  9. ];
  10. }
  11. }
  1. <?php
  2. namespace HyperfTest\Cases;
  3. use App\Api\DemoApi;
  4. use App\Logic\DemoLogic;
  5. use Hyperf\Di\Container;
  6. use HyperfTest\HttpTestCase;
  7. use Mockery;
  8. class DemoLogicTest extends HttpTestCase
  9. {
  10. public function tearDown(): void
  11. {
  12. Mockery::close();
  13. }
  14. public function testIndex()
  15. {
  16. $res = $this->getContainer()->get(DemoLogic::class)->test();
  17. $this->assertEquals(1, $res['status']);
  18. }
  19. /**
  20. * @return Container
  21. */
  22. protected function getContainer()
  23. {
  24. $container = Mockery::mock(Container::class);
  25. $apiStub = $this->createMock(DemoApi::class);
  26. $apiStub->method('test')->willReturn([
  27. 'status' => 1,
  28. ]);
  29. $container->shouldReceive('get')->with(DemoLogic::class)->andReturn(new DemoLogic($apiStub));
  30. return $container;
  31. }
  32. }

通过 Inject 注释注入依赖的测试替身

  1. <?php
  2. namespace App\Logic;
  3. use App\Api\DemoApi;
  4. use Hyperf\Di\Annotation\Inject;
  5. class DemoLogic
  6. {
  7. #[Inject]
  8. private DemoApi $demoApi;
  9. public function test()
  10. {
  11. $result = $this->demoApi->test();
  12. return $result;
  13. }
  14. }
  1. <?php
  2. namespace App\Api;
  3. class DemoApi
  4. {
  5. public function test()
  6. {
  7. return [
  8. 'status' => 1
  9. ];
  10. }
  11. }
  1. <?php
  2. namespace HyperfTest\Cases;
  3. use App\Api\DemoApi;
  4. use App\Logic\DemoLogic;
  5. use Hyperf\Di\Container;
  6. use Hyperf\Utils\ApplicationContext;
  7. use HyperfTest\HttpTestCase;
  8. use Mockery;
  9. class DemoLogicTest extends HttpTestCase
  10. {
  11. /**
  12. * @after
  13. */
  14. public function tearDownAfterMethod()
  15. {
  16. Mockery::close();
  17. }
  18. public function testIndex()
  19. {
  20. $this->getContainer();
  21. $res = $this->getContainer()->get(DemoLogic::class)->test();
  22. $this->assertEquals(11, $res['status']);
  23. }
  24. /**
  25. * @return Container
  26. */
  27. protected function getContainer()
  28. {
  29. $container = ApplicationContext::getContainer();
  30. $apiStub = $this->createMock(DemoApi::class);
  31. $apiStub->method('test')->willReturn([
  32. 'status' => 11
  33. ]);
  34. $container->getDefinitionSource()->addDefinition(DemoApi::class, function () use ($apiStub) {
  35. return $apiStub;
  36. });
  37. return $container;
  38. }
  39. }

单元测试覆盖率

使用 phpdbg 生成单元测试覆盖率

修改 phpunit.xml 文件内容为如下

  1. <?xml version="1.0" encoding="UTF-8"?>
  2. <phpunit backupGlobals="false"
  3. backupStaticAttributes="false"
  4. bootstrap="./test/bootstrap.php"
  5. colors="true"
  6. convertErrorsToExceptions="true"
  7. convertNoticesToExceptions="true"
  8. convertWarningsToExceptions="true"
  9. processIsolation="false"
  10. stopOnFailure="false">
  11. <php>
  12. <!-- other PHP.ini or environment variables -->
  13. <ini name="memory_limit" value="-1" />
  14. </php>
  15. <testsuites>
  16. <testsuite name="Tests">
  17. // 需要执行单测的测试案例目录
  18. <directory suffix="Test.php">./test</directory>
  19. </testsuite>
  20. </testsuites>
  21. <coverage includeUncoveredFiles="true"
  22. processUncoveredFiles="true"
  23. pathCoverage="false"
  24. ignoreDeprecatedCodeUnits="true"
  25. disableCodeCoverageIgnore="false">
  26. <include>
  27. // 需要统计单元测试覆盖率的文件
  28. <directory suffix=".php">./app</directory>
  29. </include>
  30. <exclude>
  31. // 生产单元测试覆盖率时,需要忽略的文件
  32. <directory suffix=".php">./app/excludeFile</directory>
  33. </exclude>
  34. <report>
  35. <html outputDirectory="test/cover/" lowUpperBound="50" highLowerBound="90"/>
  36. </report>
  37. </coverage>
  38. <logging>
  39. <junit outputFile="test/junit.xml"/>
  40. </logging>
  41. </phpunit>

执行以下命令

  1. phpdbg -dmemory_limit=1024M -qrr ./vendor/bin/co-phpunit -c phpunit.xml --colors=always