Перевод статьи https://blog.frankdejonge.nl/testing-without-mocking-frameworks/

Тестирование без мок-фреймворков Link to heading

За эти годы я сильно поменял подходы к написанию кода. От применения различных хаков, чтобы заставить код работать до использования TDD/BDD/DDD. Одно из самых больших изменений в моей карьере как разработчика произошло, когда стал задумываться почему и как я тестирую свой код. В частности, мой взгляд на мок-фреймворки сильно изменился. Несколько лет назад я не мог представить свою жизнь без них, но сейчас наоборот не советую использовать.

После нескольких бесед в Twitter, я подумал что было бы неплохо поделиться своим видением почему я ушёл от использования мок-фреймворков и что использую как альтернативу.

Поддержка кода, который использует мок-фреймворки Link to heading

С помощью мок-фреймворков можно с легкостью изолировать свой код. И этот путь очень привлекателен на начальных этапах проекта. Во время этого этапа, в основном код только добавляется, по мере появления новых функций, т.к. такое быстрое наращивание функционала приложения дает хорошие результаты для бизнеса. Наш TTM(time to market) очень короткий, накладные расходы по внедрению новых возможностей очень малы. В течении этого времени функции, как правило, не очень сложны по сравнению с более поздними этапами развития приложения.

Мы добавляем код, а не изменяем его, что означает, что мы пропускаем важную часть цикла обратной связи. Когда мы создаем код, то находимся непосредственно в контексте нового функционала, и нам не нужен код, чтобы понимать как сделать свою работу. Но когда мы изменяем, то подходы сильно отличаются. Мы используем различные инструменты, техники и у нас разные потребности. Техники рефакторинга и легкого понимания становится более важны. Способность делать автоматизированный рефакторинг, такой как переименование и перемещение становится жизненно важным. Давайте взглянем на пример теста, который использует мок-фреймворк:

/**
 * @test
 */
public function testing_something_with_mocks(): void  
{
    $mock = Mockery::mock(ExternalDependency::class);
 
    $mock->shouldReceive('oldMethodName')
        ->once()
        ->with('some_argument')
        ->andReturn('some_response');
 
    $dependingCode = new DependingCode($mock);
    $result = $dependingCode->performOperation();
 
    $this->assertEquals('some_response', $result);

}

В этом тесте выше, мок создается для класса ExternalDependency. Выставляются ожидания и возвращаемое значение. Это не очень сложный пример, но что произойдет когда код, который мы тестируем потребуется изменить? Для примера, что произойдет, если мы попытается отрефакторить этот код и переименовать метод oldMethodName в newMethodName? После переименования в IDE наш код сломается:

В IDE, таких как PhpStorm, есть несколько способов для поиска и исправления таких участков, однако результат может быть непредсказуем и приводящий к ошибкам. Когда для названий методов используются более общие (или более распространенные) термины, то типы динамического поиска не столь надежны. Переименовывая их, нам приходится вручную проверять каждое вхождение. Я лично сталкивался с ситуациями, когда выполнение этих ручных проверок обходилось дороже, чем переименование метода вручную.

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

Так что мы можем сделать, чтобы улучшить нашу ситуацию? Для начала, давайте превратим наш пример во что-то более конкретное.

Выставление счетов клиентам на основе рассчитанных затрат Посмотрите на ниже приведенный код. В этом примере, мы моделируем сервис выставления счетов. Он имеет один метод для выставления счета по ID клиента для выбранного периода.

use League\Flysystem\Filesystem;  
use League\Flysystem\UnableToWriteFile;
 
class InvoiceService  
{
    private Filesystem $storage;
 
    public function __construct(Filesystem $storage)
    {
        $this->storage = $storage;
    }
 
    public function invoiceClient(string $clientId, InvoicePeriod $period): void
    {
        $cost = $this->calculateCosts($clientId, $period);
        $invoice = new Invoice($clientId, $cost, $period);
 
        try {
            $path = '/invoices/' . $clientId . '/' . $period->toString() . '.txt';
            $this->storage->write($path, json_encode($invoice));
        } catch (UnableToWriteFile $exception) {
            throw new UnableToInvoiceClient(
                "Unable to upload the invoice.", 0, $exception);
        }
    }
 
