Перейти к содержимому

PHPUnit и модульное тестирование в 1С-Битрикс

Модульный тест проверяет один класс или функцию в изоляции: вызвал метод, сравнил результат с ожидаемым, никакого браузера и живого сайта. На обычном PHP-проекте настройка такого теста занимает пару минут. На 1С-Битрикс - нет, потому что почти любой код проекта так или иначе опирается на загруженное ядро: суперглобальные объекты, статические классы, открытое соединение с базой. Ниже - как в этих условиях писать и запускать модульные тесты через PHPUnit. Как загружают ядро в тестовом окружении и как избежать загрузки там, где без неё можно обойтись. И что делать с базой данных и платформенными классами. Соседняя статья «Тестирование и выкладка» описывает место модульных тестов среди прочих видов проверки качества и разбирает нагрузочное тестирование с миграциями структуры базы - здесь только код: как именно его тестировать.

Как это работает

Код на Битриксе редко существует сам по себе. Чтобы выполнить одну строчку с CIBlockElement или Bitrix\Main\Loader, до неё должен отработать пролог. Это подключение конфигурации, регистрация модулей, соединение с базой и запуск сессии. Легаси-классы вдобавок держат состояние в статических свойствах, а часть D7-классов рассчитывает, что Application уже поднят. Нельзя просто подключить файл класса и создать объект, как в фреймворк-независимом PHP-проекте - интерпретатору нужно окружение, которое обычно создаёт веб-сервер на каждый хит.

Ядро в тестах подключают тем же способом, что и в консольных скриптах. Официально Битрикс описывает не тестирование через PHPUnit, а более общую задачу. Она звучит как подключение ядра вне обычного хита, для cron- и консольных скриптов. Способ

  • подключить /bitrix/modules/main/include/prolog_before.php самостоятельно, заранее задав $_SERVER['DOCUMENT_ROOT'] и определив константы, которые меняют поведение пролога: NOT_CHECK_PERMISSIONS отключает проверку прав, NO_KEEP_STATISTIC - сбор статистики хитов, NO_AGENT_CHECK - запуск агентов на странице. Тестовый bootstrap использует ровно этот механизм: официальной документации по PHPUnit у вендора нет, но подключение ядра - тот же приём, что и для любого другого скрипта вне браузера.

PHPUnit и ядро конфликтуют на уровне глобальных переменных. По умолчанию PHPUnit сохраняет значения суперглобальных массивов перед тестом и восстанавливает после - это защищает тесты друг от друга. Ядро Битрикса активно пишет в суперглобальные переменные уже на этапе подключения, и это поведение PHPUnit конфликтует с прологом вплоть до фатальной ошибки. Решение, которое годами используется в практике сообщества - выключить это поведение свойством $backupGlobals = false в базовом классе теста, до первого обращения к прологу.

Раз загрузка ядра тяжёлая, отделение своей логики от платформы - не стиль оформления, а способ иметь быстрые тесты. Каждый тест с подключённым ядром открывает сессию и соединение с базой. Десятки файлов подключаются ещё до первого assert. Практический вывод прямой. Код, принимающий решения, держат в обычных PHP- классах без единого обращения к Bitrix\* и C*-классам. Это расчёты, валидация, форматирование и бизнес-правила. Вызовы платформенного API выносят за интерфейс в тонкий класс-адаптер. Тестов первого рода получается много, и они выполняются без bootstrap за миллисекунды. Тестов второго рода - мало, и они дороже, но проверяют именно стык с платформой, а не бизнес-логику.

Тестовую базу чаще обходятся без отдельного экземпляра - открывают транзакцию и откатывают её после теста. Полноценная отдельная база - это копия схемы ядра в несколько сотен таблиц плюс её обслуживание в CI. Для большинства проектов дешевле и быстрее взять существующую dev-базу. Каждый тест при этом оборачивают в startTransaction() в начале и rollbackTransaction() в конце. Изменения не переживают тест, а поднимать отдельную инфраструктуру не нужно. Отдельную тестовую базу заводят в двух случаях. Первый - тесты должны выполняться параллельно, а транзакции в один поток соединения без конфликтов не распараллелить. Второй - тестируемый код сам управляет транзакциями, и тогда откат теста спорит с внутренним коммитом кода.

