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/phpunitvendor/bin/phpunitPHPUnit 12.x by Sebastian Bergmann and contributors.
Runtime: PHP 8.3Configuration: 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, в разделе поддерживаемых версий.
Связанные темы
- Тестирование и выкладка - место модульных тестов среди прочих видов проверки качества, нагрузочное тестирование и миграции структуры базы
- Старое ядро 1С-Битрикс - глобальные
$APPLICATION,$USER,$DBи классы с префиксомC, из-за которых код завязан на состояние - Ядро D7 -
Application,Loader, события: то самое ядро, которое тесты подключают целиком или обходят через адаптер - Стандарты кода - размещение кода в
/local/и пространства имён вида «вендор.модуль», на которых строится изолируемая логика - Свой модуль - структура
lib/иinstall/: куда кладут тестируемые классы и адаптеры к платформе - Composer и автозагрузка -
autoload-devи PSR-4, которыми тестируемый код подключают в обходBitrix\Main\Loader - Раздел Инфраструктура
- Тесты для своего кода: что покрывать и как запускать - практика на проекте
Первоисточники
- Специальные константы -
NOT_CHECK_PERMISSIONS,NO_KEEP_STATISTICи другие константы подключения ядра вне обычного хита - Application::getConnection - получение соединения с базой в D7
- Connection::startTransaction и Connection::rollbackTransaction - транзакции D7 для отката тестовых данных
- Курс «Как проводить тестирование» - официальная методология, где названы модульные тесты (UnitTests)
- PHPUnit: установка
- PHPUnit: написание тестов - атрибуты, data providers, проверка исключений
- PHPUnit: тестовые дублёры - createStub, createMock
- PHPUnit: поддерживаемые версии - соответствие версий PHPUnit версиям PHP
Подключение ядра Битрикса внутри bootstrap PHPUnit и свойство $backupGlobals = false нигде не описаны вендором как официальная рекомендация - это практика
сообщества, сложившаяся за годы использования PHPUnit на проектах Битрикс:
- Запускаем PHPUnit тесты для проекта на 1С-Битрикс - разбор проблем инициализации ядра и готовый bootstrap
- phpunit-for-bitrix - пакет с базовым классом теста, включая
backupGlobals