Вступление: зачем вообще это нужно
«Легаси, легаси никогда не меняется. Когда-то мой код станет чьим-то легаси, и мне нужно постараться, чтобы следующему разработчику (или мне в будущем) не было мучительно больно его поддерживать.»
Знакомо? Если нет — поздравляю, вы либо:
- 🎮 только что начали играть в «Симулятор программиста» и ещё не дошли до уровня «Наследие»;
- 🎬 смотрите только комедии, где код работает с первого раза;
- 🧙♂️ маг, и ваши
if-elseifсами превращаются в чистую архитектуру.
Для всех остальных — добро пожаловать в квест «Рефакторинг без паники».
Почему мы здесь?
Представьте: код — это как старый автомобиль. Сначала он едет, потом начинает скрипеть, потом дымить, а потом вы стоите на обочине с гаечным ключом и думаете: «А почему я не поменял масло, когда был шанс?»
Так и с кодом. Когда он остаётся в зачаточном состоянии и начинает обрастать фичами, как снежный ком, наступает момент, когда его перестают поддерживать. Причины классические:
| Причина | Перевод на человеческий |
|---|---|
| ⏰ Нехватка времени | «Срочно нужно фичу, потом допилю» |
| 😴 Нет желания | «Работает — и ладно, потом перепишу» |
| 🤷 Нет знаний | «А как это вообще тестируется?» |
Всё это ведёт к закисанию, стагнации и тому моменту,
когда даже git blame показывает на вас, но вы уже не помните, что там написали.
Наш «босс»: модуль editDocs
Для примера возьмём модуль, который уже стал легендой в экосистеме EvolutionCMS CE — editDocs.
Почему он?
| Критерий | Почему это важно |
|---|---|
| Польза | Массовый импорт/экспорт, редактирование полей и TV любой вложенности — то, за что любят модуль |
| Популярность | У модуля есть пользователи, а значит, изменения должны быть безопасными |
| Документация | Красивая документация с примерами — редкость для легаси |
| Видео-урок | Наглядный гайд по использованию — чтобы даже новичок понял |
Но есть и «но»:
ajax.phpна 100+ строк сif-elseif, который читается как древний свиток;- Гуляющий $_POST без проверок;
- Тестов нет — а значит, любой рефакторинг — это игра в «русскую рулетку».
Начнём: ajax.php — свиток, который нужно уметь читать
«Код, который работает — это хорошо. Код, который работает и понятен — это искусство.»
Если ajax.php — это свиток, то читать его нужно с факелом в одной руке и огнетушителем в другой. 🔥🧯
Давайте разберём этот «древний манускрипт» и найдём точки, где рефакторинг принесёт максимальную пользу с минимальными рисками.
🔍 Что не так с этим кодом?
Взглянем на ajax.php как на детективную историю. У нас есть:
| Герой | Проблема | Последствия |
|---|---|---|
$_POST |
Гуляет без валидации | 🔓 Уязвимости, баги, «магия» |
$modx->db->query() |
Прямая подстановка данных | 💉 SQL-инъекции |
$obj->massMove() |
Нет проверки результата | 🎭 «Успех», даже когда всё сломалось |
🚨 Проблема 1: $_POST без валидации
if (!empty($_POST['parent1']) && !empty($_POST['parent2'])) {
echo $obj->massMove();
}
Почему это опасно, даже в админке?
- Админка ≠ безопасная зона. Если злоумышленник получит доступ к сессии администратора (через XSS, утечку куки, MITM), он сможет отправлять любые
$_POST-данные. - Человеческий фактор. Даже честный админ может случайно отправить
parent1=abcилиparent2=-1. Код должен быть устойчив к ошибкам. - Отладка и логирование. Без валидации сложно понять, почему что-то сломалось. «Пришло что-то не то» — плохая диагностика.
«Золотой слиток» — как застраховать рефакторинг легаси
«Прежде чем менять код, нужно понять, что он делает. Не то, что он должен делать. А то, что он фактически делает.»
Если рефакторинг — это ремонт старого дома, то характеризационные тесты (Characterization Tests) — это фотофиксация каждой трещинки в стене до начала работ. Не для того чтобы сохранить трещины, а чтобы потом не гадать: «Это я так задумал или случайно сломал?».
Что такое «Золотой слиток» (Golden Master)?
Представьте: у вас есть чёрный ящик. Вы кладёте внутрь $_POST['parent1'] = 10, $_POST['parent2'] = 20 — и ящик выдаёт строку 'Успешное массовое перемещение'.
Вы не знаете, как он это делает. Но вы фиксируете: «При таких входных данных — такой вывод». Это и есть Золотой слиток — эталон текущего поведения системы.
📦 Чёрный ящик (легаси)
│
▼
🔢 Вход: $_POST = ['parent1' => '10', 'parent2' => '20']
│
▼
🎁 Выход: 'Успешное массовое перемещение'
│
▼
📝 Фиксируем: "10+20 → Успех" ← Это наш Золотой слиток
| Без Золотого слитка | С Золотым слитком |
|---|---|
| ❌ Рефакторинг = гадание на кофейной гуще | ✅ Рефакторинг = контролируемый эксперимент |
| ❌ «Работало — перестало. Почему?» | ✅ «Тест упал — значит, поведение изменилось. Это задумано?» |
| ❌ Страх менять код | ✅ Уверенность: если тесты зелёные — всё ок |
Установка инструментов
В assets/modules/editdocs/libs/composer.json добавляем PHPUnit:
{
"require": {
"phpoffice/phpspreadsheet": "1.30.0"
},
"require-dev": {
"phpunit/phpunit": "^9.0"
},
"autoload": {
"psr-4": {
"EditDocs\\": "../src/"
}}
}
}
Затем в терминале:
cd assets/modules/editdocs/libs/
composer update --dev
✅ Готово: у нас есть vendor/bin/phpunit и автозагрузка для наших классов.
Пишем характеризационный тест
Файл: tests/Legacy/EditDocsMassMoveBaseCharacterizationTest.php
<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;
class MassMoveObjStub
{
public array $lang = ['notall' => 'Заполните оба поля'];
public function massMove(): string
{
return 'Успешное массовое перемещение';
}
}
class EditDocsMassMoveBaseCharacterizationTest extends TestCase
{
private array $originalPost = [];
protected function setUp(): void
{
$this->originalPost = $_POST;
}
protected function tearDown(): void
{
$_POST = $this->originalPost;
}
/**
* @dataProvider postCasesProvider
*/
public function testCharacterizeMassMoveBehavior(array $postData, string $expectedOutput): void
{
$_POST = $postData;
$obj = new MassMoveObjStub();
ob_start();
// ИССЛЕДУЕМЫЙ КОД
if (!empty($_POST['parent1']) && !empty($_POST['parent2'])) {
echo $obj->massMove();
} else if (isset($_POST['parent1']) || isset($_POST['parent2'])) {
echo '<div class="alert alert-danger">' . $obj->lang['notall'] . '</div>';
}
$actualOutput = ob_get_clean();
// Фиксируем текущее поведение
$this->assertSame($expectedOutput, $actualOutput);
}
public static function postCasesProvider(): array
{
return [
'Оба параметра заполнены' => [
['parent1' => '10', 'parent2' => '20'],
'Успешное массовое перемещение'
],
'Только parent1' => [
['parent1' => '10'],
'<div class="alert alert-danger">Заполните оба поля</div>'
],
'Только parent2' => [
['parent2' => '20'],
'<div class="alert alert-danger">Заполните оба поля</div>'
],
'Оба пустые строки' => [
['parent1' => '', 'parent2' => ''],
'<div class="alert alert-danger">Заполните оба поля</div>'
],
'Оба отсутствуют' => [
[],
''
],
'Значение "0"' => [
['parent1' => '0', 'parent2' => '0'],
'<div class="alert alert-danger">Заполните оба поля</div>'
],
];
}
}
Структура проекта — от свитка к модулям
После того, как Золотой слиток зафиксирован, мы можем начинать рефакторинг. Но сначала — организуем код, чтобы он не превратился в новый «свиток».
Было: ajax.php — всё в одном файле
📄 ajax.php (100+ строк)
├── Проверки окружения
├── Подключение классов
├── 10+ обработчиков действий
└── Логика массового перемещения (в editdocs.class.php)
Стало: модульная структура
📁 assets/modules/editdocs/
├── ajax.php # ← Точка входа (тонкий слой)
├── editdocs.class.php # ← Легаси (не трогаем пока)
├── libs/
│ ├── composer.json # ← Зависимости (PHPUnit, PhpSpreadsheet)
│ └── vendor/ # ← Установленные пакеты
├── src/ # ← Наш новый код
│ ├── Http/
│ │ └── EditDocsAjaxHandler.php # ← Контроллер (маршрутизация)
│ ├── Dto/
│ │ └── EditDocsMassMoveInput.php # ← DTO для валидации входных данных
│ ├── Repository/
│ │ └── EditDocsDocumentRepository.php # ← Работа с БД
│ └── Service/
│ └── EditDocsMassMoveService.php # ← Бизнес-логика
└── tests/
├── Legacy/
│ └── EditDocsMassMoveBaseCharacterizationTest.php # ← Золотой слиток
└── Service/
└── EditDocsMassMoveServiceTest.php # ← Тесты нового кода
| Было | Стало | Выгода |
|---|---|---|
ajax.php на 100 строк |
ajax.php на 30 строк |
Читаемость |
| Логика размазана по файлам | Логика в Service/Repository |
Ответственность |
| Тесты невозможны | Тесты через PHPUnit | Безопасность изменений |
new editDocs($modx) везде |
Внедрение зависимостей | Тестируемость |
Тонкий ajax.php — входная дверь
<?php
/**
* AJAX контроллер для модуля editDocs
*/
define('MODX_API_MODE', true);
define('IN_MANAGER_MODE', true);
define('NO_TRACY', true);
include_once(__DIR__ . "/../../../index.php");
global $modx;
$modx->db->connect();
if (empty($modx->config)) {
$modx->getSettings();
}
spl_autoload_register(function ($class) {
$prefix = 'EditDocs\\';
$baseDir = __DIR__ . '/src/';
$len = strlen($prefix);
if (strncmp($prefix, $class, $len) !== 0) {
return;
}
$relativeClass = substr($class, $len);
$file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php';
if (file_exists($file)) {
require $file;
}
});
require_once(MODX_BASE_PATH . "assets/modules/editdocs/editdocs.class.php");
use EditDocs\Http\EditDocsAjaxHandler;
function validateEnvironment($modx): void { /* ... */ }
validateEnvironment($modx);
$legacyEditDocs = new editDocs($modx);
$handler = new EditDocsAjaxHandler($modx, $legacyEditDocs);
$handler->handleRequest();
Что здесь происходит и зачем?
| Блок кода | Зачем это нужно | Почему так, а не иначе |
|---|---|---|
define('MODX_API_MODE', true) |
Сообщает MODX, что мы в режиме API | Требуется ядром для корректной инициализации |
include_once(.../index.php) |
Загружает ядро MODX | Без $modx модуль не работает |
spl_autoload_register(...) |
Подключает наши классы EditDocs\... |
Composer не всегда доступен в хостингах, делаем свой простой PSR-4 |
require_once(...editdocs.class.php) |
Подключает легаси-класс | Он ещё нужен, но мы постепенно «оборачиваем» его |
validateEnvironment($modx) |
Проверяет права, сессию, AJAX | Безопасность — на входе, а не внутри бизнес-логики |
new EditDocsAjaxHandler(...) |
Создаёт контроллер с зависимостями | Внедрение зависимостей = тестируемость |
Почему автозагрузчик свой, а не Composer?
spl_autoload_register(function ($class) {
$prefix = 'EditDocs\\';
$baseDir = __DIR__ . '/src/';
// ... PSR-4 логика ...
});
Причины:
- Совместимость: Не все хостинги позволяют запускать
composer install. - Изоляция: Мы не хотим, чтобы наш автозагрузчик конфликтовал с другими модулями.
- Простота: 15 строк кода, которые делают то, что нужно — и ничего лишнего.
EditDocsAjaxHandler — диспетчер запросов
class EditDocsAjaxHandler
{
protected DocumentParser $modx;
private editDocs $editDocs;
private ResponseSenderInterface $responseSender;
public function __construct(
DocumentParser $modx,
editDocs $editDocs,
ResponseSenderInterface $responseSender = null
) {
$this->modx = $modx;
$this->editDocs = $editDocs;
$this->responseSender = $responseSender ?? new HttpHeaderResponseSender();
}
private function handleMassMove(): void
{
$input = EditDocsMassMoveInput::fromPost($_POST);
$service = $this->createMassMoveService();
echo $service->execute($input);
}
protected function createMassMoveService(): EditDocsMassMoveServiceInterface
{
$repository = new EditDocsDocumentRepository($this->modx);
$validator = new EditDocsMassMoveValidator($repository, $this->editDocs);
return new EditDocsMassMoveService($repository, $this->editDocs, $validator);
}
}
Паттерны и решения
1. Dependency Injection (Внедрение зависимостей)
public function __construct(
DocumentParser $modx,
editDocs $editDocs,
ResponseSenderInterface $responseSender = null
)
Зачем?
- ✅ Тестируемость: В тестах можно передать мок
$modxили$responseSender. - ✅ Гибкость: Если завтра появится новый способ отправки ответов — меняем реализацию, не трогая контроллер.
- ✅ Ясность: Сразу видно, от чего зависит класс.
2. Фабричный метод createMassMoveService()
protected function createMassMoveService(): EditDocsMassMoveServiceInterface
{
$repository = new EditDocsDocumentRepository($this->modx);
$validator = new EditDocsMassMoveValidator($repository, $this->editDocs);
return new EditDocsMassMoveService($repository, $this->editDocs, $validator);
}
Зачем выносить создание сервиса в отдельный метод?
| Без фабрики | С фабрикой |
|---|---|
new EditDocsMassMoveService(...) прямо в handleMassMove() |
Можно переопределить в тесте через наследование |
| Сложно замокать зависимости | Легко подменить сервис на мок |
| Нарушение принципа единой ответственности | Контроллер только использует сервис, не создаёт его |
protected-метод.
Это «бэкдор» для тестов.
DTO — EditDocsMassMoveInput и магия Value Objects
«Данные — как гости: если не проверить на входе, потом придётся убирать беспорядок во всей квартире.»
Если раньше $_POST['parent1'] мог быть чем угодно — строкой, массивом, скриптом для инъекции — то теперь он проходит через двойной фильтр: сначала Value Object, потом DTO. И только после этого попадает в бизнес-логику.
Сначала — ParentId: Value Object, который не врет
// src/ValueObject/ParentId.php
final class ParentId
{
private int $value;
private function __construct(int $value)
{
if ($value < 1) {
throw new InvalidArgumentException(
sprintf('ParentId must be a positive integer, %d given', $value)
);
}
$this->value = $value;
}
public static function fromRaw(mixed $value): self
{
$int = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
if ($int === false) {
throw new InvalidArgumentException(
sprintf('Invalid ParentId value: %s', var_export($value, true))
);
}
return new self($int);
}
public static function tryFromRaw(mixed $value): array
{
try {
return ['valid' => true, 'vo' => self::fromRaw($value), 'error' => null];
} catch (InvalidArgumentException $e) {
return ['valid' => false, 'vo' => null, 'error' => $e->getMessage()];
}
}
public function toInt(): int { return $this->value; }
public function equals(self $other): bool { return $this->value === $other->value; }
public function __toString(): string { return (string)$this->value; }
}
| Метод | Зачем | Почему так |
|---|---|---|
private __construct() |
Запретить создание «как попало» | Гарантия: ParentId нельзя создать с невалидным значением |
fromRaw() |
Создать VO с исключением | Для случаев, когда ошибка = крах (например, внутри сервиса) |
tryFromRaw() |
Создать VO с возвратом ошибки | Для случаев, когда ошибка = валидация (например, в DTO из $_POST) |
toInt() |
Получить «сырое» значение для БД | Интерфейс с внешним миром: SQL, API, логирование |
equals() |
Сравнить два ParentId |
Бизнес-логика: «источник и цель не должны совпадать» |
__toString() |
Удобный вывод в логах/отладке | Человеко-читаемое представление |
Затем — EditDocsMassMoveInput: DTO, который фильтрует вход
// src/Dto/EditDocsMassMoveInput.php
final class EditDocsMassMoveInput
{
public ParentId $sourceParent;
public ParentId $targetParent;
public ?string $error;
private function __construct() {}
public static function fromPost(array $post): self
{
$dto = new self();
$sourceResult = ParentId::tryFromRaw($post['parent1'] ?? null);
$targetResult = ParentId::tryFromRaw($post['parent2'] ?? null);
if (!$sourceResult['valid']) {
$dto->error = 'Source parent ID: ' . $sourceResult['error'];
return $dto;
}
if (!$targetResult['valid']) {
$dto->error = 'Target parent ID: ' . $targetResult['error'];
return $dto;
}
$dto->sourceParent = $sourceResult['vo'];
$dto->targetParent = $targetResult['vo'];
$dto->error = null;
return $dto;
}
public function isValid(): bool { return $this->error === null; }
}
Почему именно так?
- Приватный конструктор: Невозможно создать DTO «мимо»
fromPost. Это гарантирует, что валидация всегда происходит. tryFromRawвместоfromRaw: Мы ожидаем, что входные данные могут быть невалидными. Вместо исключения — мягкий возврат ошибки.?string $error: Единое место для сообщения об ошибке. Контроллер проверяетisValid()и решает, что делать дальше.ParentIdвнутри: Как только данные прошли валидацию, они уже не могут стать невалидными. Бизнес-логика работает с гарантированно корректными значениями.
Поток данных
📥 $_POST
│
▼
[DTO] EditDocsMassMoveInput::fromPost($_POST)
│ • tryFromRaw для parent1 и parent2
│ • Если ошибка → $dto->error !== null
│ • Если ок → $dto->sourceParent/targetParent — гарантированно валидные ParentId
▼
[Validator] EditDocsMassMoveValidator::validate($dto)
│ • Бизнес-правила: нельзя в себя, нельзя в потомка, и т.д.
│ • Если ошибка → исключение (SameParentError, TargetIsChildError...)
▼
[Service] EditDocsMassMoveService::execute($dto)
│ • Работает только с валидными ParentId через ->toInt()
│ • Не проверяет формат — только бизнес-логику
▼
📤 Готовый HTML-ответ
Сервис — EditDocsMassMoveService
class EditDocsMassMoveService implements EditDocsMassMoveServiceInterface
{
private EditDocsDocumentRepositoryInterface $repository;
private editDocs $module;
private EditDocsMassMoveValidatorInterface $validator;
public function __construct(
EditDocsDocumentRepositoryInterface $repository,
editDocs $module,
EditDocsMassMoveValidatorInterface $validator
) {
$this->repository = $repository;
$this->module = $module;
$this->validator = $validator;
}
public function execute(EditDocsMassMoveInput $input): string
{
try {
$this->validator->validate($input);
} catch (SameParentError|TargetIsChildError|NoChildrenError|TargetDoesNotExistError|ValidationError $e) {
return '' . htmlspecialchars($e->getMessage()) . '';
}
$sourceId = $input->sourceParent->toInt();
$targetId = $input->targetParent->toInt();
try {
// Бизнес-операция
$moved = $this->repository->moveChildren($sourceId, $targetId);
if (!$moved) {
return '' . $this->module->lang['error_tree'] . '';
}
// Обновление флагов
if ($this->repository->updateFolderFlag($targetId, true) && $this->repository->updateFolderFlag($sourceId, false)) {
$this->module->clearCache();
return '' . $this->module->lang['ok_move'] . '';
}
return '' . $this->module->lang['error_mass_move'] . '';
} catch (PDOException $e) {
return '' . $this->module->lang['error_database'] . '';
} catch (Throwable $e) {
return '' . $this->module->lang['error_unknown'] . '';
}
}
}
Бизнес-правила в EditDocsMassMoveValidator
class EditDocsMassMoveValidator implements EditDocsMassMoveValidatorInterface
{
public function validate(EditDocsMassMoveInput $input): void
{
if (!$input->isValid()) {
throw new ValidationError($input->error ?? 'Input validation failed');
}
$sourceId = $input->sourceParent->toInt();
$targetId = $input->targetParent->toInt();
// БИЗНЕС-ПРАВИЛО: источник и цель должны быть разными
if ($input->sourceParent->equals($input->targetParent)) {
throw new SameParentError('Source and target cannot be the same');
}
// БИЗНЕС-ПРАВИЛО: цель не должна быть потомком источника
if ($this->repository->isChildOf($targetId, $sourceId)) {
throw new TargetIsChildError('Target cannot be a child of source');
}
// БИЗНЕС-ПРАВИЛО: у источника должны быть дочерние ресурсы
if (!$this->repository->hasChildren($sourceId)) {
throw new NoChildrenError('Source has no children to move');
}
// Проверка существования целевого документа
if (!$this->repository->documentExists($targetId)) {
throw new TargetDoesNotExistError('Target document not found');
}
}
}
| Без валидатора | С валидатором |
|---|---|
| Бизнес-правила размазаны по сервису | Все правила в одном месте |
| Сложно тестировать правила отдельно | Можно тестировать validate() изолированно |
| Новые правила = правка сервиса | Новые правила = новый catch в сервисе |
Repository — два варианта, один интерфейс
interface EditDocsDocumentRepositoryInterface
{
public function moveChildren(int $fromParent, int $toParent): bool;
public function updateFolderFlag(int $docId, bool $isFolder): bool;
public function documentExists(int $docId): bool;
public function isChildOf(int $child, int $parent): bool;
public function hasChildren(int $parentId): bool;
}
Вариант 1: EditDocsDocumentLegacyRepository (условно для 1.4)
class EditDocsDocumentLegacyRepository implements EditDocsDocumentRepositoryInterface
{
private DocumentParser $modx;
public function __construct(DocumentParser $modx)
{
$this->modx = $modx;
}
public function moveChildren(int $fromParent, int $toParent): bool
{
$table = $this->modx->getFullTableName('site_content');
return $this->modx->db->query(
"UPDATE {$table} SET parent = {$toParent} WHERE parent = {$fromParent}"
) !== false;
}
// ... остальные методы
}
Вариант 2: EditDocsDocumentRepository (современный, с плейсхолдерами)
class EditDocsDocumentRepository implements EditDocsDocumentRepositoryInterface
{
private DocumentParser $modx;
public function __construct(DocumentParser $modx)
{
$this->modx = $modx;
}
public function moveChildren(int $from, int $to): bool
{
$table = $this->getTable();
$pdo = $this->getPdo();
$sql = "UPDATE {$table}
SET parent = :to
WHERE parent = :from";
$stmt = $pdo->prepare($sql);
if (!$stmt->execute([':to' => $to, ':from' => $from])) {
return false;
}
return $stmt->rowCount() > 0;
}
// ... остальные методы с плейсхолдерами
}
| Причина | Почему это важно |
|---|---|
| Обратная совместимость | Старые проекты на 1.4 могут использовать LegacyRepository |
| Постепенная миграция | Новые проекты могут сразу использовать современный Repository |
| Тестируемость | Интерфейс позволяет подменить реализацию на мок в тестах |
| Безопасность | Современный вариант использует подготовленные выражения |
Тесты после рефакторинга — не «надо бы», а «страховка»
«Код без тестов — как машина без тормозов. Едет? Да. Но останавливать страшно.»
После того как мы вынесли логику massMove() из легаси-спагетти в чистые слои (DTO → Validator → Service → Repository), пришло время зафиксировать результат. Не для галочки. А чтобы завтра, когда кто-то (возможно, вы) захочет добавить фичу, можно было нажать «запустить тесты» и быть уверенным: «Ничего не сломал».
Что мы тестируем и зачем?
Файл: tests/Legacy/EditDocsMassMoveFinalTest.php
Цель теста: убедиться, что после рефакторинга EditDocsAjaxHandler::handleRequest() корректно:
- Распознаёт запрос на массовое перемещение (
parent1+parent2). - Передаёт данные в
EditDocsMassMoveService. - Возвращает правильный ответ (успех или ошибку).
- Обрабатывает исключения без падения.
$_POST, echo, $modx).
Моки: почему мы «притворяемся», а не используем реальные классы
$this->mockModx = $this->getMockBuilder(\DocumentParser::class)
->disableOriginalConstructor()
->onlyMethods(['logEvent', 'clearCache', 'getFullTableName'])
->getMock();
Реальный $modx |
Мок $modx |
|---|---|
| ❌ Требует инициализации всего MODX | ✅ Создаётся за миллисекунды |
| ❌ Зависит от БД, конфига, сессии | ✅ Изолирован, предсказуем |
| ❌ Тест = интеграционный, медленный | ✅ Тест = юнит-тест, быстрый |
❌ Сложно проверить, что вызван logEvent |
✅ Легко: $this->mockModx->expects($this->once())->method('logEvent') |
Тест успешного сценария: testHandleRequest_MassMove_Success
public function testHandleRequest_MassMove_Success(array $postData, string $expectedOutput): void
{
$_POST = $postData;
$responseSender = new TestResponseSender();
$handler = $this->getMockBuilder(EditDocsAjaxHandler::class)
->setConstructorArgs([$this->mockModx, $this->mockEditDocs, $responseSender])
->onlyMethods(['createMassMoveService'])
->getMock();
$mockService = $this->createMock(EditDocsMassMoveService::class);
$mockService
->method('execute')
->with($this->isInstanceOf(EditDocsMassMoveInput::class))
->willReturn($expectedOutput);
$handler
->method('createMassMoveService')
->willReturn($mockService);
ob_start();
$handler->handleRequest();
$actualOutput = ob_get_clean();
$this->assertSame($expectedOutput, $actualOutput);
}
Почему createMassMoveService вынесен в protected-метод?
| Без выноса | С выносом |
|---|---|
new EditDocsMassMoveService(...) прямо в handleMassMove() |
Можно переопределить в тесте через ->onlyMethods(['createMassMoveService']) |
| Сложно подменить сервис на мок | Легко: $handler->method('createMassMoveService')->willReturn($mockService) |
| Тест зависит от реальных зависимостей | Тест изолирован, быстрый, предсказуемый |
protected.
Это «бэкдор» для тестов, который не ломает инкапсуляцию в продакшене.
Почему TestResponseSender?
// tests/Stubs/TestResponseSender.php
class TestResponseSender implements ResponseSenderInterface
{
public ?string $lastContent = null;
public ?int $lastStatusCode = null;
public ?array $lastHeaders = null;
public function send(string $body, int $code, array $headers): void
{
$this->lastContent = $body;
$this->lastStatusCode = $code;
$this->lastHeaders = $headers;
// Никакого header(), echo, exit — только запомнить
}
}
Зачем это нужно?
- Изоляция: Тест не зависит от реального HTTP-стека.
- Проверяемость: Можно легко проверить, что и с каким кодом должно было быть отправлено.
- Гибкость: Если завтра мы решим отправлять ответы через WebSocket — поменяем реализацию
ResponseSenderInterface, а тесты останутся зелёными.
Выводы: что мы узнали
- Легаси — это не приговор. Это просто код, который можно улучшить маленькими шагами.
- Характеризационные тесты — это ваша страховка. Они не проверяют «правильность», но фиксируют «фактичность».
- Разделение ответственности (DTO/Service/Repository) — это не перемудрили, а подготовились к будущему.
- Интерфейсы — это контракт. Реализация — это деталь. Меняй детали, не меняя контракт.
- Внедрение зависимостей — это не модно, а тестируемо.
- PHPUnit — это не сложно. Это стандарт индустрии, который экономит нервы.
Челлендж для читателя
- Открой свой легаси-проект.
- Найди одну функцию, которую страшно менять.
- Напиши для неё характеризационный тест.
- Сделай один маленький рефакторинг.
- Запушь. Почувствуй, как страх превращается в уверенность.
«Лучше маленький шаг вперёд, чем большой страх остаться на месте».
Что изменилось в ощущениях:
- Было: «Страшно трогать — вдруг сломается» → Стало: «Есть тесты — можно менять»
- Было: «Где тут логика перемещения?» → Стало: «Открываю
EditDocsMassMoveService— всё на месте» - Было: «Как добавить новое правило?» → Стало: «Добавляю метод в
Validator— готово» - Было: «А если придёт
parent1=abc?» → Стало: «DTO отклонит на входе, пользователь увидит понятную ошибку»
Что это дало для будущего:
- Масштабируемость: новые фичи добавляются в соответствующий слой, не ломая существующие.
- Тестируемость: юнит-тесты для сервисов, интеграционные — для контроллеров, характеризационные — для легаси.
- Поддерживаемость: новый разработчик может открыть
src/и быстро понять, где что лежит. - Безопасность: валидация на входе + подготовленные выражения = защита от распространённых уязвимостей.
Если захотелось сказать «да ладно, у меня и так работает» — перечитай пункт про страх и попробуй ещё раз. 😊