PHP 单元测试:PHPUnit 入门与持续集成配置

dp32323
dp32323 正式会员正式会员
发布于 2026-10-01 07:20 ·10 浏览 ·3 回复

学完这篇,你能在自己的 PHP 项目里跑起第一个单元测试,并让 GitHub 在每次提交时自动帮你跑一遍。

第一步:装 PHPUnit

PHPUnit 走 Composer 分发,在项目根目录执行:

composer require --dev phpunit/phpunit ^11

装完验证:

vendor/bin/phpunit --version

注意:PHPUnit 版本和 PHP 版本是强绑定的。PHPUnit 11 要 PHP 8.2+,PHPUnit 10 要 PHP 8.1+,PHP 7.4 的项目只能用 PHPUnit 9.x。装最新版在旧 PHP 上会直接依赖解析失败,别硬试。

第二步:写配置文件

根目录新建 `phpunit.xml`:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true"
         cacheDirectory=".phpunit.cache">
    <testsuites>
        <testsuite name="unit">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
    <source>
        <include>
            <directory>src</directory>
        </include>
    </source>
</phpunit>

`tests` 是测试代码目录,`src` 是被测代码目录,按你的实际结构改。`<source>` 是 PHPUnit 10 之后的新写法,老教程里的 `<filter><whitelist>` 已经废弃。

第三步:写第一个测试

假设有个 `src/Cart.php`,测试文件放 `tests/CartTest.php`:

<?php
declare(strict_types=1);

use PHPUnit\Framework\TestCase;
use App\Cart;

final class CartTest extends TestCase
{
    public function testEmptyTotalIsZero(): void
    {
        $this->assertSame(0, (new Cart())->total());
    }
}

跑一下:

vendor/bin/phpunit

文件命名必须 `*Test.php`,类必须继承 `TestCase`,方法名以 `test` 开头(或加 `#[Test]` 属性)。

常用断言:`assertSame`(类型和值都相等,优先用这个)、`assertEquals`、`assertTrue`、`assertNull`、`assertCount`、`expectException`。

第四步:参数化与替身

同一段逻辑要测多组数据,用数据提供器,别复制粘贴:

#[DataProvider('priceProvider')]
public function testTotal(array $prices, int $expected): void
{
    $cart = new Cart();
    foreach ($prices as $p) {
        $cart->add($p);
    }
    $this->assertSame($expected, $cart->total());
}

public static function priceProvider(): array
{
    return [
        '单件' => [[100], 100],
        '多件' => [[100, 250], 350],
    ];
}

注意:PHPUnit 10 之后数据提供器必须是 `public static`,写成普通方法会直接报错——这是从老版本迁移时最常见的一处改动。

要隔离数据库、HTTP 这类外部依赖,用 `createMock()` 造替身,断言调用行为而不是真实副作用:

$mailer = $this->createMock(Mailer::class);
$mailer->expects($this->once())->method('send');

第五步:看覆盖率

vendor/bin/phpunit --coverage-text

注意:覆盖率依赖 Xdebug 或 PCOV 扩展,没装的话 PHPUnit 只会给一行警告,不给数字。别以为是自己配置写错了。

覆盖率是参考值不是 KPI,追 100% 通常换来一堆没意义的断言。核心业务逻辑(金额计算、权限判断、状态流转)优先覆盖。

第六步:接入持续集成

在项目根目录建 `.github/workflows/tests.yml`:

name: tests
on: [push, pull_request]

jobs:
  phpunit:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        php: ['8.1', '8.2', '8.3']
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          coverage: xdebug
      - run: composer install --prefer-dist --no-progress
      - run: vendor/bin/phpunit --coverage-text

提交后到仓库的 Actions 页就能看到结果,失败会标红并把断言差异打印出来。

几个要点:

  • 矩阵里列几个 PHP 版本,就相当于同时验证几个环境,避免"我本地能跑"。
  • 测试要连 MySQL 的话,用 job 级 `services:` 起一个 mysql 容器,再把连接参数通过环境变量传进去。
  • `composer.lock` 必须提交,CI 里才能装出与本地一致的依赖版本;`vendor/` 则要写进 `.gitignore`。

注意:CI 是无状态环境。任何依赖本地文件、本地数据库、绝对路径的测试都会挂,写测试时就要把这些东西做成可注入的配置。

用 GitLab 的话思路一样,`.gitlab-ci.yml` 里换成 `image: php:8.3`,加 `before_script: composer install` 即可。

小结

  • 用 `composer require --dev phpunit/phpunit` 安装,PHPUnit 版本必须匹配 PHP 版本。
  • `phpunit.xml` 里配 `testsuites` 和 `source`,后者是新版写法。
  • 测试类继承 `TestCase`,断言优先用 `assertSame`。
  • 数据提供器必须 `public static`,外部依赖用 `createMock()` 隔离。
  • 覆盖率需要 Xdebug/PCOV;CI 用 GitHub Actions 矩阵跑多 PHP 版本,`composer.lock` 要提交。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-663.html
转载请注明出处,版权归原作者所有。
他们都看过 1 人浏览过
CLARA轻量论坛系统

全部回复 3

陈先生
陈先生 正式会员正式会员认证极客认证极客 1楼 2026-10-01 07:24

内容挺扎实的,补两个新手最容易卡住的地方:autoload 没配和CI 没写。这两块不通,本地能跑、推到 GitHub 照样红。

先说 autoload。`composer require --dev phpunit/phpunit` 只会装依赖,不会帮你建 `App\` 的映射。如果 `src/Cart.php` 里写的是 `namespace App;`,必须先在 `composer.json` 补:

"autoload": { "psr-4": { "App\\": "src/" } }

然后 `composer dump-autoload`,否则 `vendor/bin/phpunit` 一跑就是 `Class "App\Cart" not found`——这个报错 90% 是 autoload 没配或命名空间大小写不匹配。

再补一份能直接用的 GitHub Actions:

name: tests
on: [push, pull_request]
jobs:
  phpunit:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        php: ['8.2', '8.3', '8.4']
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          coverage: xdebug
      - run: composer install --prefer-dist --no-progress
      - run: vendor/bin/phpunit --coverage-text

两点注意:一是 composer.lock 一定要提交,CI 用 `install` 而不是 `update`,否则每次跑出来的依赖版本都不一样;二是 matrix 里别塞你 composer.json 的 `require.php` 约束之外的版本,PHPUnit 11 在 8.1 上直接装不上。

最后提一句和本站相关的:Clara 核心不走 Composer,没有 `vendor/autoload.php`。如果你想给插件写测试,`bootstrap` 不能指向 autoload,得自己写个只 require 必要文件的引导脚本——这点跟常规框架项目不一样,别照搬教程。

延伸建议:测试里一旦出现数据库、文件系统、`time()`、`rand()`,就会变得不可重复。先给这些开依赖注入的口子,或把测试跑在事务里最后回滚,比事后到处打补丁省事得多。

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客 #292 2楼 2026-10-01 07:26
陈先生:内容挺扎实的,补两个新手最容易卡住的地方:**autoload 没配**和**CI 没写**。这两块不通,本地能跑、推到 GitHub 照样红。 先说 aut…

autoload 和 CI 这两刀插得准,尤其"命名空间大小写"那条——本地 Windows 能跑、Linux CI 报 `Class not found` 的经典现场就在这。

补两个 autoload 的细节:一是映射要分清位置,`src/` 里的生产代码放 `autoload`,只在测试里用的假实现/辅助类放 `autoload-dev`,别混着写;二是改完 `composer.json` 必须 `composer dump-autoload`,顺手加 `-o` 生成优化类映射,CI 上能少一次全盘扫描。

CI 那边再补三点:`coverage: pcov` 比 xdebug 快一个数量级,只要覆盖率报告就用它;matrix 加上 `fail-fast: false`,否则第一组挂了后面几组直接被取消,你只看得到一个失败;想要提速可以用 `actions/cache` 缓存 `~/.composer/cache`,key 带上 PHP 版本和 lock 的哈希,几十秒能压到几秒。

Clara 那块你说得对,它没有 `vendor/autoload.php`,bootstrap 得自己写。但也不用把核心文件全 require 进来——在 `bootstrap.php` 里用 `spl_autoload_register` 注册一个几行的 PSR-4 映射指向插件目录就够了,不碰核心、测起来也干净。

aixiu
aixiu 正式会员正式会员认证极客认证极客 #293 3楼 2026-10-01 07:34
zjlxcf:autoload 和 CI 这两刀插得准,尤其"命名空间大小写"那条——本地 Windows 能跑、Linux CI 报 `Class not found` 的…

pcov + `fail-fast: false` + 自定义 PSR-4 bootstrap,这三条我全认,尤其 pcov 那条——很多人还在用 xdebug 跑覆盖率,CI 上白白多烧几分钟。

补两个小细节:`coverage: pcov` 只在跑覆盖率时快,如果你还需要断点调试那还是得装 xdebug,两者可以按 job 分开;另外 `fail-fast: false` 配合 matrix 最好再加个 `include` 单独放一组 `experimental` 的 PHP 版本,允许失败但能提前看到信号。缓存那块 key 建议带上 `${{ hashFiles('**/composer.lock') }}` 和 matrix.php,否则 PHP 版本一换缓存串味,反而出玄学问题。哦对,`composer.lock` 必须提交进仓库,不然 CI 装出来的依赖和本地根本不是一套,本地绿了线上红就是这么来的。

Clara 那段我同意你的路子。它的插件确实是运行时钩子加载、没有 Composer 那一层,所以 bootstrap 里只注册一个指向 `content/plugins` 的 PSR-4 映射、不去 require 核心,这个边界划得很对。我自己的做法再往前走一步:既然插件是挂钩子,测试时可以直接把回调函数取出来手动调用,传一个数组当钩子载荷,断言返回值或副作用,完全不需要把整个论坛跑起来。这样测的是纯逻辑,快且不依赖数据库。

一个坑:`dump-autoload -o` 生成的是 classmap,新增类文件后必须重新生成,本地改完忘了跑、CI 上又是干净的 `-o`,就会出现"本地通、CI 找不到类"——和前面说的命名空间大小写是同一类症状,但成因完全不同,排查时容易跑偏。