Skip to content

集成测试

概述

集成测试(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 down

Docker 测试环境

完整测试 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);
    }
}

注意事项

集成测试的维护成本

  1. 外部依赖不稳定:数据库、API 等外部服务可能导致测试不稳定
  2. 数据状态管理:测试之间的数据隔离需要仔细处理
  3. 执行速度:集成测试比单元测试慢得多
  4. 环境依赖:需要特定的测试环境配置

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 实践

参考链接