🐘 PHP

PHP में कोड परीक्षण: एक विस्तृत गाइड

Что это такое и зачем? (аналогия из реальной жизни)

Представь, что ты написал программу для школьного дневника. Она должна уметь: ставить оценки, считать средний балл, выводить расписание. Ты написал код, запустил — и всё работает. Но что будет, если пользователь введёт не число, а букву? Или попросит вывести оценку за день, который ещё не наступил? Твоя программа может «упасть» или выдать чушь.

Тестирование кода — это как проверка домашнего задания перед тем, как сдать его учителю. Ты не просто смотришь на код, а запускаешь специальные проверки, которые автоматически убеждаются, что каждая функция работает правильно.

Представь, что ты печёшь пирожки. Ты же не подашь гостям сырое тесто? Ты проверяешь: пропёкся ли пирожок, не подгорел ли, вкусная ли начинка. Вот и код нужно «попробовать» — протестировать.

В реальной жизни тестирование помогает:

  • Не сломать старое, когда добавляешь новое. Ты изменил одну строчку — а перестало работать 10 функций. Тесты это заметят.
  • Быстрее находить ошибки. Вместо того чтобы вручную вводить данные 100 раз, ты запускаешь одну команду.
  • Думать как пользователь. Ты начинаешь понимать, что программа должна делать в любой ситуации.

Теория: как это работает изнутри

В программировании есть разные виды тестов. Мы сосредоточимся на юнит-тестах (unit tests) — это проверка одной маленькой части программы: функции или метода.

Идея простая: ты пишешь код, который вызывает твою функцию и сравнивает результат с ожидаемым. Если совпало — тест пройден. Если нет — тест упал, и ты знаешь, где искать проблему.

В PHP есть специальные библиотеки для тестирования. Самая популярная — PHPUnit. Она работает так:

  1. Ты создаёшь класс-тест, который наследуется от PHPUnitFrameworkTestCase.
  2. Внутри пишешь методы, названия которых начинаются на test.
  3. В каждом методе используешь специальные функции-«утверждения» (assertions), которые проверяют условия.

Например, есть функция сложения. Ты пишешь тест: «вызови функцию с числами 2 и 3 — проверь, что результат равен 5». Если функция вернёт 5 — тест зелёный. Если вернёт 6 — красный.

Важно: тесты не доказывают, что программа правильная на 100%. Они доказывают, что программа работает так, как ты ожидаешь в тех случаях, которые проверил.

Процесс тестирования обычно выглядит так:

  • Red — пишешь тест, который падает (потому что функции ещё нет).
  • Green — пишешь минимальный код, чтобы тест прошёл.
  • Refactor — улучшаешь код, не меняя его поведения.

Это называется TDD — Test-Driven Development (разработка через тестирование).

Синтаксис в PHP: все формы и варианты

Для работы с PHPUnit тебе нужно:

  1. Установить PHPUnit через Composer: composer require --dev phpunit/phpunit
  2. Создать файл с тестом.
  3. Запустить: ./vendor/bin/phpunit tests/

Вот основные «утверждения» (assertions), которые ты будешь использовать:

Метод Что проверяет Пример
assertEquals($expected, $actual) Равенство значений (с приведением типов) $this->assertEquals(5, sum(2,3));
assertSame($expected, $actual) Строгое равенство (тип + значение) $this->assertSame(5, sum(2,3));
assertTrue($condition) Условие истинно $this->assertTrue($user->isAdmin());
assertFalse($condition) Условие ложно $this->assertFalse($user->isBlocked());
assertNull($variable) Переменная равна null $this->assertNull($result);
assertCount($count, $array) Количество элементов в массиве $this->assertCount(3, $items);
assertContains($value, $array) Массив содержит значение $this->assertContains('apple', $fruits);
assertInstanceOf($class, $object) Объект принадлежит классу $this->assertInstanceOf(User::class, $user);

Также есть методы для проверки исключений:

  • expectException(ExceptionClass::class) — ожидаем, что будет выброшено исключение.
  • expectExceptionMessage('Message') — проверяем текст сообщения.

Примеры кода — от простого к сложному

Пример 1: Простейший тест функции сложения

<?php
// Файл: src/Math.php
// Простая функция для сложения двух чисел
function sum(int $a, int $b): int {
    return $a + $b;
}
<?php
// Файл: tests/MathTest.php
// Подключаем PHPUnit
use PHPUnitFrameworkTestCase;

// Подключаем нашу функцию
require_once __DIR__ . '/../src/Math.php';

