Behat 场景编写与步骤定义

解读

在国内 PHP 面试中,Behat 通常被视为“行为驱动开发(BDD)落地”的试金石。面试官不关心你是否会写“Hello World”,而是关心:

  1. 能否把产品经理的“口语化需求”翻译成可执行的 Gherkin 场景;
  2. 步骤定义是否兼顾可维护性、可复用性与性能;
  3. 是否知道在 Laravel/Symfony 项目中如何与数据库、HTTP 客户端、队列、缓存等基础设施打通;
  4. 是否具备“失败场景”与“回归场景”的嗅觉,能一次性覆盖正向、逆向、边界路径。

因此,回答时要体现“业务语言→技术语言→自动化脚本”的完整闭环,并主动提及国内常用的持续集成平台(如阿里云效、腾讯云 CODING、GitLab-CI 自建)如何收集 Behat 报告,才能拿到加分。

知识点

  1. Gherkin 语法:Feature、Scenario、Given/When/Then、Scenario Outline、Examples、Background、Tag;
  2. 步骤定义最佳实践:正则捕获组、转换器(Transform)、步骤复用、PageObject 模式;
  3. 上下文(Context)拆分:FeatureContext、WebContext、ApiContext、DatabaseContext,避免“上帝类”;
  4. 环境隔离:behat.yml 多 profile(dev、ci、prod)、环境变量、Docker Compose 一键起服务;
  5. 数据初始化:Doctrine Fixture、Laravel Model Factory、Faker 中文本地化(zh_CN);
  6. 断言策略:PHPUnit 断言、Webmozart Assert、自定义正交断言;
  7. 失败重试与截图:FailAid、ChromeDriver 无头模式、阿里云 OSS 上传截图;
  8. 性能与并发:Symfony Panther 复用浏览器实例、并行场景(Panther + Facebook WebDriver);
  9. 报告与度量:Allure、Cucumber JSON、Junit XML,与钉钉/飞书机器人联动;
  10. 合规与安全:敏感数据脱敏、SQL 注入场景覆盖、GDPR/《个人信息保护法》合规校验。

答案

以下给出一个“国内电商秒杀”真实面试题示例,从场景到步骤定义逐层展开,可直接在 Laravel 9 + PHP 8.1 环境运行。

Gherkin 场景(features/seckill.feature)

@seckill @critical
Feature: 限时秒杀下单
  保证高并发下库存不超卖,且用户不会重复下单

  Background:
    Given 商品“iPhone14”库存为 100 件
    And 秒杀活动“618”已上线,持续 10 分钟

  @smoke
  Scenario: 用户首次秒杀成功
    When 用户“13800138000”请求秒杀 1 件“iPhone14”
    Then 响应状态码应为 201
    And 订单中心应存在 1 条待支付订单
    And 商品“iPhone14”剩余库存应为 99 件

  @inventory
  Scenario Outline: 并发秒杀库存校验
    When 用户“<mobile>”请求秒杀 1 件“iPhone14”
    Then 响应状态码应为 <code>

    Examples:
      | mobile      | code |
      | 13800138001 | 201  |
      | 13800138002 | 201  |
      | 13800138003 | 201  |
      # … 共 150 条,CI 阶段可动态生成

  @repeat
  Scenario: 用户重复秒杀失败
    Given 用户“13800138000”已成功秒杀 1 件“iPhone14”
    When 用户“13800138000”再次请求秒杀 1 件“iPhone14”
    Then 响应状态码应为 422
    And 错误信息应包含“您已参与过活动”

步骤定义(features/bootstrap/SeckillContext.php)

<?php
declare(strict_types=1);

namespace App\Testing\Behat\Context;

use Behat\Behat\Context\Context;
use Behat\Step\Given;
use Behat\Step\When;
use Behat\Step\Then;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Assert;

class SeckillContext implements Context
{
    use RefreshDatabase;

    private array $response = [];

    /** @Given 商品“:product”库存为 :stock 件 */
    public function setProductStock(string $product, int $stock): void
    {
        DB::table('products')->updateOrInsert(
            ['name' => $product],
            ['stock' => $stock, 'created_at' => now(), 'updated_at' => now()]
        );
    }

