Пишем тесты для backend приложений

Введение

Статья нацелена в первую очередь на PHP backend-разработчиков уровня junior/middle, чтобы познакомить с теорией, которую спрашивают на собеседованиях, и с практическими примерами/советами, полезными для разработки

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

Содержание

  1. Зачем писать тесты?

  2. Инструменты и паттерны

    • Инструменты

      • PHPUnit

      • Faker

      • Sendstruck

      • Fixtures

      • Infection

      • Pest

      • Codeception

  3. Unit vs Интеграционные тесты

  4. Stubs vs Mocks

  5. TDD и метрики

  6. AAA-паттерн

  7. Метрики

  8. Итоги и советы

Зачем писать тесты?

Здесь есть несколько аргументов, я расскажу их в порядке важности (для меня по крайней мере)

  1. Уверенность в своем коде:

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

    Однако мы хотим сконцентрироваться на smoke test — искать все баги в автотестах нет смысла, так как как только вы их нашли и починили, тесты на этот case имеют мало смысла (если это был просто глупый баг, а не какой-то edgecase бизнес логики, который надо проверять).

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

  2. Ускорение рефакторинга:

    Тесты позволяют быстро проверять изменения.

    Есть понятие Fear driven development (разработка, движимая страхом), когда вам достался контроллер на 3к строк,
    который вызывает другие контроллеры (а я видел такое).

    Но отрефакторить вам это запрещает вся команда, потому что можно сломать базовую функциональность, и не сразу узнать об этом

  3. Удобство

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

    Вот как в примере — по сути 4 строки кода. Наверняка вы напишете это быстрее, чем запустите postman.

        public function testGetEventSettings(): void
        {
            // arrange
            $client = self::createAuthorizedClient(TestCoreUserProvider::TEST_USER1);
    
            // act
            $response = $client->request('GET', '/api/event_settings')->toArray(false);
    
            // assert
            self::assertResponseIsSuccessful();
            self::assertEquals(count(EventTypeEnum::cases()), $response['hydra:totalItems']);
        }
    
  4. Проектирование кода

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

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

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

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

    Это одинаково актуально как для АПИ приложения — то есть для эндпойнтов, так и для АПИ классов — например для библиотек.

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

    Благодаря чему на 3й день я смог уже писать новые фичи.

Инструменты и паттерны

1. PHPUnit

Документация Самый известный тестовый фреймворк, аналог JUnit в java.

Пример теста на экране

 final class GreeterTest extends TestCase
 {
     public function testGreetsWithName(): void
     {
         $greeter = new Greeter;
 
         $greeting = $greeter->greet('Alice');
 
         $this->assertSame('Hello, Alice!', $greeting);
     }
 }

Предоставляет функциональность assert, code coverage, mocks, группировка тестов, отчеты

2. Faker

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

Библиотека для генерации тестовых данных.

Пример:

name;
  // 'Lucy Cechtelar';
echo $faker->address;
  // "426 Jordy Lodge
  // Cartwrightshire, SC 88120-6700"
echo $faker->text;
  // Dolores sit sint laboriosam dolorem culpa et autem. Beatae nam sunt fugit
  // et sit et mollitia sed.
  // Fuga deserunt tempora facere magni omnis. Omnis quia temporibus laudantium
  // sit minima sint.

Библиотека предоставляет очень богатое АПИ, сгенерировать можно чуть ли не все на свете например что-то такое

avs13; // "756.1234.5678.97"

Плюс можно генерировать на разных языках

3. Sendstruck

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

Фабрики (используются в Symfony, в Laravel это factory + seeder) для того, чтобы легко сгенерировать много записей в БД

Пример


*/
class ArtifactFactory extends PersistentProxyObjectFactory
{
  public static function class(): string
  {
      return Artifact::class;
  }