    private function calculateCosts(string $clientId, InvoicePeriod $period): int
    {
        return 42;
    }

}

Итак, как мы можем протестировать этот код? Давайте сперва попробуем сделать это с использованием моков.

use League\Flysystem\Filesystem;  
use PHPUnit\Framework\TestCase;
 
class TestInvoiceServiceWithMocksTest extends TestCase  
{
    /**
     * @test
     */
    public function invoicing_a_client(): void
    {
        // Need to work around marking this test as risky
        $this->expectNotToPerformAssertions();
 
        $mock = Mockery::mock(Filesystem::class);
        $mock->shouldReceive('write')
            ->once()
            ->with('/invoices/abc/2020/3.txt', '{"client_id":"abc","amount":42,"invoice_period":{"year":2020,"month":3}}');
 
        $invoicePeriod = InvoicePeriod::fromDateTime(DateTimeImmutable::createFromFormat('!Y-m', '2020-03'));
        $invoiceService = new InvoiceService($mock);
        $invoiceService->invoiceClient('abc', $invoicePeriod);
    }
 
    protected function tearDown(): void
    {
        Mockery::close();
    }

}

Когда я смотрю на этот код, пару вещей привлекают мое внимание. Кроме того, что PHPUnit приходится обманом заставить поместить этот тест как “не рискованный”, я хочу отметить следующее:

  1. Количество “поддельного” кода и остального
  2. Высокая связанность между тестом и реализацией
  3. Тест не описывает поведение, а валидирует реализацию

Количество “поддельного” кода и остального Link to heading

В этом тесте, хотя и очень простом, подготовка моков уже перевешивает весь остальной код теста, а в более сложных случаях будет еще хуже. Это вредит пониманию в дальнейшем, т.к. более крупные тесты, как правило, сложнее понять.

Высокая связанность между тестом и реализацией Link to heading

Подготовка теста очень сильно повышает связность между деталями реализации и тестом. Тесту нужно знать как счет сериализуется в JSON, чтобы сделать верные утверждения. Когда внутренняя реализация меняется, то нам также необходимо будет изменять тест. Если мы хотим переименовать поле в JSON, то нам нужно также нужно будет изменить это в наших тестах. Это делает тест хрупким. Стабильные тесты, с другой стороны, позволяют нам производить рефакторинг внутренних деталей реализации без нужды изменять сам тест, который это проверяет.

Тест не описывает поведение, он валидирует реализацию Link to heading

Когда я начинал писать тесты, то я был сильно сфокусирован на написании тестирующего кода. Только позже, я осознал, что мои тесты были описанием того КАК я хочу чтобы мой код себя вел. Это полностью изменило мои представления о написании тестов. Именно эта перспектива направила меня в сторону, которая сделала мой код более стабильным, мои тесты менее хрупкими и повысила уровень доверия, который тесты давали мне и моим коллегам. Можно утверждать, что связь между объектами является или должна являться частью спецификации. Я бы сказал, что есть и другие способы проверить это, которые дают больше уверенности, чем моки. Я подробнее расскажу об этом позже.

Улучшим наш тест Давайте посмотрим на несколько способов, с помощью которых мы можем улучшить наш тест. Для начала, удалим мок. Этот код использует Flysystem, поэтому мы можем написать in-memory реализацию для наших тестов.

/**
 * @test
 */
public function invoicing_the_client(): void  
{
    // Arrange
    $storage = new Filesystem(new InMemoryFilesystemAdapter());
    $invoicePeriod = InvoicePeriod::fromDateTime(DateTimeImmutable::createFromFormat('!Y-m', '2020-03'));
    $invoiceService = new InvoiceService($storage);
 
    // Act
    $invoiceService->invoiceClient('abc', $invoicePeriod);
 
    // Assert
    $expectedContents = '{"client_id":"abc","amount":42,"invoice_period":{"year":2020,"month":3}}';
    $this->assertTrue($storage->fileExists('/invoices/abc/2020/3.txt'));
    $this->assertEquals($expectedContents, $storage->read('/invoices/abc/2020/3.txt'));

}

