集成测试与功能测试
真实测试组件如何协同工作
集成测试与功能测试 是 CoddyKit 上的免费 PHP Academy 课时。 这是第 3 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 PHP Academy 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 PHP Academy 课程共包含 4 节课。
超越单元测试
单元测试证明一个类在所有依赖都使用模拟对象的情况下可以独立工作。但模拟对象可能造成误导——真实的数据库、框架路由器或 HTTP 层的行为可能不同。集成测试验证两个或更多真实组件连接后的协作;功能测试通过应用的公共入口点(HTTP 请求、控制台命令)端到端地运行应用。本课将介绍这两类测试的定位并分别编写它们。
测试金字塔
健康的测试套件应呈金字塔形:
- 大量单元测试——运行快速、彼此隔离、持续执行。
- 较少的集成测试——使用真实数据库、队列和缓存,运行较慢。
- 少量功能测试/端到端测试——覆盖完整技术栈,运行最慢,也最脆弱。
如果反过来(大多数是端到端测试,形成“冰激凌甜筒”形),就会得到一个缓慢且不稳定的测试套件。只要可能,就尽量在更底层完成覆盖。
集成测试:仓储对接真实数据库
针对仓储的集成测试使用真实的数据库连接,而不是模拟对象,以捕获 SQL、架构和映射方面的错误。请使用专用的测试数据库(或者使用与目标数据库方言足够接近的内存 SQLite)以及真实的 PDO。
<?php
use PHPUnit\Framework\TestCase;
final class UserRepositoryTest extends TestCase
{
private \PDO $pdo;
protected function setUp(): void
{
$this->pdo = new \PDO('sqlite::memory:');
$this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
$this->pdo->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT)');
}
public function test_persists_and_finds_a_user(): void
{
$repo = new UserRepository($this->pdo);
$id = $repo->create('ada@example.com');
self::assertSame('ada@example.com', $repo->find($id)->email);
}
}
隔离:事务回滚
集成测试不得彼此泄漏状态。最快的做法是将每个测试都包装在事务中,并在 tearDown() 中回滚事务——无需重新填充种子数据,数据库就能恢复到干净状态。(当被测代码提交事务或使用 DDL 时,截断是备用方案。)
<?php
protected function setUp(): void
{
$this->pdo = TestDb::connection();
$this->pdo->beginTransaction();
}
protected function tearDown(): void
{
$this->pdo->rollBack(); // undo everything this test did
}
测试固件与工厂
测试需要数据。请避免脆弱而庞大的 SQL 转储文件;优先使用小型工厂,只创建测试所需的行,并提供可针对每个案例覆盖的合理默认值。这样可以明确表达每个测试的意图,也能更好地适应架构增长。
<?php
final class UserFactory
{
public static function create(\PDO $pdo, array $overrides = []): int
{
$data = array_merge(['email' => 'user'.uniqid().'@test.dev'], $overrides);
$stmt = $pdo->prepare('INSERT INTO users (email) VALUES (:email)');
$stmt->execute($data);
return (int) $pdo->lastInsertId();
}
}
功能测试:像客户端一样访问应用
功能测试使用模拟请求驱动真实的应用内核和路由器,并对响应进行断言——包括状态码、标头和正文。Symfony 的 WebTestCase 以及 Laravel 的 HTTP 测试辅助工具正是这样工作的,无需启动真实的 Web 服务器。下面展示 Symfony 的写法。
<?php
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class HealthControllerTest extends WebTestCase
{
public function test_health_endpoint_returns_ok(): void
{
$client = static::createClient();
$client->request('GET', '/health');
self::assertResponseIsSuccessful(); // 2xx
self::assertJson($client->getResponse()->getContent());
}
}
对响应进行断言
功能测试在边界处验证可观察的行为:状态、JSON 结构以及数据库中的副作用。下面的 POST 请求会创建一个资源;我们同时检查 HTTP 响应,以及记录是否确实写入了存储,从而证明整个技术栈协同工作。
<?php
public function test_creating_a_user_persists_it(): void
{
$client = static::createClient();
$client->request('POST', '/users', server: [
'CONTENT_TYPE' => 'application/json',
], content: json_encode(['email' => 'ada@example.com']));
self::assertResponseStatusCodeSame(201);
$repo = static::getContainer()->get(UserRepository::class);
self::assertNotNull($repo->findByEmail('ada@example.com'));
}
有选择地模拟外部边界
即使是功能测试,也不应调用真实的支付提供商或发送真实的电子邮件。只替换最外层的外部服务——在测试容器中将它们替换为伪实现或内存实现——同时保留自有代码、路由和数据库的真实性。这样既能保持端到端的可信度,又能避免不稳定的网络调用。
<?php
public function test_checkout_charges_via_gateway(): void
{
$client = static::createClient();
// Replace the real gateway binding with an in-memory fake:
static::getContainer()->set(PaymentGateway::class, new FakePaymentGateway());
$client->request('POST', '/checkout', content: json_encode(['cart' => 1]));
self::assertResponseIsSuccessful();
}
按速度拆分测试套件
将 phpunit.xml 拆分为多个测试套件,让快速的单元测试套件在每次保存时运行,而较慢的集成测试和功能测试套件按需运行或在持续集成中运行。按目录分组,并使用 --testsuite 进行选择。
<phpunit>
<testsuites>
<testsuite name="unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="integration">
<directory>tests/Integration</directory>
</testsuite>
<testsuite name="functional">
<directory>tests/Functional</directory>
</testsuite>
</testsuites>
</phpunit>确定性胜过覆盖率
让测试套件失去信任的最快方式就是不稳定。请让集成测试和功能测试具有确定性:
- 注入时钟来冻结时间,而不是调用
time()。 - 为随机数设定种子;避免对未排序查询的结果进行依赖顺序的断言。
- 在每个测试中重置数据库状态(事务回滚/截断)。
- 绝不依赖测试的执行顺序。
让测试数据库与生产环境一致
内存 SQLite 虽然速度快,但方言差异(大小写敏感性、JSON 函数、外键约束、类型)可能掩盖只有在真实数据库引擎上才会出现的错误。对于使用特定引擎 SQL 的代码,应让集成测试使用与生产环境相同的数据库引擎——持续集成中通常使用一次性的 Docker 容器——而不是方便的替代品。
<?php
// Read connection from environment so CI points at a real Postgres/MySQL container
$dsn = getenv('TEST_DATABASE_URL') ?: 'sqlite::memory:';
$pdo = new \PDO($dsn);
$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
// In CI, TEST_DATABASE_URL targets the same engine as production.
快速检查
功能测试应如何处理第三方支付提供商?
回顾
您已经学会了如何测试协同工作的组件:
- 集成测试使用真实数据库和服务来捕获模拟对象掩盖的问题;使用事务回滚将它们隔离。
- 功能测试通过模拟请求驱动真实内核,并验证响应和副作用。
- 工厂创建最少的数据;只在最外层的外部边界使用模拟实现。
- 按运行速度拆分测试套件,并让较慢的测试保持确定性。
下一步:使用变异测试衡量测试质量。
常见问题解答
「集成测试与功能测试」课时是免费的吗?
是的 — 「集成测试与功能测试」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 PHP Academy 课程的其余内容,请升级到 CoddyKit PRO。 PHP Academy 课程共包含 4 节课。
「集成测试与功能测试」这节课中我会学到什么?
真实测试组件如何协同工作 你通过在浏览器中直接运行的动手代码来练习 PHP Academy,全天候 AI 导师会在你学习这节课的过程中回答你的问题。
学习 PHP Academy 需要有经验吗?
无需任何先前经验。CoddyKit 上的 PHP Academy 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 3 节课,共 4 节。
「集成测试与功能测试」课时需要多长时间?
大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。
我能在这节 PHP Academy 课中编写并运行代码吗?
能。每节 PHP Academy 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。