Заглушки платформенных классов работают через интерфейс адаптера, а не через статический вызов напрямую - и здесь же кроется главный риск. PHPUnit создаёт стабы и моки для объектов, доступных через внедрение зависимости. А статический вызов вроде CIBlockElement::GetList() подменить нечем. Поэтому заглушку ставят не вместо самого легаси-класса, а вместо своего адаптера, реализующего простой интерфейс. Пока интерфейс тонкий («дай цену», «дай статус активности») - заглушка помогает и не требует ухода. Заглушка может начать воспроизводить внутреннюю логику Битрикса: разбирать формат фильтра, эмулировать поведение GetList при разных параметрах. Это уже поддержка собственной теневой копии платформы. При каждом обновлении она расходится с оригиналом и даёт ложную уверенность в зелёных тестах.

У платформы есть собственная методология тестирования, но не инструмент для неё. В официальном курсе о процессе разработки Битрикс называет модульные тесты одним из четырёх видов проверки качества. Рекомендует их для технически сложного кастомного функционала, низкоуровневых утилит и ООП-кода в модулях. Из инструментов курс упоминает «PHPUnit, SimpleTest или написать свой попроще». Ни один из них вместе с платформой не поставляется. Единственное, что Битрикс действительно поставляет сам, - Монитор качества, набор проверок окружения перед сдачей проекта: настройки безопасности, кеширования, производительности и хостинга. Он проверяет готовность системы, а не поведение конкретного класса при конкретных входных данных. Модульные тесты он не заменяет, а стоит рядом с ними. Отсюда и путаница, и то, что каждый проект настраивает PHPUnit заново, а не переиспользует штатное.

Примеры

1. Настройка запуска: composer и phpunit.xml

PHPUnit подключают как dev-зависимость. В настройке указывают bootstrap-файл, каталог с тестами и файлы с тестируемым кодом через PSR-4. От модульной автозагрузки самого Битрикса это не зависит: «чистые» тесты подключают классы без участия ядра:

{
"require-dev": {
"phpunit/phpunit": "^12.0"
},
"autoload-dev": {
"psr-4": {
"Vendor\\Pricing\\": "local/modules/vendor.pricing/lib/",
"Vendor\\Catalog\\": "local/modules/vendor.catalog/lib/",
"Local\\Tests\\": "local/tests/"
}
}
}
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
<testsuites>
<testsuite name="unit">
<directory>local/tests</directory>
</testsuite>
</testsuites>
</phpunit>

Bootstrap в phpunit.xml указывает только на автозагрузчик Composer, а не на ядро Битрикса. Ядро подключают точечно, внутри setUp() тех тестов, которым оно действительно нужно (пример 3). Итоговая раскладка каталогов:

project-root/
├── bitrix/
├── local/
│ ├── modules/
│ │ ├── vendor.pricing/lib/DiscountCalculator.php
│ │ └── vendor.catalog/lib/
│ │ ├── ElementGatewayInterface.php
│ │ ├── BitrixElementGateway.php
│ │ ├── OrderItemValidator.php
│ │ └── BrandTable.php
│ └── tests/
│ ├── BitrixTestCase.php
│ ├── DiscountCalculatorTest.php
│ ├── BitrixElementGatewayTest.php
│ ├── OrderItemValidatorTest.php
│ └── BrandTableTest.php
├── composer.json
└── phpunit.xml

Установка и запуск:

Окно терминала
composer require --dev phpunit/phpunit
vendor/bin/phpunit
PHPUnit 12.x by Sebastian Bergmann and contributors.
Runtime: PHP 8.3
Configuration: project-root/phpunit.xml
.......... 10 / 10 (100%)
Time: 00:01.284, Memory: 12.00 MB
OK (10 tests, 14 assertions)

Дальше по одному тесту на каждый из показанных файлов - от самого дешёвого к самому дорогому.

2. Тест чистой логики - без ядра

Расчёт скидки не знает о существовании Битрикса вообще: обычный класс без единого обращения к Bitrix\*.

<?php declare(strict_types=1);
namespace Vendor\Pricing;
class DiscountCalculator
{
public function applyPercent(int $priceKopecks, float $percent): int
{
if ($percent < 0 || $percent > 100) {
throw new \InvalidArgumentException('Percent must be between 0 and 100');
}
return (int) round($priceKopecks * (100 - $percent) / 100);
}
}
<?php declare(strict_types=1);
namespace Local\Tests;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Vendor\Pricing\DiscountCalculator;
final class DiscountCalculatorTest extends TestCase
{
#[DataProvider('percentProvider')]
public function testAppliesPercent(int $price, float $percent, int $expected): void
{
$calculator = new DiscountCalculator();
$this->assertSame($expected, $calculator->applyPercent($price, $percent));
}
public static function percentProvider(): array
{
return [
'no discount' => [10000, 0, 10000],
'ten percent' => [10000, 10, 9000],
'rounds down' => [999, 33, 669],
];
}
public function testRejectsPercentAboveHundred(): void
{
$this->expectException(\InvalidArgumentException::class);
(new DiscountCalculator())->applyPercent(1000, 101);
}
}
.... 4 / 4 (100%)
OK (4 tests, 4 assertions)

Класс TestCase подключён напрямую из PHPUnit, без промежуточного базового класса с ядром. Для такого теста прологу попросту нечего делать. Четыре теста из примера выполняются за миллисекунды и не зависят ни от базы, ни от состояния сайта.

3. Тест кода, зависящего от ядра

Базовый класс подключает ядро один раз на процесс и лениво. Ядро поднимается, только когда тест в нём реально нуждается, а не на каждый прогон PHPUnit:

<?php declare(strict_types=1);
namespace Local\Tests;
use PHPUnit\Framework\TestCase;
abstract class BitrixTestCase extends TestCase
{
// Обязательно: иначе подключение пролога Битрикса
// конфликтует со штатным поведением PHPUnit.
protected $backupGlobals = false;
protected function setUp(): void
{
parent::setUp();
if (!defined('B_PROLOG_INCLUDED')) {
$_SERVER['DOCUMENT_ROOT'] = dirname(__DIR__, 2);
define('NOT_CHECK_PERMISSIONS', true);
define('NO_KEEP_STATISTIC', true);
define('NO_AGENT_CHECK', true);
require $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php';
}
}
}

Класс-адаптер, реально обращающийся к D7 Iblock API, и тест на нём:

<?php declare(strict_types=1);
namespace Vendor\Catalog;
class BitrixElementGateway implements ElementGatewayInterface
{
public function isActive(int $elementId): bool
{
$row = \Bitrix\Iblock\ElementTable::getList([
'select' => ['ID', 'ACTIVE'],
'filter' => ['=ID' => $elementId],
])->fetch();
return $row !== false && $row['ACTIVE'] === 'Y';
}
}
<?php declare(strict_types=1);
namespace Local\Tests;
use Bitrix\Main\Loader;
use Vendor\Catalog\BitrixElementGateway;
final class BitrixElementGatewayTest extends BitrixTestCase
{
public function testReturnsFalseForMissingElement(): void
{
Loader::includeModule('iblock');
$gateway = new BitrixElementGateway();
// 999999999 - заведомо несуществующий ID, отдельная фикстура не нужна
$this->assertFalse($gateway->isActive(999999999));
}
}
. 1 / 1 (100%)
Time: 00:00.912, Memory: 18.00 MB
OK (1 test, 1 assertion)

Секунда против миллисекунд из примера 2 - это цена поднятого ядра. В неё входят сессия, соединение с базой и регистрация модулей. ElementGatewayInterface из этого примера ещё пригодится в примере 5.

4. Работа с базой: транзакция с откатом

Здесь тот же BitrixTestCase. Методы setUp() и tearDown() дополнительно открывают и откатывают транзакцию вокруг каждого теста:

<?php declare(strict_types=1);
namespace Local\Tests;
use Bitrix\Main\Application;
use Bitrix\Main\Loader;
use Vendor\Catalog\BrandTable;
final class BrandTableTest extends BitrixTestCase
{
protected function setUp(): void
{
parent::setUp();
Loader::includeModule('vendor.catalog');
Application::getConnection()->startTransaction();
}
protected function tearDown(): void
{
Application::getConnection()->rollbackTransaction();
parent::tearDown();
}
public function testAddCreatesBrandWithGivenName(): void
{
$result = BrandTable::add(['NAME' => 'Test brand']);
$this->assertTrue($result->isSuccess());
$row = BrandTable::getByPrimary($result->getId())->fetch();
$this->assertSame('Test brand', $row['NAME']);
}
}
. 1 / 1 (100%)
Time: 00:01.050, Memory: 18.50 MB
OK (1 test, 2 assertions)

