Глава 11.1: Введение в тестирование — зачем тесты, виды тестов, PHPUnit
🎯 Цели главы
После изучения этой главы ты сможешь:
- Понимать, зачем нужны автоматизированные тесты
- Различать виды тестов и знать, когда какие применять
- Устанавливать и настраивать PHPUnit
- Писать свои первые юнит-тесты
- Запускать тесты и читать результаты
📖 Теория
Почему тесты критически важны
Представь: ты добавил новую фичу в свой мессенджер. Все работает отлично. Через неделю фиксишь баг в системе авторизации и... внезапно перестала работать отправка сообщений. Ты об этом узнаешь только когда пользователь написал гневный отзыв.
Без тестов:
Код → Ручная проверка → Деплой → 🔥 Что-то сломалось в productionС тестами:
Код → Автоматические тесты → ❌ Тесты упали → Фикс → ✅ Все зелено → ДеплойРеальные преимущества тестов
1. Уверенность в изменениях
// Ты меняешь calculateDiscount()
// Тесты проверят, что ничего не сломалось
public function test_discount_calculation()
{
$order = new Order(['total' => 1000]);
$this->assertEquals(900, $order->calculateDiscount(10));
}2. Живая документация
// Тест показывает КАК должен работать код
public function test_user_can_send_message_only_to_friends()
{
$user = User::factory()->create();
$stranger = User::factory()->create();
$result = $user->sendMessage($stranger, 'Hello');
$this->assertFalse($result);
$this->assertDatabaseMissing('messages', [
'sender_id' => $user->id,
'receiver_id' => $stranger->id
]);
}3. Быстрая обратная связь Вместо 5 минут ручного кликанья — 2 секунды автотестов.
4. Рефакторинг без страха
// Хочешь переписать весь метод? Тесты скажут, если что-то сломалось
// БЫЛО:
public function calculateTotal() {
return array_sum($this->items) + $this->tax;
}
// СТАЛО:
public function calculateTotal() {
return collect($this->items)->sum() + $this->getTax();
}
// Тесты остались те же — если проходят, рефакторинг успешенКогда тесты НЕ нужны (да, бывает)
- Прототипы — код живет 2 дня, тесты дольше писать
- Скрипты-однодневки — парсер, который запустишь раз
- Очевидный код — геттер
getName()тестировать смысла нет
Но как только проект живет дольше недели — тесты окупаются.
🔍 Виды тестов (Пирамида тестирования)
/\
/ \ E2E Tests (End-to-End)
/____\ Мало, медленные, дорогие
/ \
/ Integr \ Integration Tests
/ ation \ Средне
/____________\
/ \
/ Unit Tests \ Много, быстрые, дешевые
/__________________\1. Unit-тесты (юнит-тесты)
Что: Тестируют отдельные функции/методы изолированно.
Пример:
class Calculator
{
public function add($a, $b)
{
return $a + $b;
}
}
// ТЕСТ
class CalculatorTest extends TestCase
{
public function test_add_two_numbers()
{
$calc = new Calculator();
$result = $calc->add(2, 3);
$this->assertEquals(5, $result);
}
}Характеристики:
- ⚡ Очень быстрые (миллисекунды)
- 🎯 Тестируют одну вещь
- 🔒 Не обращаются к БД/файлам/сети
- 📦 Используют моки для зависимостей
2. Integration Tests (интеграционные)
Что: Проверяют взаимодействие нескольких компонентов.
Пример:
// Проверяем: контроллер → сервис → репозиторий → БД
public function test_user_registration_flow()
{
$data = [
'email' => 'test@example.com',
'password' => 'secret123'
];
$response = $this->post('/register', $data);
$response->assertStatus(201);
$this->assertDatabaseHas('users', [
'email' => 'test@example.com'
]);
}Характеристики:
- 🐢 Медленнее юнит-тестов (секунды)
- 🔗 Проверяют связи между компонентами
- 💾 Могут использовать реальную БД (или тестовую)
3. E2E Tests (сквозные тесты)
Что: Симулируют реального пользователя в браузере.
Пример (Laravel Dusk):
public function test_user_can_send_message()
{
$this->browse(function (Browser $browser) {
$browser->visit('/login')
->type('email', 'user@test.com')
->type('password', 'password')
->press('Login')
->waitForText('Welcome')
->visit('/chat/1')
->type('message', 'Hello!')
->press('Send')
->waitForText('Hello!');
});
}Характеристики:
- 🐌 Очень медленные (минуты)
- 💰 Сложные в поддержке
- 🎭 Проверяют весь стек (фронтенд + бэкенд)
Правило распределения
70% — Unit-тесты
20% — Integration-тесты
10% — E2E-тесты⚙️ PHPUnit — установка и настройка
Установка через Composer
composer require --dev phpunit/phpunitПроверка:
vendor/bin/phpunit --version
# PHPUnit 10.5.0 by Sebastian Bergmann and contributors.Структура проекта с тестами
project/
├── src/
│ ├── Calculator.php
│ └── User.php
├── tests/
│ ├── Unit/
│ │ ├── CalculatorTest.php
│ │ └── UserTest.php
│ └── Integration/
│ └── UserRegistrationTest.php
├── vendor/
├── composer.json
└── phpunit.xmlКонфигурация phpunit.xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Integration">
<directory>tests/Integration</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>Что здесь:
bootstrap— автозагрузка классов через Composercolors="true"— красивый цветной выводtestsuites— группировка тестовsource— какие файлы покрывать тестами
💻 Первый тест на PHPUnit
Шаг 1: Класс для тестирования
src/StringHelper.php:
<?php
namespace App;
class StringHelper
{
public function reverse(string $str): string
{
return strrev($str);
}
public function capitalize(string $str): string
{
return ucfirst(strtolower($str));
}
public function slugify(string $str): string
{
$str = strtolower($str);
$str = preg_replace('/[^a-z0-9]+/', '-', $str);
return trim($str, '-');
}
}Шаг 2: Тест
tests/Unit/StringHelperTest.php:
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use App\StringHelper;
class StringHelperTest extends TestCase
{
private StringHelper $helper;
// Выполняется перед КАЖДЫМ тестом
protected function setUp(): void
{
parent::setUp();
$this->helper = new StringHelper();
}
public function test_reverse_string()
{
$result = $this->helper->reverse('hello');
$this->assertEquals('olleh', $result);
}
public function test_reverse_empty_string()
{
$result = $this->helper->reverse('');
$this->assertEquals('', $result);
}
public function test_capitalize_lowercase_word()
{
$result = $this->helper->capitalize('hello');
$this->assertEquals('Hello', $result);
}
public function test_capitalize_uppercase_word()
{
$result = $this->helper->capitalize('HELLO');
$this->assertEquals('Hello', $result);
}
public function test_slugify_creates_valid_slug()
{
$result = $this->helper->slugify('Hello World 123');
$this->assertEquals('hello-world-123', $result);
}
public function test_slugify_removes_special_characters()
{
$result = $this->helper->slugify('Hello@World!');
$this->assertEquals('hello-world', $result);
}
}Шаг 3: Запуск
vendor/bin/phpunit
# PHPUnit 10.5.0 by Sebastian Bergmann and contributors.
...... 6 / 6 (100%)
Time: 00:00.012, Memory: 6.00 MB
OK (6 tests, 6 assertions)🔧 Ассерции (assertions) — основа тестов
Базовые проверки
// Равенство
$this->assertEquals(5, $result); // 5 == $result
$this->assertSame(5, $result); // 5 === $result (строже)
// Истина/Ложь
$this->assertTrue($user->isActive());
$this->assertFalse($user->isBanned());
// Null
$this->assertNull($user->deletedAt);
$this->assertNotNull($user->email);
// Массивы
$this->assertCount(3, $items);
$this->assertContains('apple', $fruits);
$this->assertArrayHasKey('email', $data);
// Строки
$this->assertStringContainsString('error', $message);
$this->assertStringStartsWith('Hello', $greeting);
// Исключения
$this->expectException(InvalidArgumentException::class);
$user->setAge(-5); // должно выбросить исключениеПример: полноценный тест класса
src/Order.php:
<?php
namespace App;
class Order
{
private array $items = [];
private float $tax = 0.2; // 20% налог
public function addItem(string $name, float $price): void
{
if ($price < 0) {
throw new \InvalidArgumentException('Price cannot be negative');
}
$this->items[] = ['name' => $name, 'price' => $price];
}
public function getSubtotal(): float
{
return array_sum(array_column($this->items, 'price'));
}
public function getTotal(): float
{
return $this->getSubtotal() * (1 + $this->tax);
}
public function isEmpty(): bool
{
return empty($this->items);
}
}tests/Unit/OrderTest.php:
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use App\Order;
class OrderTest extends TestCase
{
private Order $order;
protected function setUp(): void
{
parent::setUp();
$this->order = new Order();
}
public function test_new_order_is_empty()
{
$this->assertTrue($this->order->isEmpty());
$this->assertEquals(0, $this->order->getSubtotal());
}
public function test_can_add_item()
{
$this->order->addItem('Laptop', 1000);
$this->assertFalse($this->order->isEmpty());
$this->assertEquals(1000, $this->order->getSubtotal());
}
public function test_calculates_total_with_tax()
{
$this->order->addItem('Laptop', 1000);
// 1000 + 20% = 1200
$this->assertEquals(1200, $this->order->getTotal());
}
public function test_multiple_items()
{
$this->order->addItem('Laptop', 1000);
$this->order->addItem('Mouse', 50);
$this->assertEquals(1050, $this->order->getSubtotal());
$this->assertEquals(1260, $this->order->getTotal());
}
public function test_throws_exception_for_negative_price()
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Price cannot be negative');
$this->order->addItem('Broken', -100);
}
}Запуск:
vendor/bin/phpunit tests/Unit/OrderTest.php
..... 5 / 5 (100%)
OK (5 tests, 10 assertions)🎨 Data Providers — тестирование с разными данными
Вместо копипаста тестов для разных входных данных:
class CalculatorTest extends TestCase
{
/**
* @dataProvider additionProvider
*/
public function test_addition(int $a, int $b, int $expected)
{
$calc = new Calculator();
$this->assertEquals($expected, $calc->add($a, $b));
}
public static function additionProvider(): array
{
return [
'positive numbers' => [2, 3, 5],
'negative numbers' => [-2, -3, -5],
'mixed' => [10, -5, 5],
'with zero' => [0, 5, 5],
'large numbers' => [1000, 2000, 3000],
];
}
}Результат:
..... 5 / 5 (100%)
✓ Addition with data set "positive numbers"
✓ Addition with data set "negative numbers"
✓ Addition with data set "mixed"
✓ Addition with data set "with zero"
✓ Addition with data set "large numbers"🚦 Практика: TDD-цикл (Red-Green-Refactor)
Задача: Валидатор email
Шаг 1: RED — пишем ТЕСТ (он упадет)
class EmailValidatorTest extends TestCase
{
public function test_valid_email_passes()
{
$validator = new EmailValidator();
$this->assertTrue($validator->isValid('test@example.com'));
}
}Запуск → ❌ Error: Class EmailValidator not found
Шаг 2: GREEN — пишем КОД (минимум для прохождения)
class EmailValidator
{
public function isValid(string $email): bool
{
return true; // Заглушка, чтобы тест прошел
}
}Запуск → ✅ OK (1 test)
Шаг 3: Добавляем тест, который УПАДЕТ
public function test_invalid_email_fails()
{
$validator = new EmailValidator();
$this->assertFalse($validator->isValid('not-an-email'));
}Запуск → ❌ Failed: true is not false
Шаг 4: Реализуем валидацию
class EmailValidator
{
public function isValid(string $email): bool
{
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
}Запуск → ✅ OK (2 tests)
Шаг 5: REFACTOR — улучшаем без ломки тестов
class EmailValidator
{
public function isValid(string $email): bool
{
// Добавили проверку домена
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return false;
}
$domain = substr($email, strpos($email, '@') + 1);
return checkdnsrr($domain, 'MX');
}
}Тесты все еще проходят → уверенность в рефакторинге.
⚡ Полезные команды PHPUnit
# Запустить все тесты
vendor/bin/phpunit
# Запустить конкретный файл
vendor/bin/phpunit tests/Unit/OrderTest.php
# Запустить конкретный метод
vendor/bin/phpunit --filter test_addition
# Запустить только Unit-тесты
vendor/bin/phpunit --testsuite Unit
# Покрытие кода (нужен Xdebug)
vendor/bin/phpunit --coverage-html coverage
# Подробный вывод
vendor/bin/phpunit --verbose
# Остановиться на первой ошибке
vendor/bin/phpunit --stop-on-failure🛠️ Упражнения
Упражнение 1: Базовый тест
Создай класс Temperature с методом toFahrenheit($celsius). Напиши тесты:
- 0°C = 32°F
- 100°C = 212°F
- -40°C = -40°F
Решение
// src/Temperature.php
class Temperature
{
public function toFahrenheit(float $celsius): float
{
return ($celsius * 9/5) + 32;
}
}
// tests/Unit/TemperatureTest.php
class TemperatureTest extends TestCase
{
private Temperature $temp;
protected function setUp(): void
{
$this->temp = new Temperature();
}
public function test_zero_celsius()
{
$this->assertEquals(32, $this->temp->toFahrenheit(0));
}
public function test_boiling_point()
{
$this->assertEquals(212, $this->temp->toFahrenheit(100));
}
public function test_negative_forty()
{
$this->assertEquals(-40, $this->temp->toFahrenheit(-40));
}
}Упражнение 2: Data Provider
Класс PasswordStrength проверяет надежность пароля. Используй Data Provider для тестирования:
- Слабые: "123", "password", "abc"
- Сильные: "P@ssw0rd!", "MySecure123#"
Решение
class PasswordStrength
{
public function isStrong(string $password): bool
{
if (strlen($password) < 8) return false;
if (!preg_match('/[A-Z]/', $password)) return false;
if (!preg_match('/[a-z]/', $password)) return false;
if (!preg_match('/[0-9]/', $password)) return false;
if (!preg_match('/[\W]/', $password)) return false;
return true;
}
}
class PasswordStrengthTest extends TestCase
{
/**
* @dataProvider weakPasswordsProvider
*/
public function test_weak_passwords($password)
{
$checker = new PasswordStrength();
$this->assertFalse($checker->isStrong($password));
}
/**
* @dataProvider strongPasswordsProvider
*/
public function test_strong_passwords($password)
{
$checker = new PasswordStrength();
$this->assertTrue($checker->isStrong($password));
}
public static function weakPasswordsProvider(): array
{
return [
['123'],
['password'],
['abc'],
['NoNumbers!'],
['nocapitals123!']
];
}
public static function strongPasswordsProvider(): array
{
return [
['P@ssw0rd!'],
['MySecure123#'],
['Qwerty123$']
];
}
}Упражнение 3: TDD — корзина покупок
Методом TDD создай класс Cart:
- Начни с теста
test_new_cart_is_empty - Добавь
addProduct($name, $price, $quantity) - Реализуй
getTotal() - Добавь скидку 10% при сумме > 1000
Решение (только тесты, реализацию пиши сам!)
class CartTest extends TestCase
{
private Cart $cart;
protected function setUp(): void
{
$this->cart = new Cart();
}
public function test_new_cart_is_empty()
{
$this->assertEquals(0, $this->cart->getTotal());
}
public function test_add_single_product()
{
$this->cart->addProduct('Laptop', 1000, 1);
$this->assertEquals(1000, $this->cart->getTotal());
}
public function test_add_multiple_quantity()
{
$this->cart->addProduct('Mouse', 50, 3);
$this->assertEquals(150, $this->cart->getTotal());
}
public function test_discount_applies_over_1000()
{
$this->cart->addProduct('Laptop', 1200, 1);
// 1200 - 10% = 1080
$this->assertEquals(1080, $this->cart->getTotal());
}
public function test_no_discount_under_1000()
{
$this->cart->addProduct('Mouse', 500, 1);
$this->assertEquals(500, $this->cart->getTotal());
}
}📝 Чек-лист: Что ты теперь знаешь
- [ ] Понимаю зачем нужны тесты (документация, уверенность, регресс)
- [ ] Знаю пирамиду тестирования (Unit → Integration → E2E)
- [ ] Умею устанавливать PHPUnit
- [ ] Могу написать базовый тест с
assertEquals,assertTrue - [ ] Понимаю
setUp()и подготовку данных - [ ] Использую Data Providers для множества входных данных
- [ ] Знаю цикл TDD: Red → Green → Refactor
- [ ] Умею запускать тесты разными способами
🎓 Итоги
Тесты — не "дополнительная работа", а инвестиция в будущее. Первые 2 недели кажется, что пишешь в 2 раза медленнее. Через месяц понимаешь, что экономишь часы на дебаге.
Следующая глава: Unit-тесты глубже — моки, стабы, тестирование зависимостей.
❓ Вопросы для самопроверки
- В чем разница между
assertEqualsиassertSame? - Когда использовать Unit-тесты, а когда Integration?
- Что такое Data Provider и зачем он нужен?
- Объясни цикл TDD своими словами
- Почему тест должен проверять одну вещь?
Если ответил на все — готов к следующей главе! 🚀