Как пишутся юнит тесты

A> 100% покрытие тестами должно быть следствием, а не целью.

Первые шаги

Вы, вероятно, уже слышали про unit-тестирование.
Оно довольно популярно сейчас.
Я довольно часто общаюсь с разработчиками, которые утверждают, что не начинают писать код, пока не напишут тест для него.
TDD-маньяки!
Начинать писать unit-тесты довольно сложно, особенно если вы пишете, используя фреймворки, такие как Laravel.
Unit-тесты одни из лучших индикаторов качества кода в проекте.
Фреймворки пытаются сделать процесс добавления новых фич как можно более быстрым, позволяя срезать углы в некоторых местах, но высоко-связанный код обычная тому цена.
Сущности железно связанные с базой данных, классы с большим количеством зависимостей, которые бывает трудно найти (Laravel фасады).
В этой главе я постараюсь протестировать код Laravel приложения и показать главные трудности, но начнем с самого начала.

Чистая функция — это функция, результат которой зависит только от введенных данных.
Она не меняет никакие внешние значения и просто вычисляет результат.
Примеры:

function strpos(string $needle, string $haystack)
function array_chunk(array $input, $size, $preserve_keys = null)

Чистые функции очень простые и предсказуемые.
Unit-тесты для них писать легко.
Попробуем написать простую функцию (это может быть и методом класса):

function cutString(string $source, int $limit): string
{
    return ''; // просто пустая строка пока
}

class CutStringTest extends PHPUnitFrameworkTestCase
{
    public function testEmpty()
    {
        $this->assertEquals('', cutString('', 20));
    }

    public function testShortString()
    {
        $this->assertEquals('short', cutString('short', 20));
    }

    public function testCut()
    {
        $this->assertEquals('long string shoul...', 
            cutString('long string should be cut', 20));
    }
}

Я здесь использую PHPUnit для написания тестов.
Название функции не очень удачное, но просто взглянув на тесты, можно понять что она делает.
Тесты проверяют результат с помощью assertEquals.
Unit-тесты могут служить документацией к коду, если они такие же простые и легко-читаемые.

Если я запущу эти тесты, то получу такой вывод:

Failed asserting that two strings are equal.
Expected :'short'
Actual   :''

Failed asserting that two strings are equal.
Expected :'long string shoul...'
Actual   :''

Разумеется, ведь наша функция еще не написана.
Время ее написать:

function cutString(string $source, int $limit): string
{
    $len = strlen($source);

    if($len < $limit) {
        return $source;
    }

    return substr($source, 0, $limit-3) . '...';
}

Вывод PHPUnit после этих правок:

OK (3 tests, 3 assertions)

Отлично!
Класс unit-теста содержит список требований к функции:

  • Для пустой строки результат тоже должен быть пуст.
  • Для строк, которые короче лимита, должна вернуться эта строка без изменений.
  • Для строк длиннее лимита, результатом должна стать строка, укороченная до этого лимита с тремя точками в конце.

Успешные тесты говорят о том, что код удовлетворяет требованиям.
Но это не так!
В коде небольшая ошибка и функция не работает как задумано, если длина строки совпадает с лимитом.
Хорошая привычка: если найден баг, надо написать тест, который его воспроизведет и упадёт.
В любом случае, нам нужно будет проверить исправлен ли этот баг и unit-тест хорошее место для этого.
Новые тест-методы:

class CutStringTest extends PHPUnitFrameworkTestCase
{
    // старые тесты
    
    public function testLimit()
    {
        $this->assertEquals('limit', cutString('limit', 5));
    }

    public function testBeyondTheLimit()
    {
        $this->assertEquals('beyondl...', 
                            cutString('beyondlimit', 10));
    }
}

testBeyondTheLimit выполняется хорошо, а testLimit падает:

Failed asserting that two strings are equal.
Expected :'limit'
Actual   :'li...'

Исправление простое: поменять < на <=

function cutString(string $source, int $limit): string
{
    $len = strlen($source);

    if($len <= $limit) {
        return $source;
    }

    return substr($source, 0, $limit-3) . '...';
}

Сразу же запускаем тесты:

OK (5 tests, 5 assertions)

Отлично. Проверка краевых значений (0, длина $limit, длина $limit+1, и т.д.) очень важная часть тестирования.
Многие ошибки находятся именно в этих местах.

Когда я писал функцию cutString, я думал, что длина исходной строки мне понадобится дальше и сохранил её в переменную, но оказалось, что дальше нам нужна только переменная $limit.
Теперь я могу удалить эту переменную.

function cutString(string $source, int $limit): string
{
    if(strlen($source) <= $limit) {
        return $source;
    }

    return substr($source, 0, $limit-3) . '...';
}

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

Я всё внимание обратил на главный функционал и забыл про пред-условия.
Разумеется, параметр $limit в реальном проекте никогда не будет слишком маленький, но хороший дизайн функции предполагает проверку этого значения тоже:

class CutStringTest extends PHPUnitFrameworkTestCase
{
    //...
    
    public function testLimitCondition()
    {
        $this->expectException(InvalidArgumentException::class);
        
        cutString('limit', 4);
    }
}

function cutString(string $source, int $limit): string
{
    if($limit < 5) {
        throw new InvalidArgumentException(
            'The limit is too low');
    }
        
    if(strlen($source) <= $limit) {
        return $source;
    }

    return substr($source, 0, $limit-3) . '...';
}

Вызов expectException проверяет то, что исключение будет выброшено. Если этого не произойдет, то тест будет признан упавшим.

Тестирование классов с состоянием

Чистые функции прекрасны, но в реальном мире слишком много вещей, которые нельзя описать исключительно ими.
Объекты могут иметь состояние.
Unit-тестирование классов с состоянием немного сложнее.
Для таких тестов есть рекомендация делить код теста на три части:

  1. инициализация объекта в нужном состоянии
  2. выполнение тестируемого действия
  3. проверка результата

I> Есть также шаблон AAA: Arrange, Act, Assert, который описывает те же три шага.

Тесты чистых функций тоже имеют эти 3 части, но все они располагаются в одной строке кода.
Начну с простого примера теста воображаемой сущности Статья, которая не является Eloquent моделью.
Её можно создать только с непустым заголовком, а текст может быть пустым.
Но опубликовать эту статью можно только, если её текст не пустой.

class Post
{
    /** @var string */
    public $title;

    /** @var string */
    public $body;

    /** @var bool */
    public $published;

    public function __construct(string $title, string $body)
    {
        if (empty($title)) {
            throw new InvalidArgumentException(
                'Title should not be empty');
        }

        $this->title = $title;
        $this->body = $body;
        $this->published = false;
    }

    public function publish()
    {        
        if (empty($this->body)) {
            throw new CantPublishException(
                'Cant publish post with empty body');
        }

        $this->published = true;
    }
}

Конструктор класса Post — чистая функция, поэтому тесты для нее подобны предыдущим:

class CreatePostTest extends PHPUnitFrameworkTestCase
{
    public function testSuccessfulCreate()
    {
        // инициализация и выполнение
        $post = new Post('title', '');

        // проверка
        $this->assertEquals('title', $post->title);
    }

    public function testEmptyTitle()
    {
        // проверка
        $this->expectException(InvalidArgumentException::class);

        // инициализация и выполнение
        new Post('', '');
    }
}

Однако, метод publish зависит от текущего состояния объекта и части тестов более ощутимы:

class PublishPostTest extends PHPUnitFrameworkTestCase
{
    public function testSuccessfulPublish()
    {
        // инициализация
        $post = new Post('title', 'body');

        // выполнение
        $post->publish();

        // проверка
        $this->assertTrue($post->published);
    }

    public function testPublishEmptyBody()
    {
        // инициализация
        $post = new Post('title', '');

        // проверка
        $this->expectException(CantPublishException::class);

        // выполнение
        $post->publish();
    }
}

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

Тестирование классов с зависимостями

Одной из важных особенностей unit-тестирования является тестирование в изоляции.
Unit (класс, функция или другой модуль) должен быть изолирован от всего остального мира.
Это будет гарантировать, что тест тестирует только этот модуль.
Тест может упасть только по двум причинам: неправильный тест или неправильный код тестируемого модуля.
Тестирование в изоляции даёт нам эту простоту и быстродействие.
Настоящие unit-тесты выполняются очень быстро, поскольку во время их выполнения не происходит никаких тяжелых операций, вроде запросов в базу данных, чтения файлов или вызовов API.
Когда класс просит некоторые зависимости, тест должен их ему предоставить.

Зависимости на реальные классы

В главе про внедрение зависимостей я разговаривал про два типа возможных интерфейсов:

  1. Есть интерфейс и несколько возможных реализаций.
  2. Есть интерфейс и одна реализация.

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

Это пример описанного случая. Класс TaxCalculator был вынесен в свой класс из класса OrderService.

class OrderService
{
    /** @var TaxCalculator $taxCalculator */
    private $taxCalculator;
    
    public function __construct(TaxCalculator $taxCalculator) 
    {
        $this->taxCalculator = $taxCalculator;
    }
    
    public function create(OrderCreateDto $orderCreateDto)
    {
        $order = new Order();
        //...
        $order->sum = ...;
        $order->taxSum = $this->taxCalculator
                            ->calculateOrderTax($order);
        //...
    }
}

Но если мы взглянем на класс OrderService, то увидим, что TaxCalculator не выглядит его зависимостью.
Он не выглядит как что-то внешнее, нужное OrderService для работы.
Он выглядит как часть класса OrderService.

Класс OrderService здесь модуль, который содержит не только класс OrderService, но и класс TaxCalculator.
Класс TaxCalculator должен быть внутренней зависимостью, а не внешней.

class OrderService
{
    /** @var TaxCalculator $taxCalculator */
    private $taxCalculator;
    
    public function __construct() 
    {
        $this->taxCalculator = new TaxCalculator();
    }
    
    //...
}

Теперь всему остальному коду необязательно знать про TaxCalculator.
Unit-тесты могут тестировать класс OrderService не заботясь о предоставлении ему объекта TaxCalculator.
Если условия изменятся и TaxCalculator станет внешней зависимостью (разные алгоритмы подсчета налогов), то зависимость будет несложно сделать публичной, нужно будет просто поставить его как параметр в конструктор и поменять код тестов.

Модуль — весьма широкое понятие.
В начале этой статьи модулем была маленькая функция, а иногда в модуле может содержаться несколько классов.
Программные объекты внутри модуля должны быть сфокусированы на одной ответственности, другими словами, иметь сильную связность.
Когда методы класса полностью независимы друг от друга, класс не является модулем.
Каждый метод класса — это модуль в данном случае.
Возможно, стоит вынести эти методы в отдельные классы, чтобы разработчики не просматривали кучу лишнего кода каждый раз?
Помните я говорил, что часто предпочитаю классы одной команды, такие как PublishPostCommand, а не PostService классы?
Это упрощает тестирование.

Стабы и фейки

Обычно зависимость — это интерфейс, который имеет несколько реализаций.
Использование реальных реализаций этого интерфейса во время unit-тестирования — плохая идея, поскольку там могут проводиться те самые операции ввода-вывода, замедляющие тестирование и не дающие провести тестирование этого модуля в изоляции.
Прогон unit-тестов должен быть быстр как молния, поскольку запускаться они будут часто и важно, чтобы разработчик запустив их не потерял фокус над кодом.
Написал код — прогнал тесты, еще написал код — прогнал тесты.
Быстрые тесты позволят ему оставаться более продуктивным, не позволяя отвлекаться.
Решение в лоб задачи изоляции класса от зависимостей — создание отдельной реализации этого интерфейса, предназначенного просто для тестирования.
Вернемся к предыдущему примеру и вообразим, что TaxCalculator стал зависимостью и это теперь интерфейс с некоей реализацией.

interface TaxCalculator
{
    public function calculateOrderTax(Order $order): float;
}

class OrderService
{
    /** @var TaxCalculator $taxCalculator */
    private $taxCalculator;
    
    public function __construct(TaxCalculator $taxCalculator) 
    {
        $this->taxCalculator = $taxCalculator;
    }
    
    public function create(OrderCreateDto $orderCreateDto)
    {
        $order = new Order();
        //...
        $order->sum = ...;
        $order->taxSum = $this->taxCalculator
                            ->calculateOrderTax($order);
        //...
    }
}

class FakeTaxCalculator implements TaxCalculator
{
    public function calculateOrderTax(Order $order): float
    {
        return 0;
    }
}

class OrderServiceTest extends PHPUnitFrameworkTestCase
{
    public function testCreate()
    {
        $orderService = new OrderService(new FakeTaxCalculator());

        $orderService->create(new OrderCreateDto(...));

        // some assertions
    }
}

Работает!
Такие классы называются фейками.
Библиотеки для unit-тестирования могут создавать такие классы на лету.
Тот же самый тест, но с использованием метода createMock библиотеки PHPUnit:

class OrderServiceTest extends PHPUnitFrameworkTestCase
{
    public function testCreate()
    {
        $stub = $this->createMock(TaxCalculator::class);

        $stub->method('calculateOrderTax')
            ->willReturn(0);

        $orderService = new OrderService($stub);

        $orderService->create(new OrderCreateDto(...));

        // some assertions
    }
}

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

Моки

Иногда разработчик хочет протестировать вызваны ли методы стаба, сколько раз и какие параметры переданы были.

Вообще, я не считаю идею тестирования вызовов методов зависимостей хорошей идеей.
Unit-тест в этом случае начинает знать слишком многое о том, как этот класс работает.
Как следствие, такие тесты очень легко ломаются.
Небольшой рефакторинг и тесты падают.
Если это случается слишком часто, команда может просто забить на unit-тестирование.
Это называется тестированием методом белого ящика.
Тестированием методом черного ящика пытается тестировать только входные и выходные данные, не залезая внутрь.
Разумеется, тестирование черным ящиком намного стабильнее.

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

class OrderServiceTest extends PHPUnitFrameworkTestCase
{
    public function testCreate()
    {
        $stub = $this->createMock(TaxCalculator::class);

        // Конфигурация мок-класса
        $stub->expects($this->once())
            ->method('calculateOrderTax')
            ->willReturn(0);

        $orderService = new OrderService($stub);

        $orderService->create(new OrderCreateDto(...));

        // некоторые проверки
    }
}

Теперь тест проверяет, что во время выполнения метода OrderService::create с переданными параметрами TaxCalculator::calculateOrderTax был вызван ровно один раз.
С мок-классами можно делать различные проверки на значения параметров и количество вызовов, настраивать возвращаемые значения, выбрасывать исключения и т.д.
Я не хочу фокусироваться на этом в данной книге.
Фейки, стабы и моки имеют общее имя — test doubles, название для объектов, которые подставляются вместо реальных с целью тестирования.
Они могут использоваться не только в unit-тестах, но в и интеграционных тестах.

Типы тестов ПО

Люди придумали множество способов тестировать приложения.
Security testing для проверки приложений на различные уязвимости.
Performance testing для проверки насколько хорошо приложение ведет себя при нагрузке.
В этой главе мы сфокусируемся на проверке корректности работы приложений.
Unit-тестирование уже было рассмотрено.
Интеграционное тестирование проверяет совместную работу нескольких модулей.
Пример: Попросить UserService зарегистрировать нового пользователя и проверить, что новая строка создана в базе данных, нужное событие (UserRegistered) было сгенерировано и соответствующий email был послан (ну или хотя бы фреймворк получил команду сделать это).

Функциональное тестирование (приёмочное или E2E — end to end) проверяет приложение на соответствие функциональным требованиям.
Пример: Требование о создании некоей сущности (этот процесс может быть детально описан в QA-документации).
Тест открывает браузер, идёт на специфическую страницу, заполняет поля значениями, «нажимает» кнопку Создать и проверяет, что нужная сущность была создана, путем поиска её на определенной странице.

Тестирование в Laravel

Laravel (текущая версия 6.12) предоставляет много инструментов для различного тестирования.

Инструменты Laravel для функционального тестирования

Инструменты для тестирования HTTP запросов, браузерного и консоли делают функциональное тестирование в Laravel весьма удобным, но как всегда мне не нравятся примеры из документации.
Один из них, совсем немного измененный:

class ExampleTest extends TestCase
{
    public function testBasicExample()
    {
        $response = $this->postJson('/users', [
            'name' => 'Sally',
            'email' => 'sally@example.com'
        ]);

        $response
            ->assertOk()
            ->assertJson([
                'created' => true,
            ]);
    }
}

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

class ExampleTest extends TestCase
{
    public function testDatabase()
    {
        // Сделать запрос на создание пользователя
    
        $this->assertDatabaseHas('users', [
            'email' => 'sally@example.com'
        ]);
    }
}

Хорошо. Напишем другой тест подобным образом:

class PostsTest extends TestCase
{
    public function testDelete()
    {
        $response = $this->deleteJson('/posts/1');

        $response->assertOk();
        
        $this->assertDatabaseMissing('posts', [
            'id' => 1
        ]);
    }
}

А вот тут уже небольшая ловушка.
Абсолютно такая же как и в главе про валидацию.
Просто добавив трейт SoftDeletes в класс Post, мы уроним этот тест.
Однако, приложение работает абсолютно также, выполняет те же самые требования и пользователи этой разницы не заметят.
Функциональные тесты не должны падать в таких условиях.
Тест, который делает запрос в приложение, а потом лезет в базу данных проверять результат, не является настоящим функциональным тестом.
Он знает слишком многое про то, как работает приложение, как оно хранит данные и какие таблицы для этого использует.
Это еще один пример тестирования методом белого ящика.

Как я уже говорил, функциональное тестирование проверяет, удовлетворяет ли приложение функциональным требованиям.
Функциональное тестирование не про базу данных, оно о приложении в целом.
Поэтому нормальные функциональные тесты не лезут внутрь приложения, они работают снаружи.

class PostsTest extends TestCase
{
    public function testCreate()
    {
        $response = $this->postJson('/api/posts', [
            'title' => 'Post test title'
        ]);

        $response
            ->assertOk()
            ->assertJsonStructure([
                'id',
            ]);

        $checkResponse = $this->getJson(
            '/api/posts/' . $response->getData()->id);

        $checkResponse
            ->assertOk()
            ->assertJson([
                'title' => 'Post test title',
            ]);
    }
    
    public function testDelete()
    {
        // Здесь некоторая инициализация, чтобы создать
        // объект Post с id = $postId

        // Удостоверяемся, что этот объект есть 
        $this->getJson('/api/posts/' . $postId)
            ->assertOk();
        
        // Удаляем его
        $this->jsonDelete('/posts/' . $postId)
            ->assertOk();
        
        // Проверяем, что больше в приложении его нет
        $this->getJson('/api/posts/' . $postId)
            ->assertStatus(404);
    }
}

Этому тесту абсолютно все равно как удален объект, с помощью ‘delete’ SQL запроса или с помощью Soft delete шаблона.
Функциональный тест проверяет поведение приложения в целом.
Ожидаемое поведение, если объект удален — он не возвращается по своему id и тест проверяет именно это.

Схема процессинга запросов «POST /posts/» и «GET /post/{id}»:

Что должен видеть функциональный тест:

Моки Laravel-фасадов

Laravel предоставляет удобную реализацию шаблона Service Locator — Laravel-фасады.
Я всегда говорю Laravel-фасады, чтобы не путаться со структурным шаблоном Фасад, который про другое.
Laravel не только предлагает их использовать, но и предоставляет инструменты для тестирования кода, который использует фасады.
Давайте напишем один из предыдущих примеров с использованием Laravel-фасадов и протестируем этот код:

class Poll extends Model
{
    public function options()
    {
        return $this->hasMany(PollOption::class);
    }
}

class PollOption extends Model
{
}

class PollCreated
{
    /** @var int */
    private $pollId;

    public function __construct(int $pollId)
    {
        $this->pollId = $pollId;
    }

    public function getPollId(): int
    {
        return $this->pollId;
    }
}

class PollCreateDto
{
    /** @var string */
    public $title;

    /** @var string[] */
    public $options;

    public function __construct(string $title, array $options)
    {
        $this->title = $title;
        $this->options = $options;
    }
}

class PollService
{
    public function create(PollCreateDto $dto)
    {
        if(count($dto->options) < 2) {
            throw new BusinessException(
                "Please provide at least 2 options");
        }

        $poll = new Poll();

        DB::transaction(function() use ($dto, $poll) {
            $poll->title = $dto->title;
            $poll->save();

            foreach ($dto->options as $option) {
                $poll->options()->create([
                    'text' => $option,
                ]);
            }
        });

        Event::dispatch(new PollCreated($poll->id));
    }
}

class PollServiceTest extends TestCase
{
    public function testCreate()
    {
        Event::fake();

        $postService = new PollService();
        $postService->create(new PollCreateDto(
            'test title', 
            ['option1', 'option2']));

        Event::assertDispatched(PollCreated::class);
    }
}
  • Вызов Event::fake() трансформирует Laravel-фасад Event в мок-объект.
  • Метод PostService::create создаёт опрос с опциями ответа, сохраняет его в базу данных и генерирует событие PollCreated.
  • Вызов Event::assertDispatched проверяет, что это событие было вызвано.

Я вижу несколько недостатков:

  • Это не является unit-тестом.
    Фасад Event был заменен моком, но база данных — нет.
    Реальные строчки будут добавлены в базу данных.
    Для того, чтобы сделать этот тест более чистым, в класс теста обычно добавляют трейт RefreshDatabase, который создаёт базу данных заново каждый раз.
    Это очень медленно.
    Один такой тест может быть выполнен за разумное время, но сотни таких займут уже несколько минут и никто не будет их выполнять после каждый мелких правок.
  • База данных используется в каждом тесте.
    Соответственно, база данных должна быть такая же как и на сервере продакшена.
    Иногда это невозможно, например с некоторыми облачными база данных, соответственно, некоторые баги могут не всплыть при тестировании локально, но вызовут неприятные сюрпризы когда код будет выпущен на продакшен.
  • Тесты проверяют только генерацию событий.
    Для того, чтобы проверить создание записей в базе данных нужно использовать вызовы методов, таких как assertDatabaseHas или что-то вроде PollService::getById, которое делает этот тест неким функциональным тестом к Слою Приложения, поскольку он просит Слой Приложения что-то сделать и проверяет результат тоже вызвав его.
  • Зависимости класса PollService не описаны явно.
    Чтобы понять, что конкретно он требует для своей работы, нужно просмотреть весь его код.
    Это делает написание тестов для него весьма неудобным.
    Хуже всего, если в класс будет добавлена новая зависимость с помощью laravel-фасада.
    Тесты будут продолжать работать как ни в чем не бывало, но не с моком, а с реальной реализацией этого фасада: реальные вызовы API и т.д.
    Я слышал несколько реальных историй, когда разработчики запускали тесты и это приводило к тысячам реальных денежных переводов!
    Я думаю, что ноги у таких случаев растут именно из-за таких вот случаев, когда неожиданно в тесты попадет реальная реализация.

Я называю это «форсированным интеграционным тестированием».
Разработчик хочет написать unit-тест, но код обладает такой высокой связанностью, так крепко сцеплен с фреймворком, что этого просто не получается.
Попробуем это сделать!

Unit-тестирование Слоя Приложения

Отсоединяем код от laravel-фасадов

Для того, чтобы протестировать метод PollService::create в изоляции, нужно убрать использование Laravel-фасадов и базы данных (Eloquent).
Первая часть несложная, у нас есть Внедрение Зависимостей.

  • Фасад Event представляет интерфейс IlluminateContractsEventsDispatcher.
  • Фасад DBIlluminateDatabaseConnectionInterface.

Вообще, последнее не совсем правда. Фасад DB представляет IlluminateDatabaseDatabaseManager, который содержит вот такую вот магию:

class DatabaseManager
{
    /**
     * Dynamically pass methods to the default connection.
     *
     * @param  string  $method
     * @param  array   $parameters
     * @return mixed
     */
    public function __call($method, $parameters)
    {
        return $this->connection()->$method(...$parameters);
    }
}    

Как видите, Laravel использует магию PHP по полной и не всегда уважает принципы ООП.
Спасибо пакету barryvdh/laravel-ide-helper, который помогает обнаруживать те классы, которые реально выполняют нужные действия.

class PollService
{
    /** @var IlluminateDatabaseConnectionInterface */
    private $connection;

    /** @var IlluminateContractsEventsDispatcher */
    private $dispatcher;

    public function __construct(
        ConnectionInterface $connection, Dispatcher $dispatcher)
    {
        $this->connection = $connection;
        $this->dispatcher = $dispatcher;
    }

    public function create(PollCreateDto $dto)
    {
        if(count($dto->options) < 2) {
            throw new BusinessException(
                "Please provide at least 2 options");
        }

        $poll = new Poll();

        $this->connection->transaction(function() use ($dto, $poll) {
            $poll->title = $dto->title;
            $poll->save();

            foreach ($dto->options as $option) {
                $poll->options()->create([
                    'text' => $option,
                ]);
            }
        });

        $this->dispatcher->dispatch(new PollCreated($poll->id));
    }
}

Хорошо. Для интерфейса ConnectionInterface я могу создать фейк класс FakeConnection.
Класс EventFake, который используется, когда происходит вызов Event::fake(), может быть использован напрямую.

use IlluminateSupportTestingFakesEventFake;
//...

class PollServiceTest extends TestCase
{
    public function testCreatePoll()
    {
        $eventFake = new EventFake(
            $this->createMock(Dispatcher::class));

        $postService = new PollService(
            new FakeConnection(), $eventFake);
        
        $postService->create(new PollCreateDto(
            'test title',
            ['option1', 'option2']));

        $eventFake->assertDispatched(PollCreated::class);
    }
}

Этот тест выглядит очень похоже на прошлый, с фасадами, но теперь он намного строже с зависимостями класса PollService.
Но не совсем.
Любой разработчик всё еще может использовать любой фасад внутри класса PollService и тест будет продолжать работать.
Это происходит потому, что здесь используется специальный базовый класс для тестов, предоставляемый Laravel, который полностью настраивает рабочее окружение.

use IlluminateFoundationTestingTestCase as BaseTestCase;

abstract class TestCase extends BaseTestCase
{
    use CreatesApplication;
}

Для unit-тестов это недопустимо, нужно использовать обычный базовый класс PHPUnit:

abstract class TestCase extends PHPUnitFrameworkTestCase
{    
}

Теперь, если кто-то добавит вызов фасада, тест упадёт с ошибкой:

Error : Class 'SomeFacade' not found

Отлично, от laravel-фасадов мы код полностью избавили.

Отсоединяем от базы данных

Отсоединять от базы данных намного сложнее.
Создадим класс репозитория (шаблон Репозиторий), чтобы собрать в нём всю работу с базой данных.

interface PollRepository
{
    //... другие методы

    public function save(Poll $poll);

    public function saveOption(PollOption $pollOption);
}

class EloquentPollRepository implements PollRepository
{
    //... другие методы

    public function save(Poll $poll)
    {
        $poll->save();
    }

    public function saveOption(PollOption $pollOption)
    {
        $pollOption->save();
    }
}

class PollService
{
    /** @var IlluminateDatabaseConnectionInterface */
    private $connection;

    /** @var PollRepository */
    private $repository;

    /** @var IlluminateContractsEventsDispatcher */
    private $dispatcher;

    public function __construct(
        ConnectionInterface $connection, 
        PollRepository $repository, 
        Dispatcher $dispatcher)
    {
        $this->connection = $connection;
        $this->repository = $repository;
        $this->dispatcher = $dispatcher;
    }

    public function create(PollCreateDto $dto)
    {
        if(count($dto->options) < 2) {
            throw new BusinessException(
                "Please provide at least 2 options");
        }

        $poll = new Poll();

        $this->connection->transaction(function() use ($dto, $poll) {
            $poll->title = $dto->title;
            $this->repository->save($poll);

            foreach ($dto->options as $optionText) {
                $pollOption = new PollOption();
                $pollOption->poll_id = $poll->id;
                $pollOption->text = $optionText;

                $this->repository->saveOption($pollOption);
            }
        });

        $this->dispatcher->dispatch(new PollCreated($poll->id));
    }
}

class PollServiceTest extends PHPUnitFrameworkTestCase
{
    public function testCreatePoll()
    {
        $eventFake = new EventFake(
            $this->createMock(Dispatcher::class));

        $repositoryMock = $this->createMock(PollRepository::class);

        $repositoryMock->method('save')
            ->with($this->callback(function(Poll $poll) {
                return $poll->title == 'test title';
            }));

        $repositoryMock->expects($this->at(2))
            ->method('saveOption');

        $postService = new PollService(
            new FakeConnection(), $repositoryMock, $eventFake);
        
        $postService->create(new PollCreateDto(
            'test title',
            ['option1', 'option2']));

        $eventFake->assertDispatched(PollCreated::class);
    }
}

Это корректный unit-тест.
Класс PollService был протестирован в полной изоляции, не касаясь среды Laravel и базы данных.
Но почему это не радует меня?
Причины в следующем:

  • Я был вынужден создать абстракцию с репозиторием исключительно для того, чтобы написать unit-тесты.
    Код класса PollService без него выглядит в разы читабельнее, что весьма важно.
    Это похоже на шаблон Репозиторий, но не является им.
    Он просто пытается заменить операции Eloquent с базой данных.
    Если объект опроса будет иметь больше отношений, то придётся реализовывать методы save%ИмяОтношения% для каждого из них.
  • Запрещены почти все операции Eloquent.
    Да, они будут работать корректно в реальном приложении, но не в unit-тестах.
    Раз за разом разработчики будут спрашивать себя — «а для чего нам эти unit-тесты?»
  • С другой стороны, такие unit-тесты очень сложные.
    Их сложно писать и сложно читать.
    Притом, что это пример один из простейших — просто создание одной сущности с отношениями.
  • Каждая добавленная зависимость заставить переписывать все unit-тесты.
    Это делает поддержку таких тестов весьма трудоемким занятием.

Это сложно измерить, но мне кажется, что польза от таких тестов намного меньше усилий затрачиваемых на них и урону читабельности кода.
В начале этой главы я сказал, что «Unit-тесты — одни из лучших индикаторов качества кода в проекте».
Если код сложно тестировать, скорее всего он обладает высокой связанностью.
Класс PollService точно обладает.
Он содержит основную логику (проверку количества опций ответа и создание сущности с опциями), а также логику приложения (транзакции базы данных, генерация событий, проверку на мат в одном из прошлых вариантов и т.д.).
Это может быть исправлено отделением Слоёв Приложения и Доменной Логики.
Следующая глава об этом.

Стратегия тестирования приложения

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

Дальше (обычно после каких-нибудь болезненных ошибок на продакшене) команда решает что-то изменить.

Первое, очевидное, решение — нанять ручного тестировщика.
Он может описать главные сценарии работы с приложением и после каждого обновления проходить по этим сценариям, проверяя, что приложение работает как требуется (это называется регрессионное тестирование).
А также тестировать новый функционал.

Если приложение продолжит расти, то количество сценариев тоже будет расти.
В то же время, команда наверняка начнет чаще выкатывать обновления и вручную проверять каждый сценарий при каждом обновлении станет невозможно.
Решение — писать автоматические тесты.
Сценарии использования, написанный ручным тестировщиком могут быть сконвертированы в автоматические тесты для Selenium или других инструментов функционального тестирования.

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

А что же unit-тесты?
Да, они могут помочь нам проверить много специфических случаев, которые сложно будет покрыть функциональными тестами, но главная их задача — помогать нам писать код.
Помните пример с cutString в начале главы?
Писать такой код с тестами чаще быстрее, чем без них.
Тесты сразу же проверят код на правильность, проверят поведение кода в краевых случаях и в дальнейшем не позволят изменениям в коде нарушить требования к этому коду.
Написание unit-тестов должно быть простым.
Они не должны быть тяжелым камнем на шее проекта, который постоянно хочется выбросить.
В коде наверняка есть много чистых функций, и писать их с помощью тестов — весьма хорошая практика.

Unit-тесты же для контроллеров или Слоя Приложения, как мы уже убедились ранее, писать весьма неприятно, а поддерживать — тем более.
«Форсированно интеграционные» тесты проще, но они могут быть не очень стабильны.
Если ваш проект имеет основную логику (не связанную с базой данных), которую вы очень хотите покрыть unit-тестами, чтобы, например, проверить поведение при краевых случаях, это весьма толстый намёк на то, что основная логика проекта выросла настолько, что нуждается в отделении от Слоя Приложения.
В свой собственный слой.

Рано или поздно перед вами как перед программистом встанет вопрос:«Как писать юниттесты?». Это связано с простой целью — удостовериться, что множество компонентов вашего сложного приложения работают как единое целое и самое главное правильно.

Unitтесты — это первый этап при выявлении багов в приложении, за ними идут более «тонкие» тесты:

  • интеграционные;

  • приемочные;

  • тесты «руками».

Поэтому к написанию unit-тестов нужно подойти со всей ответственностью.

Как писать юниттесты

Прежде чем писать unitтесты, нужно для начала определиться, а нужны ли они вашей разработке? Потому что не всем проектам необходимо подобное тестирование. К примеру, не нужно знать, как писать unitтесты, если:

  1. Ваш проект — это небольшой html-сайт из 2-5 страниц и с 1-2-мя формами отправки заявки или письма с обратной связью. В таком проекте не присутствует сложная логика, для которой нужно писать юниттесты. Все проверяется «руками» и очень быстро.

  2. Ваш проект — это проект «представления», когда, опять же, отсутствует сложная логика: лендинг, рекламный сайт, флеш-игра, несложная верста или анимация и др.

  3. Вы делаете презентационный софт для выставки и очень сильно ограничены во времени.

  4. Вы создаете идеальный код и всегда вообще без ошибок. А сам код может мгновенно изменяться под требования заказчика, и более того, код способен объяснить заказчику, что его требования — это полная нелепость и непонимание сути заказываемой разработки.

В общем, первые 3 пункта подсказывают, что когда у вас сжаты сроки, сжат бюджет, простая разработка и просто нет смысла тратить время на дополнительное тестирование, потому что софт будет жить 1-3 дня, — в таких случаях проводить юниттестирование необязательно.

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

Написание unitтестов

Написание unitтестов — дело ответственное, поэтому перед написанием теста нужно знать несколько правил:

  1. Тест должен быть достоверным.

  2. Он не должен зависеть от окружения.

  3. Написанный тест должен легко обслуживаться.

  4. Тест должен быть максимально простым и понятным.

  5. Должен присутствовать автоматический режим запуска тестов.

  6. Тест пишется для того кода, в котором нет уверенности.

  7. Когда пишется тест для классов, то писать его нужно, двигаясь от простого метода к сложному.

  8. Если стоит выбор, что проверять: результат или поведение, то всегда сначала нужно проверять результат.

  9. Если к коду невозможно написать тест, то это, скорее всего, «говнокод», и его нужно переписывать.

  10. У любого теста должна быть возможна фальсификация, то есть должно быть такое входное значение, чтобы тест провалился.

  11. Тест должен провалиться только тогда, когда тестируемый код меняется. И никогда не должен проваливаться просто так.

  12. Плохой тест всегда лучше, чем полное отсутствие юниттеста.

Итак, чтобы реализовать все вышеперечисленные правила и знать наверняка, как писать эффективные юниттесты, нужно:

  1. Правильно выбирать логическое размещение unit-тестов в вашей VCS. К примеру, если ваша разработка монолитная, то тесты расположить в папку Tests. Если приложение состоит из нескольких элементов, то имеет смысл хранить тесты в папке каждого элемента.

  2. Правильно выбирать способ называть проект с тестом. Лучше всего добавлять к отдельному проекту его личный тестовый проект, например: когда у вас проект называется <Имя_проекта>.Core, то имеет смысл просто добавить <Имя_проекта>.Core.tests.

  3. При тестировании классов использовать такой же способ называть тест. Должен быть такой же подход, как описано выше, например: когда у вас есть класс FindProblem, то его тестовый проект должен быть FindProblemTests.

  4. Выбирать фреймворк для теста, который лучше всего подойдет. Не нужно изобретать колесо, когда уже придумали велосипед. Можно воспользоваться некоторыми популярными фреймворками для теста: MsTest, NUnit, xUnit и др.

  5. Писать тело теста в едином стиле, чтобы было проще читать и поддерживать код.

  6. Тестировать одну вещь за раз. Нужно упрощать процесс тестирования. Если вам нужно протестировать сложный процесс (например, процесс покупки в интернет-магазине), то разбивайте его на более мелкие части и тестируйте их отдельно.

  7. Не относиться к юнит-тестам как к второстепенному коду. Все принципы и технологии при разработке основного кода должны использоваться и при написании тестов.

Несколько книг, которые отлично раскрывают ответ на вопрос о том, как писать юниттесты:

  • «The Art of Unit Testing» от Roy Osherove;

  • «xUnit Test Patterns: Refactoring Test Code» от Gerard Meszaros;

  • «Working Effectively with Legacy Code» от Michael Feathers;

  • «Pragmatic Unit Testing in C# with NUnit, 2nd Edition» от Andy Hunt и Dave Thomas.

Заключение

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

С другой стороны, есть мнение, что сложные разработки лучше начинать с написания тестов, а не с самой программы. И в этом есть свои преимущества, к примеру, в таком случае у вас будет уже готовая структура будущего приложения и вы точно не сделаете спагетти-код в своей будущей разработке.

Если вы определили, что вам необходимо написание unitтестов, то относитесь к этому процессу так же серьезно, как и к самой разработке — это сэкономит вам в дальнейшем кучу времени.

Содержание

  1. Что такое модульный тест
  2. Зачем утруждать себя написанием модульных тестов
  3. Обнаруживайте ошибки как можно раньше
  4. Документация
  5. Краткое введение в разработку на основе тестирования (TDD)
  6. Практика написания модульных тестов
  7. Пишем осмысленное имя теста
  8. Каждый тест должен охватывать только 1 сценарий
  9. Используйте шаблон AAA
  10. Изолируйте свой юнит от внешних зависимостей
  11. Избегайте тестирования детализированной реализации
  12. Работа над устаревшим кодом: должен ли я сначала отрефакторить или написать тест?
  13. Вывод

Что такое модульный тест

Модульный тест – это категория тестов с самой высокой степенью детализации, основанная на тестовой пирамиде. Обычно он ориентирован на функциональность класса, функции или компонента пользовательского интерфейса и изолирован от внешней системы, такой как базы данных и сторонние API.

Зачем утруждать себя написанием модульных тестов

В большинстве случаев задача написания/рефакторинга кода заключается в том, чтобы убедиться, что вы не нарушаете существующую функциональность. Раньше разработчику нужно было проверить измененный класс/функцию вручную, чтобы убедиться, что ничего не сломалось. Ручная работа подвержена ошибкам. Разработчики могут забыть некоторые тестовые случаи, и код с багами отправляется в продакшен. Наличие модульных тестов и их правильная настройка на CI избавит вас от таких сценариев. Следовательно, это повысит вашу уверенность в CD до продакшена.

Обнаруживайте ошибки как можно раньше

Модульные тесты пишутся (и должны быть написаны) изолированно, поэтому их можно выполнять без необходимости разворачивать внешние службы и запускать такие инструменты, как puppeteer. Он может работать быстро и гораздо менее требователен к памяти по сравнению с сквозными тестами. Это уникальное свойство модульных тестов позволяет разработчикам выполнять тесты столько, сколько необходимо в процессе разработки.

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

Документация

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

Краткое введение в разработку на основе тестирования (TDD)

Разговор о модульных тестах не будет полным без упоминания Test-Driven Development (TDD). В двух словах TDD можно охарактеризовать как красно-зеленый-рефакторинг подход к разработке программного обеспечения.

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