    /** @Given 秒杀活动“:activity”已上线,持续 :minute 分钟 */
    public function createSeckillActivity(string $activity, int $minute): void
    {
        DB::table('seckill_activities')->insert([
            'name'       => $activity,
            'start_at'   => now(),
            'end_at'     => now()->addMinutes($minute),
            'created_at' => now(),
            'updated_at' => now(),
        ]);
    }

    /** @When 用户“:mobile”请求秒杀 :quantity 件“:product” */
    public function userSeckill(string $mobile, int $quantity, string $product): void
    {
        $productId = DB::table('products')->where('name', $product)->value('id');
        $this->response = $this->apiPost('/api/seckill', [
            'mobile'     => $mobile,
            'product_id' => $productId,
            'quantity'   => $quantity,
        ]);
    }

    /** @Then 响应状态码应为 :code */
    public function assertStatusCode(int $code): void
    {
        Assert::assertSame($code, $this->response['http_code']);
    }

    /** @Then 订单中心应存在 :count 条待支付订单 */
    public function assertOrderCount(int $count): void
    {
        Assert::assertSame($count, (int)DB::table('orders')->where('status', 'pending')->count());
    }

    /** @Then 商品“:product”剩余库存应为 :stock 件 */
    public function assertRemainingStock(string $product, int $stock): void
    {
        $actual = (int)DB::table('products')->where('name', $product)->value('stock');
        Assert::assertSame($stock, $actual);
    }

    /** @Then 错误信息应包含“:message” */
    public function assertErrorMessage(string $message): void
    {
        Assert::assertStringContainsString($message, $this->response['body']['message'] ?? '');
    }

    private function apiPost(string $uri, array $data): array
    {
        // 复用 Laravel 的 TestCase 客户端,自动处理 CSRF、路由模型绑定
        $test = app(\Tests\TestCase::class);
        $test->setUp();
        $response = $test->postJson($uri, $data);
        return [
            'http_code' => $response->getStatusCode(),
            'body'      => $response->json(),
        ];
    }
}

behat.yml(节选)

default:
  suites:
    seckill:
      contexts:
        - App\Testing\Behat\Context\SeckillContext
      filters:
        tags: "@seckill"
  extensions:
    FriendsOfBehat\SymfonyExtension:
      bootstrap: tests/bootstrap.php
      kernel:
        environment: testing
        debug: false

CI 集成(.gitlab-ci.yml 片段)

behat:
  stage: test
  image: registry.cn-shanghai.aliyuncs.com/company/php8.1-cli:latest
  services:
    - mysql:8.0
    - redis:7-alpine
  variables:
    DB_HOST: mysql
    REDIS_HOST: redis
  script:
    - composer install --no-interaction
    - php artisan migrate --env=testing
    - php artisan db:seed --env=testing
    - vendor/bin/behat --profile=ci --tags=@critical --format=junit --out=behat-junit.xml
  artifacts:
    reports:
      junit: behat-junit.xml
    expire_in: 7 days
  only:
    - merge_requests
    - main

拓展思考

  1. 高并发场景下,Behat 本身不是压力测试工具,但可通过“场景切片 + 消息队列”方式验证最终一致性:在场景后置钩子中消费完所有队列,再断言库存与订单。
  2. 国内小程序、H5、RN 多端入口,需要把“用户”抽象为“客户端”维度,利用 Tag 区分 @wx @alipay @app,在上下文里切换不同 JWT 解析逻辑。
  3. 步骤定义出现“中文正则”时,务必开启 UTF-8 修饰符,避免安卓机号码、emoji 昵称捕获失败。
  4. 若公司采用领域驱动设计(DDD),可将聚合根快照(Snapshot)序列化后存入“场景数据袋”,实现跨场景状态共享,减少重复 Given。
  5. 安全测试场景:用 Behat 构造恶意 payload(SQL 注入、XSS、越权),结合 OWASP Top10 断言响应体,输出安全基线报告,直接交付给等保测评团队,可显著提升技术影响力。