Определение unit-теста
Исторически сложилось два взгляда на то, что из себя должен представлять unit-тест:
- “лондонская школа” (или “мокистский” подход),
- “детройтская школа” (или “классический” подход).
И тот и другой подход сходятся в том, что “юниты” должны быть изолированы. Принципиальное различие в том что именно понимать под “юнитом” и от чего именно изолировать.
“Мокисты” считают юнитом единицу кода (чаще всего - один конкретный класс), а “классики” - единицу поведения, или “фичу” (модуль из одного или нескольких классов). ****“Мокисты” считают что каждый юнит должен тестироваться в изоляции от других юнитов, а “классики” - что каждая фича должна тестироваться в изоляции от меж-процессных взаимодействий (то есть тесты не должны полагаться на что-то “извне” запущенного процесса - системное время, базу данных, файловую систему, БД, сеть и т.д.).
Например, имеем фичу “Машина”. Машина имеет стартовую цену и запас хода двигателя. Машина может ехать пока количество пройденных километров не превысит 300 000, при этом теряя в стоимости 0.01% на каждый километр пути. Допустим, мы написали код так:
class Car
{
private Engine $engine;
private float $currentPrice;
public function __construct(Engine $engine, float $startPrice)
{
$this->engine = $engine;
$this->currentPrice = $startPrice;
}
public function run(int $kilometers)
{
$this->engine->run($kilometers);
$this->currentPrice -= ($kilometers * $this->currentPrice / 10000);
}
public function getCurrentPrice()
{
return $this->currentPrice;
}
}
class Engine
{
const MAX_KILOMETERS = 300_000;
private int $counter = 0;
public function run(int $kilometers)
{
if ($this->counter > self::MAX_KILOMETERS) {
throw new MaintenanceRequiredException();
}
$this->counter += $kilometers;
echo 'Drrruuummmm!!!';
}
}
“Мокисты” рассматривают Car
и Engine
как два отдельных юнита и стремятся к соответствию “один класс = один тест”. Все зависимости тестируемого в данный момент класса заменяются моками:
// mockists' unit-test
// CarTest.php
function test_car_becomes_cheaper()
{
// Cоздаем мок для Engine, ибо сейчас тестируем только Car
$engineMock = Mock::create(Engine::class);
$startPrice = 10_000;
$car = new Car($engineMock, $startPrice);
$car->run(1000);
assertEquals(9990, $car->getCurrentPrice());
// также надо проверять что Car правильно взаимодействоал с Engine
$engineMock->assertCalledOnce('run', [1000]);
}
// EngineTest.php
function test_engine_cannot_run_more_than_300_000()
{
// Теперь тестируем только Engine
$engine = new Engine();
expectException(MaintenanceRequiredException::class);
$engine->run(300_001);
}
А для “классиков” здесь будет один юнит - Car
, так как именно Car
реализует интересующее нас поведение. А Engine
- это подробности реализации этого поведения, для него отдельный тест не пишется:
// CarTest.php
function test_car_becomes_cheeper()
{
$startPrice = 10_000;
// Тестируем Car целиком, как единицу поведения.
$car = new Car(new Engine(), $startPrice);
$car->run(1000);
assertEquals(9990, $car->getCurrentPrice());
}
function test_car_cannot_run_more_than_300_000()
{
// меж-классовые взаимодействия не проверям,
// рассматриваем фичу как одно целое
$car = new Car(new Engine(), 10_000);
expectException(MaintenanceRequiredException::class);
$car->run(300_001);
}
При таком подходе соответствия “один класс = один тест” не будет. А моки нужны только для замены классов, обеспечивающих меж-процессные взаимодействия (например, репозиториев, или сетевых клиентов), т.е. инфраструктурного кода. Про виды моков - в другой раз.
Оба подхода имеют свои плюсы и минусы.
“Лондонская школа”
+++ Легче определить причину падения тестов: просто смотрим в тот класс, чей тест упал.
+++ Легко создавать инстансы тестируемых классов, просто мокая все зависимости.
— Из-за обилия моков и проверок меж-классовых взаимодействий тесты становятся более “хрупкие”, то есть завязанные
на внутренне устройство фичи. При малейшем рефакторинге придется править много тестов.
— Тесты описывают ожидания очень гранулировано. Читая их, трудно понять требования к системе в целом, есть риск
“не увидеть леса за деревьями”.
— Требуются вспомогательные библиотеки для создания моков.
“Детройтская школа”
+++ Так как тестируем только наблюдаемое извне поведение фичи, достигается наилучшее соотношение “защита от багов / трудозатраты на поддержку тестов”. Внутри фичу можно рефакторить по-всякому - например вынести счетчик километров в отдельный класс, если логика расчетов усложнится, и т.д - тесты при этом править не придется.
+++ Тесты одновременно являются своеобразной документацией, так как описывают ожидания от системы в целом, используя формулировки более приближенные к реальному ТЗ.
— При падении тестов не сразу ясно в каком именно месте причина, так как в тесте задействуется сразу несколько
реальных классов.
— В тестах нужно создавать полноценные инстансы со всеми зависимыми объектами, что может быть непростой задачей и
потребовать вспомогательных методов-фабрик.
Лично я в этом плане разделяю предпочтения В.Хорикова - автора книги про юнит-тестирование - и пишу тесты в классическом стиле, с минимальным количеством моков. Больше информации по теме можно найти в его статье на Хабре или в самой книге.
Статьи по теме: