集成测试
概述
集成测试(Integration Testing)验证多个模块或组件组合在一起时的行为是否正确。与单元测试(隔离测试单个组件)不同,集成测试涉及真实的外部依赖,如数据库连接、HTTP 请求、文件系统操作等。集成测试确保代码与外部系统之间的交互符合预期,是连接单元测试和端到端测试的桥梁。
前置知识
阅读本节前,建议先了解:PHPUnit 单元测试、Mock 对象 和 代码覆盖率。
基础概念
测试金字塔
╱ ╲
╱ E2E ╲ ← 少量,慢,高置信度
╱───────╲
╱ 集成测试 ╲ ← 中等数量
╱─────────────╲
╱ 单元测试 ╲ ← 大量,快,低成本
╱───────────────────╲- 单元测试:测试单个组件,使用 Mock 隔离依赖
- 集成测试:测试组件间交互,使用真实依赖
- 端到端测试:测试整个系统流程
集成测试 vs 单元测试
| 特性 | 单元测试 | 集成测试 |
|---|---|---|
| 范围 | 单个函数/方法 | 多个组件交互 |
| 依赖 | Mock/Stub | 真实依赖 |
| 速度 | 快(毫秒级) | 中(秒级) |
| 隔离性 | 完全隔离 | 部分隔离 |
| 可靠性 | 稳定 | 可能受外部因素影响 |
| 数量 | 多 | 中等 |
详细说明
HTTP 测试
Laravel HTTP 测试
php
<?php
declare(strict_types=1);
namespace Tests\Integration\Http;
use PHPUnit\Framework\TestCase;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
class ApiEndpointTest extends BaseTestCase
{
use CreatesApplication;
/**
* 测试 GET 请求
*/
public function testGetUsersEndpoint(): void
{
$response = $this->get('/api/users');
$response->assertStatus(200);
$response->assertJsonStructure([
'data' => [
'*' => [
'id',
'name',
'email',
],
],
]);
}
/**
* 测试 POST 请求
*/
public function testCreateUserEndpoint(): void
{
$response = $this->postJson('/api/users', [
'name' => 'Alice',
'email' => 'alice@example.com',
]);
$response->assertStatus(201);
$response->assertJson([
'data' => [
'name' => 'Alice',
'email' => 'alice@example.com',
],
]);
// 验证数据库中确实创建了记录
$this->assertDatabaseHas('users', [
'email' => 'alice@example.com',
]);
}
/**
* 测试验证失败
*/
public function testCreateUserValidationFails(): void
{
$response = $this->postJson('/api/users', [
'name' => '', // 空名称
'email' => 'invalid', // 无效邮箱
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['name', 'email']);
}
/**
* 测试认证
*/
public function testProtectedEndpointRequiresAuth(): void
{
$response = $this->get('/api/profile');
$response->assertStatus(401);
}
/**
* 测试分页
*/
public function testPagination(): void
{
$response = $this->getJson('/api/users?page=2&per_page=5');
$response->assertStatus(200);
$response->assertJsonStructure([
'data',
'meta' => [
'current_page',
'last_page',
'per_page',
'total',
],
]);
}
}纯 PHP HTTP 测试(无框架)
php
<?php
declare(strict_types=1);
namespace Tests\Integration\Http;
use PHPUnit\Framework\TestCase;
/**
* 纯 PHP HTTP 集成测试
* 使用 PHP 内置服务器进行测试
*/
class PureHttpIntegrationTest extends TestCase
{
private string $baseUrl;
private int $serverPid;
protected function setUp(): void
{
// 启动内置服务器
$this->serverPid = (int) exec(
'php -S 127.0.0.1:8765 -t public > /dev/null 2>&1 & echo $!'
);
// 等待服务器启动
usleep(500000);
$this->baseUrl = 'http://127.0.0.1:8765';
}
protected function tearDown(): void
{
if ($this->serverPid > 0) {
exec("kill {$this->serverPid} 2>/dev/null");
}
}
public function testHomePage(): void
{
$response = file_get_contents($this->baseUrl);
$this->assertIsString($response);
$this->assertStringContainsString('Welcome', $response);
}
public function testApiEndpoint(): void
{
$context = stream_context_create([
'http' => [
'method' => 'GET',
'header' => "Content-Type: application/json\r\n",
],
]);
$response = file_get_contents(
$this->baseUrl . '/api/status',
false,
$context
);
$this->assertIsString($response);
$data = json_decode($response, true);
$this->assertNotNull($data);
$this->assertArrayHasKey('status', $data);
}
public function testPostEndpoint(): void
{
$data = json_encode(['name' => 'Test']);
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\n",
'content' => $data,
],
]);
$response = file_get_contents(
$this->baseUrl . '/api/create',
false,
$context
);
$this->assertIsString($response);
$result = json_decode($response, true);
$this->assertSame(201, $result['code'] ?? 0);
}
}数据库测试
测试数据库配置
php
<?php
declare(strict_types=1);
namespace Tests\Integration\Database;
use PHPUnit\Framework\TestCase;
use PDO;
/**
* 数据库集成测试基类
* 提供测试数据库的创建和清理
*/
abstract class DatabaseTestCase extends TestCase
{
protected PDO $pdo;
protected function setUp(): void
{
parent::setUp();
// 连接测试数据库
$this->pdo = new PDO(
'mysql:host=127.0.0.1;dbname=test_db;charset=utf8mb4',
'root',
'',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
// 启用事务(测试后回滚)
$this->pdo->beginTransaction();
}
protected function tearDown(): void
{
// 回滚事务(清理测试数据)
$this->pdo->rollBack();
parent::tearDown();
}
/**
* 插入测试数据
*/
protected function insertTestData(string $table, array $data): int
{
$columns = implode(', ', array_keys($data));
$placeholders = implode(', ', array_fill(0, count($data), '?'));
$stmt = $this->pdo->prepare(
"INSERT INTO {$table} ({$columns}) VALUES ({$placeholders})"
);
$stmt->execute(array_values($data));
return (int) $this->pdo->lastInsertId();
}
}数据库仓库测试
php
<?php
declare(strict_types=1);
namespace Tests\Integration\Database;
use App\Repositories\UserRepository;
use App\Models\User;
class UserRepositoryTest extends DatabaseTestCase
{
private UserRepository $repository;
protected function setUp(): void
{
parent::setUp();
$this->repository = new UserRepository($this->pdo);
}
public function testFindById(): void
{
$id = $this->insertTestData('users', [
'name' => 'Alice',
'email' => 'alice@test.com',
]);
$user = $this->repository->find($id);
$this->assertNotNull($user);
$this->assertSame('Alice', $user->name);
$this->assertSame('alice@test.com', $user->email);
}
public function testFindByEmail(): void
{
$this->insertTestData('users', [
'name' => 'Bob',
'email' => 'bob@test.com',
]);
$user = $this->repository->findByEmail('bob@test.com');
$this->assertNotNull($user);
$this->assertSame('Bob', $user->name);
}
public function testFindNotFoundReturnsNull(): void
{
$user = $this->repository->find(999999);
$this->assertNull($user);
}
public function testUpdateUser(): void
{
$id = $this->insertTestData('users', [
'name' => 'Charlie',
'email' => 'charlie@test.com',
]);
$this->repository->update($id, ['name' => 'Charles']);
$user = $this->repository->find($id);
$this->assertSame('Charles', $user->name);
}
public function testDeleteUser(): void
{
$id = $this->insertTestData('users', [
'name' => 'Dave',
'email' => 'dave@test.com',
]);
$this->repository->delete($id);
$user = $this->repository->find($id);
$this->assertNull($user);
}
public function testPagination(): void
{
// 插入多条数据
for ($i = 1; $i <= 15; $i++) {
$this->insertTestData('users', [
'name' => "User {$i}",
'email' => "user{$i}@test.com",
]);
}
// 分页查询
$result = $this->repository->paginate(1, 10);
$this->assertCount(10, $result->items());
$this->assertSame(15, $result->total());
}
}测试数据库隔离
使用 SQLite 内存数据库
php
<?php
declare(strict_types=1);
namespace Tests\Integration\Database;
use PHPUnit\Framework\TestCase;
use PDO;
/**
* 使用 SQLite 内存数据库进行隔离测试
* 每个测试方法使用独立的数据库实例
*/
class SqliteInMemoryTest extends TestCase
{
private PDO $pdo;
protected function setUp(): void
{
// SQLite 内存数据库(每个实例独立)
$this->pdo = new PDO('sqlite::memory:');
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// 运行迁移
$this->runMigrations();
}
protected function tearDown(): void
{
// SQLite 内存数据库在连接关闭后自动销毁
unset($this->pdo);
}
private function runMigrations(): void
{
$this->pdo->exec("
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
");
$this->pdo->exec("
CREATE TABLE orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
total REAL NOT NULL,
status TEXT DEFAULT 'pending',
FOREIGN KEY (user_id) REFERENCES users(id)
)
");
}
public function testInsertAndRetrieve(): void
{
$stmt = $this->pdo->prepare("INSERT INTO users (name, email) VALUES (?, ?)");
$stmt->execute(['Alice', 'alice@test.com']);
$id = (int) $this->pdo->lastInsertId();
$stmt = $this->pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$id]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
$this->assertSame('Alice', $user['name']);
$this->assertSame('alice@test.com', $user['email']);
}
}使用 Docker 测试数据库
yaml
# docker-compose.test.yml
version: '3.8'
services:
mysql-test:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: test
MYSQL_DATABASE: test_db
ports:
- "3307:3306"
tmpfs:
- /var/lib/mysql
redis-test:
image: redis:7-alpine
ports:
- "6380:6379"bash
# 启动测试环境
docker-compose -f docker-compose.test.yml up -d
# 运行集成测试
TEST_DB_HOST=127.0.0.1 TEST_DB_PORT=3307 \
./vendor/bin/phpunit --testsuite integration
# 停止测试环境
docker-compose -f docker-compose.test.yml downDocker 测试环境
完整测试 Dockerfile
dockerfile
# Dockerfile.test
FROM php:8.1-fpm-alpine
# 安装依赖
RUN apk add --no-cache \
$PHPIZE_DEPS \
mysql-client \
linux-headers
# 安装 PHP 扩展
RUN docker-php-ext-install pdo_mysql mysqli pcntl
# 安装 Xdebug
RUN pecl install xdebug && docker-php-ext-enable xdebug
WORKDIR /app
# 复制 Composer 文件
COPY composer.json composer.lock ./
RUN composer install --no-interaction --no-progress
# 复制源代码
COPY . .
# 运行测试
CMD ["./vendor/bin/phpunit", "--coverage-text"]yaml
# docker-compose.test.yml(完整版)
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile.test
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_started
environment:
DB_HOST: mysql
DB_PORT: 3306
DB_DATABASE: test_db
DB_USERNAME: root
DB_PASSWORD: test
REDIS_HOST: redis
REDIS_PORT: 6379
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: test
MYSQL_DATABASE: test_db
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 5s
timeout: 3s
retries: 10
redis:
image: redis:7-alpine实战示例
完整的集成测试基类
php
<?php
declare(strict_types=1);
namespace Tests\Integration;
use PHPUnit\Framework\TestCase;
abstract class IntegrationTestCase extends TestCase
{
protected function setUp(): void
{
parent::setUp();
// 确保是测试环境
if (getenv('APP_ENV') !== 'testing') {
$this->markTestSkipped('非测试环境,跳过集成测试');
}
}
/**
* 等待服务就绪
*/
protected function waitForService(
string $host,
int $port,
int $timeout = 10
): void {
$start = time();
while (time() - $start < $timeout) {
$connection = @fsockopen($host, $port, $errno, $errstr, 1);
if ($connection !== false) {
fclose($connection);
return;
}
sleep(1);
}
$this->fail("服务 {$host}:{$port} 在 {$timeout} 秒内未就绪");
}
/**
* 加载测试夹具数据
*/
protected function loadFixture(string $name): array
{
$path = __DIR__ . "/../Fixtures/{$name}.json";
if (!file_exists($path)) {
throw new \RuntimeException("夹具文件不存在: {$path}");
}
$content = file_get_contents($path);
return json_decode($content, true);
}
}注意事项
集成测试的维护成本
- 外部依赖不稳定:数据库、API 等外部服务可能导致测试不稳定
- 数据状态管理:测试之间的数据隔离需要仔细处理
- 执行速度:集成测试比单元测试慢得多
- 环境依赖:需要特定的测试环境配置
CI 中的集成测试
bash
# CI 中的测试执行顺序
# 1. 单元测试(快速,每PR必跑)
./vendor/bin/phpunit --testsuite unit
# 2. 集成测试(需要测试环境)
docker-compose -f docker-compose.test.yml up -d
docker-compose -f docker-compose.test.yml exec app \
./vendor/bin/phpunit --testsuite integration
docker-compose -f docker-compose.test.yml down最佳实践
1. 测试隔离原则
每个集成测试应该独立运行,不依赖其他测试的执行结果或留下的状态。
2. 使用事务回滚
php
<?php
// 每个测试开始时开启事务,结束时回滚
protected function setUp(): void
{
$this->pdo->beginTransaction();
}
protected function tearDown(): void
{
$this->pdo->rollBack();
}3. 超时保护
php
<?php
// 为集成测试设置超时
/**
* @group slow
*/
class SlowIntegrationTest extends TestCase
{
public function testLargeDataImport(): void
{
$this->markTestSkipped('耗时测试,手动运行');
}
}下一节
继续学习:TDD / BDD 实践