class MathTest extends TestCase {
    // Метод теста — имя начинается с "test"
    public function testSumWithPositiveNumbers(): void {
        // Вызываем функцию с числами 2 и 3
        $result = sum(2, 3);
        
        // Проверяем: результат должен быть равен 5
        $this->assertEquals(5, $result);
        // Если всё верно — тест зелёный
    }
}

Объяснение: Мы создали класс MathTest, который наследуется от TestCase. Внутри — метод testSumWithPositiveNumbers. Он вызывает функцию sum и проверяет, что результат равен 5. Если функция работает правильно — тест пройден.

Пример 2: Проверка нескольких случаев

<?php
// tests/MathTest.php — добавляем новые методы

class MathTest extends TestCase {
    public function testSumWithPositiveNumbers(): void {
        $this->assertEquals(5, sum(2, 3));
    }
    
    // Тест с нулём
    public function testSumWithZero(): void {
        $this->assertEquals(5, sum(5, 0));
        $this->assertEquals(0, sum(0, 0));
    }
    
    // Тест с отрицательными числами
    public function testSumWithNegativeNumbers(): void {
        $this->assertEquals(0, sum(-5, 5));
        $this->assertEquals(-10, sum(-5, -5));
    }
}

Объяснение: Один тест может проверять несколько случаев. Мы проверяем сложение с нулём и отрицательными числами. Если хоть одна проверка не пройдёт — весь тест упадёт.

Пример 3: Тестирование класса User

<?php
// src/User.php
class User {
    private string $name;
    private bool $isAdmin = false;
    
    public function __construct(string $name) {
        $this->name = $name;
    }
    
    public function getName(): string {
        return $this->name;
    }
    
    public function makeAdmin(): void {
        $this->isAdmin = true;
    }
    
    public function isAdmin(): bool {
        return $this->isAdmin;
    }
}
<?php
// tests/UserTest.php
use PHPUnitFrameworkTestCase;

require_once __DIR__ . '/../src/User.php';

class UserTest extends TestCase {
    public function testUserCreation(): void {
        // Создаём нового пользователя
        $user = new User('Иван');
        
        // Проверяем имя
        $this->assertEquals('Иван', $user->getName());
        
        // Проверяем, что он не админ по умолчанию
        $this->assertFalse($user->isAdmin());
    }
    
    public function testMakeAdmin(): void {
        $user = new User('Петя');
        
        // Делаем пользователя админом
        $user->makeAdmin();
        
        // Проверяем, что он стал админом
        $this->assertTrue($user->isAdmin());
    }
}

Объяснение: Мы тестируем объект. Проверяем, что при создании пользователь не админ, а после вызова makeAdmin() — становится. Это типичный тест для класса.

Пример 4: Проверка исключений

<?php
// src/Calculator.php
class Calculator {
    // Функция деления — выбрасывает исключение при делении на ноль
    public function divide(int $a, int $b): float {
        if ($b === 0) {
            throw new InvalidArgumentException('Деление на ноль невозможно');
        }
        return $a / $b;
    }
}
<?php
// tests/CalculatorTest.php
use PHPUnitFrameworkTestCase;

require_once __DIR__ . '/../src/Calculator.php';

class CalculatorTest extends TestCase {
    public function testDivideNormal(): void {
        $calc = new Calculator();
        $result = $calc->divide(10, 2);
        $this->assertEquals(5.0, $result);
    }
    
    public function testDivideByZero(): void {
        $calc = new Calculator();
        
        // Говорим PHPUnit: ожидаем исключение
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage('Деление на ноль невозможно');
        
        // Этот вызов должен выбросить исключение
        $calc->divide(10, 0);
    }
}

Объяснение: Мы проверяем, что при делении на ноль выбрасывается исключение с правильным сообщением. Если исключение не выбросится — тест упадёт.

Пример 5: Тестирование с setUp (подготовка данных)

<?php
// tests/UserTest.php — улучшенная версия
use PHPUnitFrameworkTestCase;

require_once __DIR__ . '/../src/User.php';

class UserTest extends TestCase {
    private User $user;
    
    // Этот метод выполняется перед каждым тестом
    protected function setUp(): void {
        $this->user = new User('Тестовый пользователь');
    }
    
    public function testDefaultName(): void {
        $this->assertEquals('Тестовый пользователь', $this->user->getName());
    }
    
    public function testDefaultIsNotAdmin(): void {
        $this->assertFalse($this->user->isAdmin());
    }
    
    public function testAfterMakeAdmin(): void {
        $this->user->makeAdmin();
        $this->assertTrue($this->user->isAdmin());
    }
}

Объяснение: Метод setUp() выполняется перед каждым тестом. Это удобно: не нужно создавать объект в каждом тесте заново. Если тестов много — это экономит время и делает код чище.

Пример 6: Data Providers (наборы данных)

<?php
// tests/MathTest.php — используем Data Provider
use PHPUnitFrameworkTestCase;

require_once __DIR__ . '/../src/Math.php';

class MathTest extends TestCase {
    /**
     * @dataProvider sumDataProvider
     */
    public function testSum(int $a, int $b, int $expected): void {
        $this->assertEquals($expected, sum($a, $b));
    }
    
    // Метод, который возвращает наборы данных
    public function sumDataProvider(): array {
        return [
            'positive numbers' => [2, 3, 5],
            'with zero' => [5, 0, 5],
            'negative numbers' => [-5, 5, 0],
            'two negatives' => [-3, -7, -10],
            'large numbers' => [1000, 2000, 3000],
        ];
    }
}

Объяснение: Data Provider позволяет запустить один тест с разными данными. Мы передаём массивы: первый элемент — первый аргумент, второй — второй, третий — ожидаемый результат. Тест запустится 5 раз с разными данными.

Пример 7: Mock-объекты (имитация)

<?php
// src/Mailer.php
class Mailer {
    public function send(string $email, string $message): bool {
        // В реальности — отправка email
        return true;
    }
}

// src/UserService.php
class UserService {
    private Mailer $mailer;
    
    public function __construct(Mailer $mailer) {
        $this->mailer = $mailer;
    }
    
    public function welcomeNewUser(string $email): string {
        $this->mailer->send($email, 'Добро пожаловать!');
        return 'Письмо отправлено на ' . $email;
    }
}
<?php
// tests/UserServiceTest.php
use PHPUnitFrameworkTestCase;

require_once __DIR__ . '/../src/Mailer.php';
require_once __DIR__ . '/../src/UserService.php';

class UserServiceTest extends TestCase {
    public function testWelcomeNewUser(): void {
        // Создаём "заглушку" для Mailer
        $mailerMock = $this->createMock(Mailer::class);
        
        // Говорим: когда вызовут метод send, верни true
        $mailerMock->method('send')->willReturn(true);
        
        // Создаём сервис с заглушкой
        $service = new UserService($mailerMock);
        
        // Вызываем метод
        $result = $service->welcomeNewUser('test@example.com');
        
        // Проверяем результат
        $this->assertEquals('Письмо отправлено на test@example.com', $result);
    }
}

Объяснение: Mock-объекты нужны, когда не хочется по-настоящему отправлять email или работать с базой данных. Мы создаём «заглушку», которая имитирует поведение настоящего объекта. Это ускоряет тесты и делает их независимыми от внешних сервисов.

Сравнение с Python: чем похоже и чем отличается

В Python тоже есть тестирование, и оно очень похоже на PHP. Вот основные отличия:

Характеристика PHP (PHPUnit) Python (unittest / pytest)
Основная библиотека PHPUnit unittest (встроенная), pytest
Синтаксис утверждений $this->assertEquals() self.assertEqual() или assert
Data Providers Аннотация @dataProvider Декоратор @pytest.mark.parametrize
Mock-объекты $this->createMock() unittest.mock.Mock()
Запуск тестов ./vendor/bin/phpunit python -m unittest или pytest
Именование тестов Методы с префиксом test Методы с префиксом test_

Похоже:

  • И там, и там тесты — это классы с методами.
  • Идея «красный-зелёный-рефакторинг» одинакова.
  • Есть setUp/tearDown для подготовки данных.

Отличия:

  • В Python тесты можно писать без классов (в pytest).
  • Python использует отступы вместо фигурных скобок.
  • В Python больше встроенных инструментов для тестирования.

Типичные ошибки и подводные камни

  1. Тестировать слишком много в одном тесте. Один тест — одна проверка. Если тест упал, ты должен сразу понять, что именно сломалось.
  2. Забывать про граничные случаи. Пустые массивы, нули, отрицательные числа, null — всё это нужно проверять.
  3. Тестировать внутреннюю реализацию, а не поведение. Тест должен проверять, что функция делает, а не как она это делает. Иначе при рефакторинге тесты сломаются.
  4. Не запускать тесты перед коммитом. Сделал изменение — запусти тесты. Если они зелёные — можно коммитить.
  5. Писать тесты после того, как код уже написан. Лучше писать тесты до или одновременно с кодом. Так ты сразу видишь, что код работает.
  6. Игнорировать медленные тесты. Если тест выполняется 10 секунд — его будут редко запускать. Оптимизируй или выноси в отдельную группу.
  7. Тестировать только «счастливый путь». Проверяй не только когда всё хорошо, но и когда приходят плохие данные.
Запомни: тест, который никогда не падает, бесполезен. Если тест всегда зелёный — возможно, он ничего не проверяет.

Лучшие практики и советы профессионалов

  1. Пиши тесты до кода (TDD). Это дисциплинирует и заставляет думать о том, что должна делать программа.
  2. Один тест — одна проверка. Используй понятные имена: testUserCannotRegisterWithEmptyEmail.
  3. Изолируй тесты. Каждый тест должен быть независимым. Не полагайся на порядок выполнения.
  4. Используй Data Providers для повторяющихся проверок. Это уменьшает дублирование кода.
  5. Тестируй границы. Если функция принимает числа от 1 до 100 — проверь 0, 1, 50, 100, 101.
  6. Не тестируй чужой код. Не нужно проверять, работает ли PHP-функция array_push() — она уже протестирована разработчиками PHP.
  7. Поддерживай тесты в чистоте. Если тест сложный — упрости его. Тесты — это тоже код, за которым нужно ухаживать.
  8. Запускай тесты автоматически. Настрой CI/CD (например, GitHub Actions), чтобы тесты запускались при каждом коммите.
  9. Пиши тесты для багов. Нашёл ошибку? Сначала напиши тест, который её воспроизводит. Потом исправляй. Так баг больше не вернётся.
  10. Используй покрытие кода (code coverage). PHPUnit показывает, какие строки кода были выполнены во время тестов. Стремись к 80%+.

Вопросы для самопроверки

  1. Что такое юнит-тест?

    Ответ: Это автоматическая проверка одной маленькой части программы (функции или метода), которая сравнивает ожидаемый результат с фактическим.

  2. Какой метод PHPUnit используется для проверки равенства значений?

    Ответ: assertEquals($expected, $actual). Он проверяет, что два значения равны (с приведением типов).

  3. Что такое Data Provider в PHPUnit?

    Ответ: Это метод, который возвращает наборы данных для одного теста. Тест запускается несколько раз с разными аргументами.

  4. Зачем нужны Mock-объекты?

    Ответ: Чтобы имитировать поведение сложных или внешних объектов (база данных, API, почта). Это делает тесты быстрыми и независимыми.

  5. Что означает «красный-зелёный-рефакторинг»?

    Ответ: Это цикл TDD: сначала пишешь тест, который падает (красный), потом пишешь код, чтобы тест прошёл (зелёный), потом улучшаешь код (рефакторинг).

  6. Как проверить, что функция выбрасывает исключение?

    Ответ: Использовать $this->expectException(ExceptionClass::class) перед вызовом функции, которая должна выбросить исключение.

  7. Почему не стоит тестировать внутреннюю реализацию?

    Ответ: Потому что при рефакторинге (изменении внутреннего устройства) тесты сломаются, даже если поведение осталось правильным. Тесты должны проверять поведение, а не код.

  8. Что такое code coverage?

    Ответ: Это метрика, показывающая, какой процент кода выполняется во время тестов. Чем выше — тем больше кода проверено.

  9. Какой метод выполняется перед каждым тестом?

    Ответ: setUp(). Он используется для подготовки данных, которые нужны в каждом тесте.

  10. Что делать, если нашёл баг в коде?

    Ответ: Сначала написать тест, который воспроизводит этот баг (он упадёт). Потом исправить код, чтобы тест прошёл. Так баг не вернётся в будущем.

Итог и что изучить дальше

Тестирование — это не роскошь, а необходимость. Оно превращает программирование из «напиши и молись, что работает» в «напиши и будь уверен». Ты научился:

  • Писать юнит-тесты на PHP с помощью PHPUnit.
  • Использовать утверждения (assertEquals, assertTrue, assertFalse).
  • Проверять исключения и использовать Data Providers.
  • Создавать Mock-объекты для изоляции тестов.
  • Понимать разницу между PHP и Python в тестировании.

Что изучить дальше:

  • Интеграционные тесты — проверка, как разные части системы работают вместе (например, с базой данных).
  • Функциональные тесты — проверка целых сценариев (например, регистрация пользователя через веб-интерфейс).
  • BDD (Behavior-Driven Development) — тестирование на естественном языке с помощью Behat.
  • CI/CD — настройка автоматического запуска тестов при каждом коммите (GitHub Actions, GitLab CI).
  • Продвинутые техники — тестирование баз данных, API, асинхронного кода.
Помни: хороший программист — это не тот, кто не делает ошибок, а тот, кто умеет их быстро находить и исправлять. Тестирование — твой главный инструмент для этого.