Теперь этот код взаимодействует с реальной реализацией. В наборе тестов Flysystem in-memory реализация тестируется по тому же набору тестов, что и любая другая реализация. Это позволяет безопасно подменять реализации. Новая подготовка теста следует AAA-шаблону: Arrange, Act, Assert (Упорядочить, действовать, утверждать). При последовательном использовании этот шаблон делает набор тестов очень предсказуемым. При подготовке мок-фреймворка существует тенденция смешивать разделы Arrange и Act, что делает тест менее понятным. Несмотря на то, что нам удалось убрать моки и наши тесты стали более читаемыми, мы все еще можем улучшить его. Тест все еще много знает о деталях реализации. Тот факт, что нам нужно написать JSON в этом тесте - отличный индикатор.

Определение значимых границ Link to heading

На старте проекта, в основном код добавляется в проект. На этом этапе отсутствуют некоторые аспекты цикла обратной связи. Только когда проекты созреют, мы столкнемся с последствиями действий, предпринятых самим собой в прошлом. Код, который трудно поддерживать имеет высокую стоимость изменения. При этом, такой код часто имеет низкую стоимость внедрения. Это нормально, потому что очень сложно определить все правильные границы в начале проекта. Определенные границы становятся понятными только после более глубокого изучения доменной модели и/или области проблемы. Делаются наивные предположения, что приводит к сильной связности. Когда вы сталкиваетесь с тем, что сложно поддерживать, это может быть подходящим моментом для создания лучших, более значимых границ. В нашем примере, мой воображаемый доменный эксперт говорил мне, что счета необходимо отправлять на портал выставления счетов. Портал может сообщить вам о том, был ли представлен конкретный счет. Давайте опишем это интерфейсом:

interface InvoicePortal  
{
    /**
     * @throws UnableToInvoiceClient
     */
    public function submitInvoice(Invoice $invoice): void;
 
    public function wasInvoiceSubmitted(Invoice $invoice): bool;

}

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

use DateTimeImmutable;  
use PHPUnit\Framework\TestCase;
 
abstract class InvoicePortalTestCase extends TestCase  
{
    abstract protected function createInvoicePortal(): InvoicePortal;
 
    /**
     * @test
     */
    public function submitting_an_invoice_successfully(): void
    {
        // Arrange
        $invoicePortal = $this->createInvoicePortal();
        $invoicePeriod = InvoicePeriod::fromDateTime(DateTimeImmutable::createFromFormat('!Y-m', '2020-03'));
        $invoice = new Invoice('abc', 42, $invoicePeriod);
 
        // Act
        $invoicePortal->submitInvoice($invoice);
 
        // Assert
        $this->assertTrue($invoicePortal-> wasInvoiceSubmitted($invoice));
    }
 
    /**
     * @test
     */
    public function detecting_if_an_invoice_was_NOT_submitted(): void
    {
        // Arrange
        $invoicePortal = $this->createInvoicePortal();
        $invoicePeriod = InvoicePeriod::fromDateTime(DateTimeImmutable::createFromFormat('!Y-m', '2020-03'));
        $invoice = new Invoice('abc', 42, $invoicePeriod);
 
        // Act
        $wasInvoiceSubmitted = $invoicePortal->wasInvoiceSubmitted($invoice);
 
        // Assert
        $this->assertFalse($wasInvoiceSubmitted);
    }
 
    /**
     * @test
     */
    public function failing_to_submit_an_invoice(): void
    {
        // ...
    }

}

Этот абстрактный тест позволяет повторно использовать подготовку теста для любого количества реализаций интерфейса портала выставления счетов. Можно сказать, что данный тест является общим контрактом для реализаций интерфейса.

Вот реализация, основанная на Flysystem:

use League\Flysystem\Filesystem;  
use League\Flysystem\InMemory\InMemoryFilesystemAdapter;
 
class FlysystemInvoicePortalTest extends InvoicePortalTestCase  
{
    protected function createInvoicePortal(): InvoicePortal
    {
        return new FlysystemInvoicePortal(new Filesystem(new InMemoryFilesystemAdapter()));
    }
}
 
// --------- NEW FILE --------- //
 
use League\Flysystem\FilesystemOperationFailed;  
use League\Flysystem\FilesystemOperator;  
use League\Flysystem\UnableToReadFile;
 
class FlysystemInvoicePortal implements InvoicePortal  
{
    private FilesystemOperator $filesystem;
 
    public function __construct(FilesystemOperator $filesystemWriter)
    {
        $this->filesystem = $filesystemWriter;
    }
 
    public function submitInvoice(Invoice $invoice): void
    {
        $json = json_encode($invoice);
        $path = sprintf('/invoices/%s/%s.txt', $invoice->clientId(), $invoice->invoicePeriod()->toString());
 
        try {
            $this->filesystem->write($path, $json);
        } catch (FilesystemOperationFailed $exception) {
            throw new UnableToInvoiceClient("Unable to upload invoice to portal", 0, $exception);
        }
    }
 
    public function wasInvoiceSubmitted(Invoice $invoice): bool
    {
        $path = sprintf('/invoices/%s/%s.txt', $invoice->clientId(), $invoice->invoicePeriod()->toString());
 
        try {
            $contents = $this->filesystem->read($path);
        } catch (UnableToReadFile $exception) {
            return false;
        }
 
        $storedInvoice = Invoice::fromJsonPayload(json_decode($contents, true));
 
        // Compare by value for VO equality.
        return $invoice == $storedInvoice;
    }

}

Создание фальшивых реализаций Link to heading

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

class FakeInvoicePortalTest extends InvoicePortalTestCase  
{
    protected function createInvoicePortal(): InvoicePortal
    {
        return new FakeInvoicePortal();
    }
}
 
// --------- NEW FILE --------- //
 
class FakeInvoicePortal implements InvoicePortal  
{
    private array $submitInvoices = [];
 
    /**
     * @inheritDoc
     */
    public function submitInvoice(Invoice $invoice): void
    {
        $this->submitInvoices[] = $invoice;
    }
 
    public function wasInvoiceSubmitted(Invoice $invoice): bool
    {
        return in_array($invoice, $this->submitInvoices, false);
    }

}

Данная фальшивая реализация проверяется с помощью одного и того же абстрактного теста. Это означает, что мы можем быть уверены, что она будет подчиняться тем же правилам, что и реальная реализация. С этой подделкой мы теперь можем еще раз протестировать наш сервис выставления счетов. И приходим к финальному результату:

use PHPUnit\Framework\TestCase;
 
class InvoiceServiceTest extends TestCase  
{
    /**
     * @test
     */
    public function invoicing_a_client_successfully(): void
    {
        // Arrange
        $invoicePortal = new FakeInvoicePortal();
        $invoiceService = new InvoiceService($invoicePortal);
        $invoicePeriod = InvoicePeriod::fromDateTime(DateTimeImmutable::createFromFormat('!Y-m', '2020-03'));
 
        // Act
        $invoiceService->invoiceClient('abc', $invoicePeriod);
 
        // Assert
        $expectedInvoice = new Invoice('abc', 42, $invoicePeriod);
        $this->assertTrue($invoicePortal-> wasInvoiceSubmitted($expectedInvoice));
    }

}

Чего мы достигли? Link to heading

Теперь наши тесты следуют AAA-шаблону (Arrange, Act, Assert) и больше не полагаются на мок-фреймворк. Кроме того, использование этого подхода дает нам следующие преимущества:

  • Тесты больше не содержат детали реализации.

Т.к. InvoiceService зависит от InvoicePortal, то мы больше не видим в тестах ни одного кода, связанного с Flysystem. Это делает тесты более стабильными. Теперь над любой реализацией InvoicePortal может быть свободно произведен рефакторинг без необходимости изменения тестов.

  • Более чистый код высокого уровня.

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

  • Предсказуемый рефакторинг с помощью IDE.

Без мок-фреймворков, система типов сделает всю работу за нас. IDE могут выполнять операции рефакторинга без необходимости вникать в темное искусство поиска волшебных строк.

  • Поддельные реализации с низкой степенью сложности легко контролировать.

Поддельная реализация нашего интерфейса InvoicePortal очень проста. Мы можем добавлять методы, чтобы влиять на его поведение В качестве примера можно привести инсценировку исключений или фиксацию ответов.

Больше примеров. Link to heading

Я создал репозиторий примеров, которые вы можете посмотреть, как можно ожидать исключения в тестах, а также как создавать контрактные тесты для сценариев сбоя. Просмотрите пример, если хотите погрузиться немного глубже. Надеюсь вам понравилось эта статья, до следующего раза!