Исторически сложилось два взгляда на то, что из себя должен представлять 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);
}

При таком подходе соответствия “один класс = один тест” не будет. А моки нужны только для замены классов, обеспечивающих меж-процессные взаимодействия (например, репозиториев, или сетевых клиентов), т.е. инфраструктурного кода. Про виды моков - в другой раз.

Оба подхода имеют свои плюсы и минусы.

“Лондонская школа”

+++ Легче определить причину падения тестов: просто смотрим в тот класс, чей тест упал.

+++ Легко создавать инстансы тестируемых классов, просто мокая все зависимости.

— Из-за обилия моков и проверок меж-классовых взаимодействий тесты становятся более “хрупкие”, то есть завязанные
на внутренне устройство фичи. При малейшем рефакторинге придется править много тестов.

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

— Требуются вспомогательные библиотеки для создания моков.

“Детройтская школа”

+++ Так как тестируем только наблюдаемое извне поведение фичи, достигается наилучшее соотношение “защита от багов / трудозатраты на поддержку тестов”. Внутри фичу можно рефакторить по-всякому - например вынести счетчик километров в отдельный класс, если логика расчетов усложнится, и т.д - тесты при этом править не придется.

+++ Тесты одновременно являются своеобразной документацией, так как описывают ожидания от системы в целом, используя формулировки более приближенные к реальному ТЗ.

— При падении тестов не сразу ясно в каком именно месте причина, так как в тесте задействуется сразу несколько
реальных классов.

— В тестах нужно создавать полноценные инстансы со всеми зависимыми объектами, что может быть непростой задачей и
потребовать вспомогательных методов-фабрик.

Лично я в этом плане разделяю предпочтения В.Хорикова - автора книги про юнит-тестирование - и пишу тесты в классическом стиле, с минимальным количеством моков. Больше информации по теме можно найти в его статье на Хабре или в самой книге.

Статьи по теме: