Юнит-тест — это маленький кусок кода, который проверяет другой кусок кода и говорит «ок» или «упало», без вашего участия. Вы вызываете функцию, подсовываете ей заведомые данные и сверяете результат с ожиданием. Один раз написав такую проверку, вы больше не запускаете сайт руками после каждой правки и не молитеесь, что «вроде работает». Тесты превращают «мне кажется, я не сломал» в «прогнал — зелено». Разберём, с чего начать на реальном PHP/JS-коде (WordPress + фронт), чем юнит-тест отличается от интеграционного, как писать тесты, которые не врут, и где они обычно ломаются.
Часть I. Что такое юнит-тест и чем он не является
Слово «юнит» (unit, «единица») — про размер того, что проверяем: одна функция, один метод класса, одна чистая операция. Не «весь сайт», не «сценарий покупки», а именно маленькая единица логики.
Три вида тестов, которые путают
| Юнит-тест | Интеграционный | E2E (сквозной) | |
|---|---|---|---|
| Что проверяет | одну функцию/метод | взаимодействие модулей (код↔БД, код↔API) | весь сценарий глазами пользователя |
| Зависимости | нет (мок/чистые данные) | реальные или тестовые контейнеры | реальный браузер, реальный сервер |
| Скорость | миллисекунды | десятки–сотни мс | секунды–минуты |
| Сколько их | сотни–тысячи | десятки | единицы |
| Где чаще всего | ваш PHP-код, JS-утилиты | репозиторий, коннект к БД | smoke-проверка «сайт жив» |
Классическая «пирамида»: юнитов — много и они быстрые, интеграционных — меньше, E2E — совсем мало (дорогие и хрупкие). Ошибка новичка — писать только E2E («открываю страницу и смотрю глазами»): это медленно, нестабильно и не говорит, где именно сломалось.
Зачем это именно вам (WordPress + фронт)
- PHP: чистые функции темы/плагина (форматирование, парсинг, валидация,
cs_slugifyиз статьи про regex) тестируются изолированно — без поднятого WordPress. - JS: утилиты (
parsePrice(),formatDate()) — тестируются в Jest без браузера. - Рефакторинг без страха: поправили функцию — прогнали тесты — если зелено, поведение то же. Это то, ради чего тесты вообще пишут.
Чек-лист части
- Отличаю юнит (одна единица, без I/O) от интеграционного (модули вместе) и E2E (весь сценарий).
- Понимаю, почему юнитов должно быть много, а E2E — мало.
- Вижу, какие мои PHP/JS-функции годятся в юниты (чистые, без обращения к БД в теле).
Часть II. Скелет любого теста: AAA
Подавляющее большинство юнит-тестов строятся по одному шаблону из трёх абзацев.
- Arrange — приготовьте вход и ожидание.
- Act — выполните одно действие (вызов проверяемой функции).
- Assert — сверьте результат.
PHP / PHPUnit
<?php
use PHPUnit\Framework\TestCase;
final class SlugifyTest extends TestCase
{
public function testConvertsCyrillicTitleToSlug(): void
{
// Arrange
$title = 'Привет, Мир 2026!';
// Act
$slug = cs_slugify($title); // функция из статьи про regex
// Assert
$this->assertSame('привет-мир-2026', $slug);
}
}Один Act, один Assert, читаемо сверху вниз. Название теста говорит что, при каком входе и какой ожидаемый результат.
JavaScript / Jest
// formatPrice.test.js
const { formatPrice } = require('./formatPrice');
test('rounds cents to two decimals in rubles', () => {
// Arrange
const value = 1234.5;
// Act
const out = formatPrice(value, 'RUB');
// Assert
expect(out).toBe('1 234 ₽');
});Правило «один повод упасть»
Хороший тест ломается по одной причине. Если в ассертах их две (проверяете и тип результата, и его строковое представление, и порядок ключей) — при падении непонятно, что именно отвалилось. Разбейте на отдельные тесты с говорящими именами. Много маленьких тестов дешевле отлаживать, чем один «комбайн».
Чек-лист части
- Вижу в тесте три фазы Arrange / Act / Assert.
- В названии теста зашито «что проверяю → при каком входе → чего жду».
- Тест имеет ровно один повод упасть (один смысл в ассертах).
Часть III. Как выбирать тестовые данные
Главный вопрос после скелета: какие именно входы подставить? Ответ «пять случайных чисел» даёт слабые тесты. Рабочие приёмы — эквивалентные классы и граничные значения.
Эквивалентные классы
Разбейте вход на группы, где код ведёт себя одинаково, и возьмите по одному представителю из каждой. Для валидатора возраста (0–120 валидно):
| Класс | Пример | Что ждём |
|---|---|---|
| Ниже минимума | -1 | ошибка |
| Ровно минимум | 0 | ок |
| Обычное валидное | 30 | ок |
| Ровно максимум | 120 | ок |
| Выше максимума | 121 | ошибка |
| Не число вообще | 'abc', null | ошибка/исключение |
Шесть осмысленных кейсов покрывают логику лучше, чем десять случайных 25, 44, 60, 61… внутри одного класса.
Граничные значения (boundary)
Баги живут на границах: <= вместо <, «off-by-one». Поэтому всегда тестируйте ровно границу (0 и 120), и по одному шагу за неё (-1, 121). Это ловит класс «на 29 работает, на 30 ломается».
Особые входы, которые забывают
- пустая строка / пустой массив;
null,undefined,0,false(их путают и «съедают» оператором||— см. статью про ES6);- строки с юникодом, эмодзи и
\r\n; - очень длинный вход (границы буфера, лимиты поля).
Таблица «вход → ожидаемое» вместо интуиции
Перед тем как писать код теста, набросьте табличку (как выше). Она сама диктует количество тестов: каждая строка — один тест. Дальше таблица переезжает вDataProvider PHPUnit и test.each Jest — и у вас автоматически растёт покрытие, а не «на глаз».
Чек-лист части
- Разбиваю вход на эквивалентные классы и беру по представителю.
- Обязательно трогаю границы и шаг за них.
- Проверяю пустое/`null`/`0`/юникод/длину — а не только «нормальные» значения.
Часть IV. Зависимости: мокать, а не вызывать
Юнит-тест обязан быть быстрым и детерминированным. Значит, функция, которая внутри ходит в БД, HTTP или читает файл, не должна в тесте реально туда ходить. Настоящего соседа заменяют подделкой — mock (следит, что и как вызывали), stub (просто возвращает заготовленное), spy (обёртка, подсматривающая за вызовами).
Что мокют в WordPress-коде
Чистую функцию мокать не нужно. Мокют точки входа наружу: wp_remote_get(), $wpdb, get_option(), файловые операции. Иначе тест зависит от состояния прод-БД и падает «сам по себе, не из-за вашего изменения».
Пример: PHP с введённой зависимостью (без мока вообще)
Самый дешёвый способ — внедрить зависимость параметром, тогда передаёте заглушку руками и никакого мока-фреймворка не надо:
<?php
// Проверяемая функция получает «фетчер», а не сам зовёт wp_remote_get
function fetch_and_parse(string $url, callable $fetcher): int {
$json = $fetcher($url);
$data = json_decode($json, true);
return (int) ($data['count'] ?? 0);
}
final class FetchParseTest extends TestCase
{
public function testReturnsCountFromJson(): void
{
$stub = fn(string $u): string => '{"count": 42}'; // заглушка вместо сети
$this->assertSame(42, fetch_and_parse('https://example.test/api', $stub));
}
}Тест не трогает сеть: вместо реального запроса передали замыкание-стаб, возвращающее фиксированный JSON. В production callable $fetcher будет реальный wp_remote_get. Это же правило («никаких сетевых вызовов в конструкторе/на старте, всё лениво и инъектируемо») живёт и в архитектуре ваших плагинов.
Jest: jest.mock для модуля
const api = require('./api');
jest.mock('./api'); // подменяем модуль заглушкой
test('renders user name', async () => {
api.getUser.mockResolvedValue({ name: 'Анна' }); // заготовленный ответ
const html = await renderUserCard('u-1');
expect(html).toContain('Анна');
});
mockResolvedValue — stub: «верни это без реального Promise-запроса». Дальше можно проверить и что getUser вызвали ровно с 'u-1' (spy-поведение).
Когда мокать — перебор
Если функция просто складывает числа или чистит строку — мока нет и не надо. Моки нужны ровно там, где есть внешний мир (сеть, БД, ФС, время, случайность, глобальное состояние WordPress). Слишком много моков — сигнал, что функция делает слишком много (см. «Что дальше»).
Чек-лист части
- Подразумеваю зависимость (сеть/БД/время) как параметр, а не зову её внутри.
- В тестах мок/стаб заменяет внешний вызов — тест не ходит в прод.
- Мокаю только внешний мир; чистые функции тестирую без моков.
Часть V. Покрытие: полезная подсказка, вредная цель
Code coverage (покрытие) — метрика «какая доля строк/веток кода хотя бы раз исполнилась под тестами». Она полезна как фонарик: «вот эта ветка if не тронута ни одним тестом — добавь». Вредна как цель: гнаться за 90% можно, написав десятки ассертов, которые ничего не проверяют (вызвали функцию — «не упала» — «покрытие +1»).
Строки ≠ ветвления
Инструменты считают по-разному. coverage by line покажет «строка выполнилась», даже если условие было всегда true. Значит, реальную пользу даёт branch/condition coverage: покрыта ли каждая ветка каждого if/тернарника/switch. Тестируйте ветки — строки подтянутся.
// line-coverage обманет: если тесты всегда идут в правую ветку,
// левая ($x < 0) формально «не покрыта» — это и надо закрыть
function abs2(int $x): int {
return $x < 0 ? -$x : $x;
}
Что реально стоит покрывать в первую очередь
- код, где уже был баг (регрессия — тест на конкретный сломанный кейс);
- чистые бизнес-функции (парсинг, валидация, расчёты);
- обработчики ошибок и граничные пути (см. Часть III).
Геттеры, сеттеры, конфигурация и «очевидные» строки — покрытие ради цифры там почти бесполезно.
Порог в CI — дисциплина, не самоцель
Поставьте в пайплайне «минимум 80% по строкам + тест обязан пройти» — это не «для отчёта», а чтобы новый код не падал ниже уже достигнутого. Поднять порог с 0 до 80 разом невозможно; растут — постепенно, по мере добавления тестов. Механику «запускать тесты автоматически на каждый коммит» разбирает статья про CI/CD.
Чек-лист части
- Использую покрытие как подсказку «что не проверено», а не как KPI.
- Смотрю на ветки (branch coverage), а не только на строки.
- Приоритет покрытия: регрессии → чистые функции → граничные пути.
Часть VI. Грабли: тесты, которые врут
Опасны не «нет тестов», а «есть тесты, которые дают ложное чувство безопасности». Вот частые.
1. Тест, который всегда зелёный
Ассерта нет (или он assertTrue(true)), либо проверяется не то. Проверка на честность: сломайте намеренно проверяемый код — тест обязан покраснеть. Если нет — он ничего не проверял.
2. Зависимость от времени, порядка, случайности
assert(new DateTime() == '2026-10-21') зелёный ровно один день. Мокните часы (передавайте Clock-зависимость), не сравнивайте списки без сортировки (assertSame по массиву чувствителен к порядку — сортируйте или сравнивайте как множества), фиксируйте сид для случайных данных.
3. Тест проверяет сам себя
Функция и тест переписывают одну и ту же формулу — «подтверждают» друг друга. Ожидаемый результат должен быть известен независимо (из требования, а не из текущей реализации): иначе тест закрепляет баг как норму.
4. «Работает у меня, падает в CI»
Скрытая зависимость от локального состояния: подключённой прод-БД, системной локали, php.ini, прав файла. Юнит не должен ничего этого требовать — всё внешнее либо мокнуто, либо поднимается в контейнере как интеграционный тест. Отсюда и детерминированность: одинаковый вход → одинаковый результат всегда.
5. Тест на то, что «не упало без исключения»
$result = f(); $this->assertNotNull($result); — слабый ассерт. Функция вернула мусор не-null, тест прошёл, а поведение сломано. Утверждайте конкретное значение/структуру.
6. Один тест на всю функцию (mega-test) и его обратная болезнь
Иногда пишут один тест на 200 строк со всеми кейсами сразу — и при падении невозможно понять, какой именно вход сломался. Разбивайте по одному поведению. Но и не плодите 40 тестов, которые проверяют один и тот же путь — дубли.
7. Игнор флак-тестов (flaky)
Тест, который «то проходит, то нет», обычно скрывает гонку, тайминг или реальную ошибку. Закомментировать/«перезапустить три раза» — не решение: чините причину (обычно зависимость от времени/порядка, пп. 2).
Чек-лист граблей этой части
- Сломал код намеренно — тест покраснел (иначе он пустышка).
- Никаких «живых» дат/случайности/порядка без фиксации.
- Ожидаемый результат известен независимо от реализации.
- Тест не зависит от прод-БД/локали/файлов — значит, он юнит, а не интеграционный.
- Конкретный ассерт вместо `assertNotNull` / «не упало».
Материал написан простым языком; любой англоязычный термин (юнит-тест, интеграционный, E2E, ассерт/expect, мок/stub/spy, инъекция зависимостей, покрытие/coverage, ветвление/branch coverage, детерминированность, флак/flaky, регрессия) объясняется при первом употреблении. Примеры — для PHPUnit 10+ и Jest 29+; конкретные имена функций темы (
cs_slugify,formatPrice) — условные, подставляйте свои; домены и пути —example.test. Юнит-тесты WordPress-кода, требующего живого ядра (WP_UnitTestCase, bootstrap,$wpdb), относятся к интеграционному слою и здесь сознательно не раскрыты — там нужен тестовый WordPress и база. Механика настройки PHPUnit-конфига,composer require --dev phpunit/phpunitи bootstrap для темы — прикладной шаг, а не концепция, — приведите его один раз для проекта и дальше держите в CI.
Инструменты под задачу
Вставляете свою функцию (или класс с методом) — получаете готовые тест-кейсы на нужном фреймворке: PHPUnit, Jest, PyTest, JUnit, NUnit, xUnit. Можно включить секции Setup/Teardown и явные моки для внешних зависимостей (сеть, БД, ФС) — как раз чтобы тест не ходил в прод, а проверял логику. Это снимает рутину «вспомнить синтаксис ассертов выбранного фреймворка», но не отменяет правило из Части VI: прогнайте красное/зелёное сами.
Генерация тестов... Это может занять до 30 секунд.
👤 Зарегистрируйтесь, чтобы добавить материал в закладки.
