Behat 场景编写与步骤定义
解读
在国内 PHP 面试中,Behat 通常被视为“行为驱动开发(BDD)落地”的试金石。面试官不关心你是否会写“Hello World”,而是关心:
- 能否把产品经理的“口语化需求”翻译成可执行的 Gherkin 场景;
- 步骤定义是否兼顾可维护性、可复用性与性能;
- 是否知道在 Laravel/Symfony 项目中如何与数据库、HTTP 客户端、队列、缓存等基础设施打通;
- 是否具备“失败场景”与“回归场景”的嗅觉,能一次性覆盖正向、逆向、边界路径。
因此,回答时要体现“业务语言→技术语言→自动化脚本”的完整闭环,并主动提及国内常用的持续集成平台(如阿里云效、腾讯云 CODING、GitLab-CI 自建)如何收集 Behat 报告,才能拿到加分。
知识点
- Gherkin 语法:Feature、Scenario、Given/When/Then、Scenario Outline、Examples、Background、Tag;
- 步骤定义最佳实践:正则捕获组、转换器(Transform)、步骤复用、PageObject 模式;
- 上下文(Context)拆分:FeatureContext、WebContext、ApiContext、DatabaseContext,避免“上帝类”;
- 环境隔离:behat.yml 多 profile(dev、ci、prod)、环境变量、Docker Compose 一键起服务;
- 数据初始化:Doctrine Fixture、Laravel Model Factory、Faker 中文本地化(zh_CN);
- 断言策略:PHPUnit 断言、Webmozart Assert、自定义正交断言;
- 失败重试与截图:FailAid、ChromeDriver 无头模式、阿里云 OSS 上传截图;
- 性能与并发:Symfony Panther 复用浏览器实例、并行场景(Panther + Facebook WebDriver);
- 报告与度量:Allure、Cucumber JSON、Junit XML,与钉钉/飞书机器人联动;
- 合规与安全:敏感数据脱敏、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
拓展思考
- 高并发场景下,Behat 本身不是压力测试工具,但可通过“场景切片 + 消息队列”方式验证最终一致性:在场景后置钩子中消费完所有队列,再断言库存与订单。
- 国内小程序、H5、RN 多端入口,需要把“用户”抽象为“客户端”维度,利用 Tag 区分 @wx @alipay @app,在上下文里切换不同 JWT 解析逻辑。
- 步骤定义出现“中文正则”时,务必开启 UTF-8 修饰符,避免安卓机号码、emoji 昵称捕获失败。
- 若公司采用领域驱动设计(DDD),可将聚合根快照(Snapshot)序列化后存入“场景数据袋”,实现跨场景状态共享,减少重复 Given。
- 安全测试场景:用 Behat 构造恶意 payload(SQL 注入、XSS、越权),结合 OWASP Top10 断言响应体,输出安全基线报告,直接交付给等保测评团队,可显著提升技术影响力。