  protected function defaults(): array|callable
  {
      return [
          'name' => self::faker()->slug(),
          'type' => self::faker()->randomElement([Artifact::TYPE_FOLDER, Artifact::TYPE_ASSET]),
          'createdAt' => DateTimeImmutable::createFromMutable(self::faker()->dateTime()),
          'updatedAt' => DateTimeImmutable::createFromMutable(self::faker()->dateTime()),
      ];
  }
}

И используется в тестах, например как ArtifactFactory::createOne();

4. Fixtures

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

Фикстуры (для doctrine). В laravel аналог — seeder’ы. Используется в основном для локальной разработки / дев окружений, чтоб заполнить систему тестовыми данными и посмотреть как все работает, не создавая записи руками.

Пример:

setUsername('jwage');
      $user->setPassword('test');

      $manager->persist($user);
      $manager->flush();
  }
}

И запускается консольной командой

5. Infection

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

Мутационное тестирование. Покрытие строк кода тестами не дает гарантий, что вы действительно проверили все corner case’s этой строки (яркий пример — тернарный оператор, где 2 исхода в одной строке)

Infection в рантайме изменяет код, генерируя мутантов, например

public function hasErrors(): bool
{ 
  return count($this->errors) >= 0; // было
  return count($this->errors) > 0; // мутировало
}

И прогоняет ваши тесты на измененном коде

Подобный мутант может выжить, если ни один из ваших тестов не учитывает ситуацию с нулем

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

6. Pest

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

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

Пример

it('performs sums', function () {
 $result = sum(1, 2);

 expect($result)->toBe(3);
});
c50eac46a91e0589530babee03a382ea.png

7. Codeception

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

Надстройка над phpunit использующая синтаксис для BDD. На мой взгляд стоит использовать только если у вас server side rendering.

Пример:

$I->amOnPage('/');
$I->click('Pages');
$I->click('New');
$I->see('New Page');
$I->submitForm('form#new_page', ['title' => 'Movie Review']);
$I->see('page created'); // notice generated
$I->see('Movie Review','h1'); // head of page of is our title
$I->seeInCurrentUrl('pages/movie-review'); // slug is generated
$I->seeInDatabase('pages', ['title' => 'Movie Review']); // data is stored in database

Unit vs Интеграционные тесты

Как вы уже могли заметить по примерам кода, есть разные уровни тестов

  1. Есть тесты которые просто вызывают функцию или метод — это Unit тесты,

    • Тестируют один компонент (метод, класс).

    • Нет обращений к другим подсистемам (Файловой системе, БД, кеш, АПИ других сервисов)

  2. Есть тесты, для которых нужны fixtures/factories — то есть какое-то состояние базы данных, которое после выполнения целевого действия должно измениться.
    Возможно даже совершают запрос на endpoint вашего приложения.
    Это интеграционные тесты, так как будет проверятся и взаимодействие компонентов (самое частое — бд).

  3. Есть тесты,  которые создают состояние базы данных без «грязных хаков» в виде fixture/factories, а в виде тех же самых запросов к апи. то есть внутри одного теста вы условно и добавляете товары в корзину и совершаете заказ и оплачиваете его. это e2e тесты (end to end).

  4. Есть тесты, которые как бы ходят по User Interface вашего приложения, нажимают кнопки, читают заголовки и тп (пример из сodeception) — это UI тесты.

Сложность этих тестов растет соответственно, а значит и отрабатывать они будут дольше, больше съедать памяти

Эта мысль была впервые сформулирована Kent Beck’ом еще до 2000х. Kent Beck изобрел TDD, был автором Extreme Programming, Agile Manifest, одним словом авторитетный дяденька

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

4bb9544a6a0710cbc8c3051c1f6a42cd.png

Идея здесь в том, чтобы как можно меньше было дорогих тестов (на которых еще и сложно протестить edge cases каких-либо участков логики), и побольше Unit тестов.

Однако времена изменились, и отношение самого Кента Бека к тестированию тоже:

fdb8eaeba0f7ff27bbf6bed5c9eebf9e.png