После теста строка в vendor_catalog_brand не остаётся: rollbackTransaction() отменяет INSERT, сделанный внутри BrandTable::add(). Автоинкрементный ID при этом не откатывается - это особенность InnoDB, а не повод считать откат ненадёжным: счётчик просто уходит вперёд, а строк лишних не остаётся. Таблицы должны быть на InnoDB - на MyISAM транзакций не существует, и rollbackTransaction() там молча ничего не отменяет.

5. Заглушка платформенного класса через интерфейс

Бизнес-логика зависит от ElementGatewayInterface из примера 3, а не от BitrixElementGateway напрямую. Поэтому в тесте её подменяют стабом и ядро не подключают вовсе:

<?php declare(strict_types=1);
namespace Vendor\Catalog;
class OrderItemValidator
{
public function __construct(private ElementGatewayInterface $gateway)
{
}
public function assertCanOrder(int $elementId): void
{
if (!$this->gateway->isActive($elementId)) {
throw new \DomainException('Element is not active');
}
}
}
<?php declare(strict_types=1);
namespace Local\Tests;
use PHPUnit\Framework\TestCase;
use Vendor\Catalog\ElementGatewayInterface;
use Vendor\Catalog\OrderItemValidator;
final class OrderItemValidatorTest extends TestCase
{
public function testRejectsInactiveElement(): void
{
$gateway = $this->createStub(ElementGatewayInterface::class);
$gateway->method('isActive')->willReturn(false);
$validator = new OrderItemValidator($gateway);
$this->expectException(\DomainException::class);
$validator->assertCanOrder(42);
}
public function testAcceptsActiveElement(): void
{
$gateway = $this->createStub(ElementGatewayInterface::class);
$gateway->method('isActive')->willReturn(true);
$validator = new OrderItemValidator($gateway);
$validator->assertCanOrder(42);
$this->assertTrue(true); // исключения не было - тест прошёл
}
}
.. 2 / 2 (100%)
OK (2 tests, 3 assertions)

OrderItemValidatorTest расширяет обычный TestCase, а не BitrixTestCase. Для проверки правила «нельзя заказать неактивный элемент» ядро не нужно вообще. BitrixElementGatewayTest из примера 3 при этом никуда не делся - он остаётся тем единственным местом, где адаптер проверяют против настоящего ядра, а не против придуманного поведения.

Справочник

Что тестируемСложностьКак поступают
Чистая PHP-логика: расчёты, форматирование, валидация без обращений к BitrixЛегкоОбычный TestCase, без bootstrap ядра
Класс-адаптер вокруг платформенного вызова (интерфейс плюс одна реализация)СреднеBootstrap ядра, немного тестов на реальных вызовах
Бизнес-логика, получающая данные от платформы через интерфейс адаптераЛегкоСтаб или мок интерфейса (createStub/createMock), без ядра
Прямые статические вызовы C*-классов внутри бизнес-логикиТрудноНе тестируют напрямую - сначала выносят за интерфейс
Запись и чтение через D7 ORM (DataManager, *Table)СреднеBootstrap ядра плюс транзакция с откатом
Обработчики событий (EventManager, AddEventHandler)ТрудноЛогику выносят в отдельный тестируемый метод, событие эмулируют вызовом напрямую
Компоненты и их шаблоныПочти не тестируется юнит-тестамиФункциональные тесты - тема статьи «Тестирование и выкладка»
Агенты (CAgent)Трудно из-за побочных эффектов и расписанияТестируют вызываемую функцию отдельно от постановки в агенты
Типовая функциональность самой платформы (готовые модули каталога, инфоблоков)Не тестируютУже проверена вендором - тестируют только свой код поверх неё

Частые ошибки

Fatal error при подключении ядра прямо в тестах. Симптом - тест падает ещё на этапе bootstrap, до первого assert. Причина - не выставлен $backupGlobals = false в базовом классе: PHPUnit по умолчанию сохраняет и восстанавливает суперглобальные переменные между тестами, а подключение пролога Битрикса на этом фоне валится в фатальную ошибку.