Ключевой вывод из TDD заключается в том, чтобы позволить тестам управлять вашей архитектурой, а не наоборот.

Практика написания модульных тестов

Я буду использовать образец, написанный на javascript + jest, так как это язык и тестовый фреймворк, с которыми мне наиболее комфортно. В качестве примера мы используем класс dateFormatter со следующей спецификацией:

  • Этот класс имеет публичный метод format, которая принимает объект Javascript Date в качестве входных данных и возвращает строку даты в формате dd-mm-yyyy.
  • Если входные данные являются недопустимым объектом даты, он вызовет исключение.

Пишем осмысленное имя теста

// Плохо
test('format should format date correctly', function (){
 ...
})// Хорошо
test('format should return date with dd-mm-yyyy format given a valid date object input', function (){
 ...
})

Золотое правило содержательного названия теста – это четкое описание выходных и входных данных. Читатель должен быть в состоянии понять желаемое поведение без необходимости читать детали реализации тестируемой системы.

Каждый тест должен охватывать только 1 сценарий

describe('DateFormatter', function() {
// Плохо
test('format should return the date with following format:dd-mm-yyyy given valid date object and throw exception if the input is invalid date object', function(){
...
}) // Хорошо
test('dateFormatter should return the date with following format:dd-mm-yyyy given valid date object', function() {
...
}) test('dateFormatter should throw exception given invalid date object', function() {
...
})
})

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

Используйте шаблон AAA

Шаблон Arrange, Act, Assert – это распространенный шаблон, который можно использовать для улучшения читабельности теста, разделяя части теста пустой строкой.

  • Arrange готовит необходимые приспособления, насмешки, заглушки и тестируемую систему.
  • Act выполняет тестируемую функциональность
  • Assert-это утверждение результата выполнения относительно желаемого значения
describe('DateFormatter', function() {
test('format should return the date with following format:dd-mm-yyyy given valid date object', function() {

// подготовка данных
const sut = DateFormatter();
const date = new Date('2020-01-01');

// выполнение логики
const result = sut.format(date); 

// сверка результатов
expect(result).toBe('01-01-2020')
})
})

Изолируйте свой юнит от внешних зависимостей

Допустим, мы добавили еще одну функциональность поверх класса. Каждый раз, когда метод format выполняется, он будет регистрировать результат в стороннем API с помощью функции logToExernalAPI.

import { logToExternalAPI } from './third-party-services'; class DateFormatter() {
format(date) {
...
logToExternalAPI(result);
return result;
}
}

Как написать тест для этой новой функциональности? Один из подходов заключается в рефакторинге класса и использовании инъекции зависимостей, чтобы избежать прямой зависимости от другого блока. Используя эту технику, мы также улучшаем дизайн класса, отделяя его от реализации logger.

class DateFormatter {
constructor(logger){
this.logger = logger;
} 
format(date) {
...
this.logger(result);
return result;
}
}

describe('DateFormatter', function(){
test('format should call logger with the formatted given valid date object', function() {
// Arrange
const loggerMock = jest.fn();
const sut = DateFormatter(loggerMock);
const date = new Date('2020-01-01');

// Act
sut.format(date); // Assert
expect(loggerMock).toBeCalledWith('01-01-2020')
})
})

Избегайте тестирования детализированной реализации

Пример детализации реализации тестирования выглядит следующим образом:

  • Проверка последовательности вызовов функций
  • Проверка внутреннего состояния класса

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

Работа над устаревшим кодом: должен ли я сначала отрефакторить или написать тест?

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

В общем, я бы предложил написать тест перед рефакторингом, если это не очень трудно сделать.

Вывод

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

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

Счастливого кодинга!

Автор: Владимир Четвериков

Оригинальная публикация

Привет! Меня зовут Владимир, я разработчик команды продукта «Сервис персонализации» в SM Lab. В этом посте я хотел бы рассказать (а в комментариях — обсудить) один очень важный и полезный инструмент разработчика — юнит-тесты.

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

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

Эта статья для всех – кто слышал про них, но не видел, кто приступает к написанию юнит-тестов, и кто их пишет уже давно. Надеюсь, каждый из вас найдет что-то полезное для себя.

При подготовке материала очень помогла книга Владимира Хорикова (@vkhorikov ) «Принципы юнит-тестирования». Рекомендую ее всем, кто хочет еще глубже погрузиться в эту тему.

Итак, поехали.

Что такое юнит-тестирование и для чего оно нужно

Приведу одно из устоявшихся определений: юнит-тестирование – это процесс, который позволяет проверить работоспособность отдельных частей исходного кода программы.

Зачем мы пишем юнит-тесты?

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

Какими качествами должен обладать юнит-тест

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

По поводу «делать это быстро». В области юнит-тестирования достаточно тяжело определить какие-то границы. Если взять тест, который выполняется за секунду — быстро это или медленно? Всё зависит от того, что это за тест, какие требования к нему. В нашей команде мы опираемся на субъективное восприятие скорости – если, по нашему мнению, тесты проходят быстро – этого достаточно.

Как тесты влияют на разрабатываемые нами продукты 

Давайте взглянем на следующий график:

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

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

С плохими тестами или без тестов рост времени нелинеен, и иногда даже наступает такой момент, когда проще отказаться от текущей реализации системы и переписать ее заново, чем пытаться в неё что-то внести. Исходя из этого, стоит оценивать покрытие кода тестами с точки зрения его жизненного цикла. Если продукт одноразовый, маленький, короткий, который мы написали и потом через какое-то время он будет не нужен, то и, возможно, уровень покрытия тестами нужен достаточно низкий.

Тесты имеют хорошее свойство выступать индикаторами низкого качества кода, которое обычно проявляется в высокой связанности, когда части кода плохо изолированы друг от друга, и это создаёт сложность с раздельным тестированием этих частей. Поэтому сложность покрытия юнит-тестами – это хороший негативный признак. Если тяжело писать тесты, то, скорее всего, покрываемый тестами код низкого качества. Правда, это работает только в одну сторону. Мы не можем сказать о том, что если легко писать тесты на код, то он хорошего качества.

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

Как измерить покрытие кода тестами 

Для этого есть две чаще всего используемые метрики – процент покрытия строк (code coverage) и процент покрытия логических ветвей (branch coverage) кода.

Как и с легкостью покрытия кода тестами, это хороший негативный признак, но плохой позитивный. Если покрыто всего десять процентов кода, это хороший признак того, что тестов слишком мало.

Но даже стопроцентное покрытие тестами не гарантирует хорошего качества тестов. И вот почему. Эти метрики не отражают некоторые важные детали, такие как наличие и полноту проверок (asserts). Мы можем написать тест, который не имеет проверок вообще. То есть он будет запускать наш основной код, но по факту не будет иметь никакой ценности. И ещё, эти метрики не оценивают код во внешних библиотеках. Они оценивает только тот код, который написали мы.

Вот короткий пример:

Есть метод, который принимает строку и превращает её в число. Очень простой метод. На него написан простой тест, который передаёт строку со значением «5» и сравнивает число 5 с тем, какое число возвращает метод.

Если мы посмотрим на этот тест и попробуем оценить метрики покрытия, мы увидим, что для этих метрик покрытие будет 100%. Но, к сожалению, если мы заглянем в библиотечный метод parseInt, мы увидим, что на самом деле мы проверили только один из четырех вариантов. Поэтому с одной стороны у нас покрытие 100%, но с другой стороны мы покрыли по факту только 25% кода. Поэтому на эти метрики стоит ориентироваться, но не слепо им доверять. В нашей команде мы ориентируемся на уровень в 80% покрытия кода.

Тестовые двойники

Помощниками в тестировании выступают «тестовые двойники» (test doubles). Их выделяют 5 видов.

Пустышка (Dummy). Такой двойник не содержит поведения и используется в качестве заполнителя параметров. Никогда реально не вызывается.

Подделка (Fake). Это реально написанная реализация, которая имеет более простую логику, чем реальный аналог.

Есть ещё три вида похожих между собой тестовых двойников. Но есть и различия.

Заглушка (Stub) — имеет заранее подготовленные ответы на вызовы методов. Практически не имеет логики.

Шпион (Spy). Более сложная система. Это гибрид реального объекта и мока. Он имеет поведение реального объекта, но может записывать определенную информацию о вызове его методов. Также можно переопределить поведение некоторых методов.

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

Все тестовые двойники, за исключением fake, в основном создаются с помощью фреймворков, с которыми мы часто работаем. Для Java это Mockito, для Kotlin – MockK, для других языков такие фреймворки тоже есть.

Изоляция и виды зависимостей

Что такое зависимость? Класс чаще всего не существует изолированно в коде. Он использует какие-то другие части программы. Зависимости – это то, что использует класс для своей работы. Это могут быть другие классы, базы данных, файловые системы, сторонние сервисы и прочее.

Зависимости с точки зрения изменяемости делятся на изменяемые – те, состояние которых может изменить тест, например, переменные или база данных, и неизменяемые – например, константа или неизменяемый объект. Зависимости с точки зрения возможности влияния тестов друг на друга делят на совместные и приватные. 

Совместные зависимости (shared). Через такие зависимости тесты могут влиять на результаты друг друга. Например, статические изменяемые поля класса, база данных. Это изменяемые зависимости.

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

Приватные зависимости (private). Это зависимости, не являющиеся совместными. Они могут быть как изменяемыми, так и неизменяемыми. Та же база данных может быть как совместной, так и приватной зависимостью. Совместной она может выступать, когда все тесты работают с одной базой. Приватной зависимостью база данных может выступать в случае, когда для каждого теста поднимается отдельная база данных, например в docker-контейнере. В этом случае тесты не смогут повлиять друг на друга через базу.

Есть два подхода (школы) к пониманию изоляции: так называемые классическая и лондонская школы.

Лондонская школа понимает изоляцию тестируемого кода как изоляцию от его изменяемых зависимостей. Все изменяемые зависимости (совместные и приватные) заменяются на тестовые двойники – «мокируются».

В этом подходе есть следующие плюсы:

  • можно разбить взаимосвязанный граф классов на отдельные классы, заменив изменяемые зависимости тестовыми двойниками, и тестировать их независимо.

  • позволяет однозначно определить, что юнит – это класс.

  • в случае падения теста поиск проблемы ограничивается одним классом.

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

Мы в команде чаще всего используем этот подход.

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

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

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

Эффективность юнит-тестов

Чтобы формально как-то измерить эффективность наших тестов, мы можем представить ее как произведение четырёх параметров.

  • защита от багов – возможность выявить ошибку, которая зависит от объема выполняемого кода, его сложности и важности.

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

  • быстрая обратная связь – быстро выполняемые тесты ускоряют обратную связь.

  • простота поддержки – насколько сложно понять и запустить тест.

Пусть каждый из параметров будет числом от 0 до 1. В соответствии с этим можно понять, что если какой-то из параметров равен нулю, то и, соответственно, вся эффективность, насколько бы высокими ни были другие параметры, будет также равна нулю.

Давайте рассмотрим следующие отношения между поведением тестируемого кода и теста:

В случаях, когда функциональность работает правильно и тест проходит (отрицательное срабатывание) или функциональность работает неправильно и тест не проходит (истинное срабатывание) – это ожидаемое поведение теста. В этом случае можно считать, что тест работает правильно.

Есть ещё и две другие области. Ложное срабатывание — функциональность работает правильно, но тест не проходит. Это значит, что тесты, имеют низкую устойчивость к рефакторингу. Чаще всего это означает, что тесты завязаны на детали имплементации и будут падать при изменении реализации тестируемого кода без изменения функциональности. Низкая защита от багов – случай, когда тест проходит, но функциональность работает неправильно.

Из четырех атрибутов для юнит-тестов один должен быть высоким – это простота поддержки, потому что иначе на тесты будет потрачено недопустимо много времени из-за их большого количества. Простота поддержки – характеристика независимая. Оставшиеся три – взаимосвязаны.

Сквозные тесты (e2e-тесты) задействуют большой объём кода, проходя через цепочку тестируемых элементов или систем, поэтому имеют хорошую защиту от багов. Такие тесты устойчивы к рефакторингу, так как ориентируются на внешний интерфейс – API, и ничего не знают про детали реализации. Такие тесты достаточно медленные — и с точки зрения написания, и с точки зрения прохождения.

Тривиальные тесты могут быть устойчивы к рефакторингу и иметь быструю обратную связь. Например тест, написанный на getter – метод получения значения приватного поля. Такой тест переживет рефакторинг getter-а (если такой вообще будет) и будет быстро проходить. Но пользы от такого теста не будет.

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

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

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

  • проще и быстрее они должны разрабатываться.

  • ниже затраты на поддержку тестов.

  • быстрее скорость прохождения отдельного теста.

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

Устойчивость к рефакторингу желательно держать максимальной, поэтому что это величина достаточно бинарная – тесты либо устойчивы к рефакторингу, либо нет. Защита от багов и быстрая обратная связь – более эластичны, поэтому выбор идет между ними.

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

Юнит-тестов в основном самое большое количество, поэтому для них важна скорость выполнения.

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

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

Обсудить в форуме

  • Как пишутся эссе примеры
  • Как по английски пишется делать уроки
  • Как пишутся электронные формулы
  • Как по английски пишется девочек
  • Как пишутся штили нот правило