Здесь он говорит, что ему платят за написание кода, а не тестов, поэтому он предпочитает писать те тесты, которые покроют максимум строк кода при минимуме строк теста -, а это интеграционные.

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

Ведь ошибка может закрасться на уровень Application/Infrastructure.

Вдобавок, интеграционные тесты (которые именно совершают запрос на endpoint) наиболее устойчивы к рефакторингу.

Они работают с системой как с черным ящиком, поэтому если вы переименовали класс / метод, изменили число аргументов или еще что-то — это никак не скажется на тесте.

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

Это большой камень в огород Mockist testing, о чем мы поговорим далее.

Однако из-за того что интеграционные тесты тестируют взаимодействие буквально сотен классов, вы никогда не покроете все test cases: здесь статья на эту тему Integrated Tests Are a Scam.

aa75c8a5ec8deb6d4658f154e4d9ab0a.png

Stubs vs Mocks

Мы затронули mock’и. часто на собесах спрашивают отличие Stub vs Mock (есть даже статья на эту тему от Мартина Фаулера) Mocks Aren’t Stubs

Начнем с того, что и Моки и Стабы лишь 2 из 5 Test doubles выделенных Gerard Meszaros в его книге 2009 года xUnit Test Patterns Refactoring Test Code

  1. Dummy

Болванка. Используется просто чтоб заполнить параметр метода/функции

2. Fake

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

Самый яркий пример — InMemoryRepository.

final class InMemoryLectureTimingRepository extends LectureTimingRepository
{
    /**
     * @var array
     */
    private array $lectureTimings = [];

    public function find(Uuid $lectureId): ?LectureTiming
    {
        return $this->lectureTimings[$lectureId->toString()] ?? null;
    }

    public function save(LectureTiming $lectureTiming): void
    {
        $this->lectureTimings[$lectureTiming->lectureId->toString()] = $lectureTiming;
    }
}

3. Stub

Рабочая иплементация контракта, которая возвращает предопределенные (захардкоженные) ответы.

Полезно для тестирования сторонних АПИ, чтобы внутри теста не совершать запросов (это долго и не надежно)

final class CoreStorageServiceStub extends CoreStorageService
{
    public function getFile(string $id, ?string $accessToken = null, bool $fromCache = true): CommonServiceResultDTO
    {
        $fileData = [
            'id' => $id,
            'mimeType' => 'image/jpeg',
            'size' => 1024,
            'width' => 1920,
            'height' => 1080,
            'url' => 'https://storage.domain.com/path/to/file.jpg',
            'bucket' => 's3?',
            'path' => 'path/to/file.jpg',
            'public' => true,
        ];

        $result = new CommonServiceResultDTO();
        $result->success = true;
        $result->data = CoreFileDTO::createFromArray($fileData);

        return $result;
    }
}

4. Spies

Это как Fake, только кроме имплементации контракта он еще и сохраняет в себе какую-то информацию о своей работе.

Например у InMemoryLogger можно вызвать getLogs() чтобы увидеть, какие логи он писал.

Или у InMemoryMailer узнать сколько писем было отправлено

Пример:

log(LogLevel::EMERGENCY, $message, $context);
    }

    public function alert(Stringable|string $message, array $context = []): void
    {
        $this->log(LogLevel::EMERGENCY, $message, $context);
    }

    public function critical(Stringable|string $message, array $context = []): void
    {
        $this->log(LogLevel::EMERGENCY, $message, $context);
    }

    public function error(Stringable|string $message, array $context = []): void
    {
        $this->log(LogLevel::EMERGENCY, $message, $context);
    }

    public function warning(Stringable|string $message, array $context = []): void
    {
        $this->log(LogLevel::EMERGENCY, $message, $context);
    }

    public function notice(Stringable|string $message, array $context = []): void
    {
        $this->log(LogLevel::EMERGENCY, $message, $context);
    }

    public function info(Stringable|string $message, array $context = []): void
    {
        $this->log(LogLevel::EMERGENCY, $message, $context);
    }

    public function debug(Stringable|string $message, array $context = []): void
    {
        $this->log(LogLevel::EMERGENCY, $message, $context);
    }

    public function log($level, Stringable|string $message, array $context = []): void
    {
        $this->logs[] = ['message' => $message, 'level' => $level, 'context' => $context];
    }

    public function getLogs(): array
    {
        return $this->logs;
    }
}