Тесты падают только вместе, а по одному - зелёные. Симптом - отдельный файл теста проходит, а полный прогон - нет, причём падает каждый раз другой тест. Причина - утечка глобального состояния между тестами: статические свойства легаси-классов, кеш ядра или $_SESSION, выставленные одним тестом, остаются к следующему. Состояние нужно явно сбрасывать в tearDown(), а не полагаться на изоляцию.

Тест возвращает разметку формы авторизации вместо результата. Симптом - assert сравнивает ожидаемое значение со строкой вида <form...>. Причина - bootstrap не отключил проверку прав, и ядро увело выполнение в чужую ветку (редирект на страницу входа) вместо тестируемого кода.

После прогона тестов в базе остаются лишние записи. Симптом - количество строк в таблице растёт с каждым запуском, а повторный тест на создание падает из-за нарушения уникальности. Причина - тест с базой не обёрнут в транзакцию с откатом (нет rollbackTransaction() в tearDown()), либо тесты по ошибке запущены на боевой или общей dev-базе.

Заглушка проходит, а на реальном сайте тот же код падает. Симптом - юнит-тесты зелёные, а на сайте - ошибка или неверный результат в том же месте. Причина - заглушка эмулирует придуманное поведение Bitrix API, а не настоящее, и ни один тест не сверяет её с реальным ядром. Это и есть тот самый «второй Битрикс» - собственная теневая копия платформы внутри тестов, которая живёт отдельной жизнью и расходится с оригиналом при обновлениях.

Тестов больше, чем самого кода. Симптом такой: на простой шаблон компонента или тонкий result_modifier.php уходит больше строк тестов, чем функциональности. Команда тратит на поддержку тестов больше времени, чем сэкономила на ручной проверке. Причина - модульные тесты написаны для кода, где они не окупаются: типовых сценариев, тонких обёрток без собственной логики. Официальная методология Битрикса прямо предупреждает об этом риске при описании модульных тестов.

Частые вопросы

Обязательно ли подключать ядро Битрикса в каждом тесте?

Нет, и это главный практический вывод темы. Ядро нужно только тем тестам, что реально вызывают платформенный API - напрямую или через адаптер. Основную часть логики выгоднее держать в обычных PHP-классах без обращения к Bitrix и тестировать их без bootstrap: такие тесты выполняются за миллисекунды и не зависят ни от базы, ни от состояния сайта.

Как протестировать код, который вызывает статический метод вроде CIBlockElement::GetList?

Напрямую - никак: PHPUnit создаёт стабы и моки для объектов, доступных через внедрение зависимости, а не для статических вызовов. Практика - вынести вызов в собственный класс-адаптер за интерфейсом и передавать в бизнес-логику именно интерфейс. Сам адаптер проверяют отдельным, более редким тестом с настоящим подключённым ядром, а логику поверх него - стабом интерфейса.

Нужна ли для тестов отдельная база данных?

Не обязательно. Практичный вариант для большинства проектов на Битрикс - тесты на существующей dev-базе, где каждый тест открывает транзакцию в setUp и откатывает её в tearDown: изменения не остаются, а поднимать копию нескольких сотен таблиц ядра не нужно. Отдельную базу заводят, когда тесты должны выполняться параллельно или когда тестируемый код сам управляет транзакциями - тогда откат теста и внутренний коммит кода конфликтуют.

Можно ли обойтись штатным Монитором качества вместо модульных тестов?

Нет, это разные инструменты. Монитор качества проверяет готовность окружения перед сдачей - настройки безопасности, кеширования, производительности, хостинга - и не выполняет код проекта построчно. Модульный тест проверяет конкретный результат конкретного класса при конкретных входных данных. Платформа называет оба вида проверки в своей методологии, но framework для модульного тестирования не поставляет.

Какую версию PHPUnit ставить на существующий проект?

Смотрите фактическую версию PHP на сервере, а не берите последнюю версию PHPUnit по умолчанию: каждый мажорный релиз PHPUnit требует свою минимальную версию PHP, и на менее свежем PHP актуальная версия просто не установится через composer. Таблица соответствия версий PHPUnit и PHP есть на официальном сайте PHPUnit, в разделе поддерживаемых версий.

Связанные темы

Первоисточники

Подключение ядра Битрикса внутри bootstrap PHPUnit и свойство $backupGlobals = false нигде не описаны вендором как официальная рекомендация - это практика сообщества, сложившаяся за годы использования PHPUnit на проектах Битрикс: