Behat — это PHP-фреймворк для Behavior-Driven Development (BDD), который использует человекочитаемый синтаксис Gherkin для описания поведения приложения. Он устраняет разрыв между бизнес-требованиями и автоматизированными тестами, делая спецификации исполняемыми. В этом руководстве рассматривается эффективное внедрение BDD с точки зрения senior-разработчика.
Зачем BDD с Behat
Behat даёт существенные преимущества:
- Живая документация: тесты служат актуальными спецификациями
- Коммуникация со стейкхолдерами: нетехнические специалисты могут читать и писать сценарии
- Тестирование от бизнеса: фокус на поведении, а не на реализации
- Тестирование API и UI: тестируйте на любом уровне вашего приложения
- Интеграция с PHP: нативный PHP, работает с Laravel, Symfony и другими
Установка и настройка
Установка через Composer
composer require --dev behat/behat
composer require --dev guzzlehttp/guzzle # Для тестирования API
composer require --dev behat/mink behat/mink-extension # Для тестирования в браузере
Инициализация проекта
vendor/bin/behat --init
Это создаст:
features/ — каталог для feature-файловfeatures/bootstrap/ — каталог для context-классовfeatures/bootstrap/FeatureContext.php — context по умолчанию
Конфигурация
Создайте behat.yml:
default:
suites:
default:
contexts:
- FeatureContext
- ApiContext
- WebContext
extensions:
Behat\MinkExtension:
base_url: http://localhost:8000
sessions:
default:
goutte: ~
Синтаксис Gherkin
Feature-файлы
Создайте features/user-registration.feature:
Feature: User Registration
As a visitor
I want to create an account
So that I can access member features
Background:
Given the system is running
And no user exists with email "[email protected]"
Scenario: Successful registration with valid data
Given I am on the registration page
When I fill in "name" with "John Doe"
And I fill in "email" with "[email protected]"
And I fill in "password" with "SecurePass123!"
And I fill in "password_confirmation" with "SecurePass123!"
And I press "Register"
Then I should see "Welcome, John Doe"
And I should be logged in
Scenario: Registration fails with invalid email
Given I am on the registration page
When I fill in "email" with "invalid-email"
And I press "Register"
Then I should see "Please enter a valid email address"
And I should not be logged in
Scenario Outline: Password validation
Given I am on the registration page
When I fill in "password" with "<password>"
And I press "Register"
Then I should see "<error_message>"
Examples:
| password | error_message |
| short | Password must be at least 8 chars |
| nodigits | Password must contain a number |
| 12345678 | Password must contain a letter |
Ключевые слова Gherkin
- Feature: описывает тестируемую функциональность
- Background: шаги, выполняемые перед каждым сценарием
- Scenario: конкретный тест-кейс
- Scenario Outline: шаблон для data-driven тестов
- Examples: таблица данных для Scenario Outline
- Given: предусловия (подготовка)
- When: действия (что делает пользователь)
- Then: проверки (ожидаемые результаты)
- And/But: продолжение предыдущего типа шага
Реализация определений шагов
Context-классы
Отредактируйте features/bootstrap/FeatureContext.php:
<?php
use Behat\Behat\Context\Context;
use Behat\Gherkin\Node\PyStringNode;
use Behat\Gherkin\Node\TableNode;
class FeatureContext implements Context
{
private $response;
private $currentUser;
/**
* @Given система запущена
*/
public function theSystemIsRunning()
{
// Проверьте, что приложение доступно
$client = new GuzzleHttp\Client();
$response = $client->get('http://localhost:8000/health');
if ($response->getStatusCode() !== 200) {
throw new Exception("System is not running");
}
}
/**
* @Given не существует пользователя с email :email
*/
public function noUserExistsWithEmail($email)
{
// Очистка тестовых данных
$this->deleteUserByEmail($email);
}
/**
* @When я заполняю :field значением :value
*/
public function iFillInWith($field, $value)
{
$this->formData[$field] = $value;
}
/**
* @Then я должен видеть :text
*/
public function iShouldSee($text)
{
if (strpos($this->response->getBody(), $text) === false) {
throw new Exception("Text '$text' not found in response");
}
}
/**
* @Then я должен быть авторизован
*/
public function iShouldBeLoggedIn()
{
if (!isset($this->currentUser)) {
throw new Exception("User is not logged in");
}
}
}
Context для тестирования API
Создайте features/bootstrap/ApiContext.php:
<?php
use Behat\Behat\Context\Context;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
class ApiContext implements Context
{
private Client $client;
private $response;
private array $headers = [];
private $requestBody;
public function __construct()
{
$this->client = new Client([
'base_uri' => 'http://localhost:8000',
'http_errors' => false,
]);
}
/**
* @Given я неаутентифицированный пользователь
*/
public function iAmAnUnauthenticatedUser()
{
unset($this->headers['Authorization']);
}
/**
* @Given я аутентифицирован как :email
*/
public function iAmAuthenticatedAs($email)
{
// Выполнить вход и получить токен
$response = $this->client->post('/api/login', [
'json' => [
'email' => $email,
'password' => 'password',
],
]);
$data = json_decode($response->getBody(), true);
$this->headers['Authorization'] = 'Bearer ' . $data['token'];
}
/**
* @When я отправляю :method запрос на :endpoint
*/
public function iSendARequestTo($method, $endpoint)
{
$options = ['headers' => $this->headers];
if ($this->requestBody) {
$options['json'] = $this->requestBody;
}
$this->response = $this->client->request($method, $endpoint, $options);
$this->requestBody = null;
}
/**
* @When я отправляю :method запрос на :endpoint с телом:
*/
public function iSendARequestToWithBody($method, $endpoint, PyStringNode $body)
{
$this->requestBody = json_decode($body->getRaw(), true);
$this->iSendARequestTo($method, $endpoint);
}
/**
* @Then код статуса ответа должен быть :code
*/
public function theResponseStatusCodeShouldBe($code)
{
$actual = $this->response->getStatusCode();
if ($actual !== (int) $code) {
throw new Exception("Expected status $code, got $actual");
}
}
/**
* @Then ответ должен содержать :text
*/
public function theResponseShouldContain($text)
{
$body = $this->response->getBody()->getContents();
if (strpos($body, $text) === false) {
throw new Exception("Response does not contain '$text'");
}
}
/**
* @Then ответ должен быть JSON, содержащим:
*/
public function theResponseShouldBeJsonContaining(PyStringNode $expected)
{
$actual = json_decode($this->response->getBody(), true);
$expected = json_decode($expected->getRaw(), true);
foreach ($expected as $key => $value) {
if (!isset($actual[$key]) || $actual[$key] !== $value) {
throw new Exception("JSON mismatch for key '$key'");
}
}
}
/**
* @Then ответ должен включать объявление с ID :id
*/
public function theResponseShouldIncludeAnAnnouncementWithId($id)
{
$data = json_decode($this->response->getBody(), true);
$announcements = $data['_embedded']['announcement'] ?? [];
foreach ($announcements as $announcement) {
if ($announcement['id'] == $id) {
return;
}
}
throw new Exception("Announcement with ID $id not found");
}
}
Пример API feature
Создайте features/api/announcements.feature:
Feature: Announcements API
As an API consumer
I want to manage announcements
So that I can display them to users
Scenario: List all announcements
Given I am an unauthenticated user
When I send a "GET" request to "/api/announcements"
Then the response status code should be 200
And the response should include an announcement with ID "12345"
Scenario: Create announcement requires authentication
Given I am an unauthenticated user
When I send a "POST" request to "/api/announcements" with body:
"""
{
"title": "New Feature",
"body": "We launched something amazing!"
}
"""
Then the response status code should be 401
Scenario: Authenticated user can create announcement
Given I am authenticated as "[email protected]"
When I send a "POST" request to "/api/announcements" with body:
"""
{
"title": "New Feature",
"body": "We launched something amazing!"
}
"""
Then the response status code should be 201
And the response should be JSON containing:
"""
{
"title": "New Feature"
}
"""
Web-тестирование с Mink
Web context
Создайте features/bootstrap/WebContext.php:
<?php
use Behat\MinkExtension\Context\MinkContext;
class WebContext extends MinkContext
{
/**
* @Given я вошёл в систему как :email
*/
public function iAmLoggedInAs($email)
{
$this->visit('/login');
$this->fillField('email', $email);
$this->fillField('password', 'password');
$this->pressButton('Login');
$this->assertPageContainsText('Dashboard');
}
/**
* @When я нажимаю на ссылку :link в навигации
*/
public function iClickOnTheLinkInTheNavigation($link)
{
$this->getSession()
->getPage()
->find('css', 'nav')
->clickLink($link);
}
/**
* @Then заголовок страницы должен быть :title
*/
public function thePageTitleShouldBe($title)
{
$actual = $this->getSession()
->getPage()
->find('css', 'title')
->getText();
if ($actual !== $title) {
throw new Exception("Expected title '$title', got '$actual'");
}
}
/**
* @Then я должен видеть :count элементов в списке
*/
public function iShouldSeeItemsInTheList($count)
{
$items = $this->getSession()
->getPage()
->findAll('css', '.list-item');
if (count($items) !== (int) $count) {
throw new Exception("Expected $count items, found " . count($items));
}
}
}
Web scraping с Goutte
<?php
use Behat\Behat\Context\Context;
use Goutte\Client;
class ScrapingContext implements Context
{
private Client $client;
private $crawler;
public function __construct()
{
$this->client = new Client();
}
/**
* @Given я открываю страницу :url
*/
public function iOpenThePage($url)
{
$this->crawler = $this->client->request('GET', $url);
}
/**
* @When я нажимаю ссылку :text
*/
public function iClickTheLink($text)
{
$link = $this->crawler->selectLink($text)->link();
$this->crawler = $this->client->click($link);
}
/**
* @Then заголовок страницы должен быть :heading
*/
public function thePageHeadingShouldBe($heading)
{
$actual = trim($this->crawler->filter('h1')->first()->text());
if ($actual !== $heading) {
throw new Exception("Expected h1 '$heading', got '$actual'");
}
}
}
Запуск тестов
Базовые команды
# Запустить все features
vendor/bin/behat
# Запустить конкретный feature
vendor/bin/behat features/user-registration.feature
# Запустить конкретный сценарий по номеру строки
vendor/bin/behat features/user-registration.feature:15
# Запустить с конкретным тегом
vendor/bin/behat --tags="@api"
# Dry run (проверка определений шагов без выполнения)
vendor/bin/behat --dry-run
# Сгенерировать отсутствующие определения шагов
vendor/bin/behat --append-snippets
Использование тегов
Помечайте сценарии тегами для организации:
@api @authentication
Feature: Authentication API
@smoke
Scenario: Valid login returns token
...
@slow
Scenario: Rate limiting after failed attempts
...
Запуск сценариев по тегам:
vendor/bin/behat --tags="@api"
vendor/bin/behat --tags="@smoke"
vendor/bin/behat --tags="~@slow" # Исключить медленные тесты
Интеграция с CI
GitHub Actions
name: BDD Tests
on: [push, pull_request]
jobs:
behat:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: secret
MYSQL_DATABASE: testing
ports:
- 3306:3306
steps:
- uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: pdo_mysql
- name: Install dependencies
run: composer install
- name: Start application
run: php artisan serve &
- name: Run Behat
run: vendor/bin/behat --format=progress
Ключевые выводы
- Сначала пишите сценарии: определяйте поведение до реализации
- Используйте предметный язык: сценарии должны читаться как требования
- Делайте шаги переиспользуемыми: создавайте библиотеку определений шагов
- Разделяйте ответственность: используйте разные context для API, web и database
- Стратегически используйте теги: организуйте тесты для выборочного выполнения
- Интегрируйте с CI: запускайте BDD-тесты на каждый коммит
Behat превращает спецификации в исполняемые тесты, гарантируя, что ваше приложение ведёт себя ровно так, как ожидают стейкхолдеры, и одновременно создаёт живую документацию, которая остаётся актуальной.