5. Mocks

Стоят особняком, так как это объекты, о поведении которых можно делать assert’ы.

например что метод send был вызван ровно один раз именно с этими параметрами.

  // arrange
  $mock = $this->createMock(Mailer::class);
  $service = new Service($mock);
  
  //assert
  $mock->expects($this->once())->method('send')->willReceive('test@email.com', 'Hello










')->willReturn(true);
  
  // act
  $service->handle('test@email.com', 'Hello');

То есть моки сильно отличаются от других 4х Test Doubles тем, что они проверяют поведение объектов, а не их состояние.
И если чем Моки и похожи на Стабы, так только тем, что можно создать мок, сказать что он должен возвращать, и подсунуть его как Стаб., но это не основная его фича.

Таким образом и подход к написанию тестов делиться на 2 школы: классическую и мокистскую, где классики проверяют состояние системы после выполнение целевого действия,
, а мокисты проверяют поведение системы ВО ВРЕМЯ выполнения целевого действия. Мне лично (да и Фаулеру) нравится первый подход,
так как такие тесты кажутся менее хрупкими, и позволяют писать тесты ничего не знаю о внутреннем устройстве системы

Classical vs. Mockist Testing.

TDD

Обычно сначала пишут код, а потом не пишут тесты

Реже бывает, что тесты пишут

Но существует TDD — упрощенно говоря, идея в том, чтобы сначала написать тесты, а потом писать код

Таким образом формируется следующий подход к разработке:

Red → Green → Refactor

Где Red символизирует только что написанный тест, который конечно падает с ошибкой

потом вы пишете реализацию, и тест проходит — green

потом вы рефакторите свой код, и готово — повторять пока фича не будет сделана

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

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

AAA-паттерн

В некоторых примерах вы могли видеть повторяющиеся комментарии вида //arrange //act //assert. Это так называемый triple A pattern, помогающий явно структурировать тесты, и тестировать в одном методе только один кейс, не все подряд

// Arrange
$user = User::factory()->create();

// Act
$response = $this->post('/login', ['email' => $user->email, 'password' => 'password']);

// Assert
$response->assertRedirect('/dashboard');

Здесь же хочу сказать, что использование конструкций if в вашем тесте, как правило, говорит о том, что он плохо написан foreach же конструкцию использовать можно (или как альтернативу им всякие array_map)

$updatedAt = array_map(
          static fn(array $edge): string => $edge['updatedAt'],
          $response['hydra:member'],
      );

self::assertSame(['2024-12-13T10:00:00+00:00', '2024-12-12T10:00:00+00:00'], $updatedAt);

Метрики

Code Coverage

При включенном xdebug, установленным в режим coverage, PHPUnit может посчитать в какие ветви кода заходила программа при выполнении ваших тестов.

Репорт в формате html выглядит следующим образом.

6aed3b9a51107559e6df492ccd43a6a6.png

Мы уже обсуждали, почему это не надежный показатель качества кода, тем не менее, 70% покрытия достигаются относительно легко, и явно полезнее для поддержки проекта, чем 0%

Mutation Score

Infection

Сам инструмент мы обсуждали выше, пример репорта в формате html:

329e991c9eca9775f0dd7cde43e3033f.png

Итоги и советы

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

  • Лучше писать интеграционные тесты вида «совершить запрос на endpoint, получить ответ»

  • Лучше писать Stub/Spy, чем Mock

  • Не доверяйте покрытию строк, доверяйте MSI

© Habrahabr.ru