Чистые тесты на PHP и PHPUnit

в 9:55, , рубрики: php, phpunit, tdd, Блог компании Mail.Ru Group, никто не читает теги, Совершенный код, Тестирование IT-систем, Тестирование веб-сервисов
Чистые тесты на PHP и PHPUnit - 1

В экосистеме PHP существует много инструментов, обеспечивающих удобное тестирование на PHP. Одним из самых известных является PHPUnit, это почти синоним тестирования на этом языке. Однако о хороших методиках тестирования пишут не так много. Есть много вариантов, для чего и когда писать тесты, какого рода тесты, и так далее. Но, честно говоря, не имеет смысла писать тест, если позднее вы не сможете его прочесть.

Тесты — это особая разновидность документации. Как я ранее писал о TDD на PHP, тест всегда будет (или хотя бы должен) ясного говорить о том, в чём заключается задача конкретной части кода.

Если один тест не может выразить эту идею, то тест плохой.

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

Начнём с основ

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

1. Тесты не должны содержать операций ввода-вывода

Основная причина: операции ввода-вывода медленные и ненадёжные.

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

Ненадёжные: некоторые файлы, бинарники, сокеты, папки и DNS-записи могут быть недоступны на некоторых машинах, на которых вы проводите тестирование. Чем больше вы полагаетесь при тестировании на операции ввода-вывода, тем больше ваши тесты привязаны к инфраструктуре.

Какие операции относятся к вводу-выводу:

  • Чтение и запись файлов.
  • Сетевые вызовы.
  • Вызовы внешних процессов (с помощью exec, proc_open и т.д.).

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

Изолируйте тесты так, чтобы им не нужны были операции ввода-вывода: ниже я привёл архитектурное решение, которое предотвращает выполнение тестами операций ввода-вывода за счёт разделения ответственности между интерфейсами.

Пример:

public function getPeople(): array
{
  $rawPeople = file_get_contents(
    'people.json'
  ) ?? '[]';

  return json_decode(
    $rawPeople,
    true
  );
}</code>
При начале тестирования с помощью этого метода будет создан локальный файл, и время от времени будут создаваться его снимки:

<source lang="php">
public function testGetPeopleReturnsPeopleList(): void
{
  $people = $this->peopleService
    ->getPeople();

  // assert it contains people
}

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

Пропуск теста из-за того, что не выполнены предварительные условия, не обеспечивает качество нашего ПО. Это лишь скроет баги!

Исправляем ситуацию: изолируем операции ввода-вывода, переложив ответственность на интерфейс.

// extract the fetching
// logic to a specialized
// interface
interface PeopleProvider
{
  public function getPeople(): array;
}

// create a concrete implementation
class JsonFilePeopleProvider
  implements PeopleProvider
{
  private const PEOPLE_JSON =
    'people.json';

  public function getPeople(): array
  {
    $rawPeople = file_get_contents(
      self::PEOPLE_JSON
    ) ?? '[]';

    return json_decode(
      $rawPeople,
      true
    );
  }
}

class PeopleService
{
  // inject via __construct()
  private PeopleProvider $peopleProvider;

  public function getPeople(): array
  {
    return $this->peopleProvider
      ->getPeople();
  }
}

Теперь я знаю, что JsonFilePeopleProvider в любом случае будет использовать ввод-вывод.

Вместо file_get_contents() можно использовать слой абстракции вроде файловой системы Flysystem, для которой легко сделать заглушки.

А тогда зачем нам PeopleService? Хороший вопрос. Для этого и нужны тесты: поставить архитектуру под сомнение и убрать бесполезный код.

2. Тесты должны быть осознанными и осмысленными

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

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

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

Характеристики хорошего и удобочитаемого теста:

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

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

Повторюсь: дело не в покрытии, а в документировании.

Вот пример сбивающего с толку теста:

public function testCanFly(): void
{
  $noWings = new Person(0);
  $this->assertEquals(
    false,
    $noWings->canFly()
  );

  $singleWing = new Person(1);
  $this->assertTrue(
    !$singleWing->canFly()
  );

  $twoWings = new Person(2);
  $this->assertTrue(
    $twoWings->canFly()
  );
}

Давайте адаптируем формат «дано, когда, тогда» и посмотрим, что получится:

public function testCanFly(): void
{
  // Given
  $person = $this->givenAPersonHasNoWings();

  // Then
  $this->assertEquals(
    false,
    $person->canFly()
  );

  // Further cases...
}

private function givenAPersonHasNoWings(): Person
{
  return new Person(0);
}

Как и раздел «дано» (Given), «когда» и «тогда» можно перенести в приватные методы. Это сделает ваш тест более удобочитаемым.

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

Использование конкретных утверждений сделает ваш тест гораздо удобочитаемее. assertTrue() должен получать булеву переменную, а не выражение вроде canFly() !== true.

В предыдущем примере мы заменяем assertEquals между false и $person->canFly() на простое assertFalse:

// ...
$person = $this->givenAPersonHasNoWings();

$this->assertFalse(
  $person->canFly()
);

// Further cases...

Теперь всё предельно понятно! Если человек не имеет крыльев, он не должен уметь летать! Читается как стихотворение

Теперь раздел «Further cases», который дважды появляется в нашем тексте, является ярким свидетельством того, что тест делает слишком много утверждений. При этом метод testCanFly() совершенно бесполезен.

Давайте снова улучшим тест:

public function testCanFlyIsFalsyWhenPersonHasNoWings(): void
{
  $person = $this->givenAPersonHasNoWings();
  $this->assertFalse(
    $person->canFly()
  );
}

public function testCanFlyIsTruthyWhenPersonHasTwoWings(): void
{
  $person = $this->givenAPersonHasTwoWings();
  $this->assertTrue(
    $person->canFly()
  );
}

// ...

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

3. Тест не должен зависеть от других тестов

Основная причина: тесты должны запускаться и успешно выполняться в любом порядке.

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

Тест должен:

  • Сгенерировать JWT-токен для входа в систему.
  • Выполнить функцию входа.
  • Утвердить изменение состояния.

Было так:

public function testGenerateJWTToken(): void
{
  // ... $token
  $this->token = $token;
}

// @depends  testGenerateJWTToken
public function testExecuteAnAmazingFeature(): void
{
  // Execute using $this->token
}

// @depends  testExecuteAnAmazingFeature
public function testStateIsBlah(): void
{
  // Poll for state changes on
  // Logged-in interface
}

Это плохо по нескольким причинам:

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

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

public function testAmazingFeatureChangesState(): void
{
  // Given
  $token = $this->givenImAuthenticated();

  // When
  $this->whenIExecuteMyAmazingFeature(
    $token
  );
  $newState = $this->pollStateFromInterface(
    $token
  );

  // Then
  $this->assertEquals(
    'my-state',
    $newState
  );
}

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

4. Всегда внедряйте зависимости

Основная причина: очень дурной тон — создавать заглушку для глобального состояния. Отсутствие возможности создавать заглушки для зависимостей не позволяет тестировать функцию.

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

Вот грустный пример:

class FeatureToggle
{
  public function isActive(
    Id $feature
  ): bool {
    $cookieName = $feature->getCookieName();

    // Early return if cookie
    // override is present
    if (Cookies::exists(
      $cookieName
    )) {
      return Cookies::get(
        $cookieName
      );
    }

    // Evaluate feature toggle...
  }
}

Как можно протестировать этот ранний ответ?

Всё верно. Никак.

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

Не делайте этого.

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

// Test class...
private Cookies $cookieMock;

private FeatureToggle $service;

// Preparing our service and dependencies
public function setUp(): void
{
  $this->cookieMock = $this->prophesize(
    Cookies::class
  );

  $this->service = new FeatureToggle(
    $this->cookieMock->reveal()
  );
}

public function testIsActiveIsOverriddenByCookies(): void
{
  // Given
  $feature = $this->givenFeatureXExists();

  // When
  $this->whenCookieOverridesFeatureWithTrue(
    $feature
  );

  // Then
  $this->assertTrue(
    $this->service->isActive($feature)
  );
  // additionally we can assert
  // no other methods were called
}

private function givenFeatureXExists(): Id
{
  // ...
  return $feature;
}

private function whenCookieOverridesFeatureWithTrue(
  Id $feature
): void {
  $cookieName = $feature->getCookieName();
  $this->cookieMock->exists($cookieName)
    ->shouldBeCalledOnce()
    ->willReturn(true);

  $this->cookieMock->get($cookieName)
    ->shouldBeCalledOnce()
    ->willReturn(true);
}

То же самое и с синглтонами. Так что если вы хотите сделать объект уникальным, то корректно сконфигурируйте ваш инъектор зависимостей, а не используйте (анти)паттерн «синглтон». Иначе будете писать методы, которые полезны лишь для случаев вроде reset() или setInstance(). На мой взгляд, это безумие.

Совершенно нормально менять архитектуру, чтобы облегчить тестирование! А создавать методы для облегчения тестирования — не нормально.

5. Никогда не тестируйте защищённые/приватные методы

Основная причина: они влияют на то, как мы тестируем функции, определяя сигнатуру поведения: при таком-то условии, когда я ввожу А, то ожидаю получить Б. Приватные/защищённые методы не являются частью сигнатур функций.

Я даже не хочу показывать способ «тестирования» приватных методов, но дам подсказку: вы можете это сделать только с помощью API reflection.

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

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

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

Продвинутые советы

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

Самое важное, о чём я не забываю при написании тестов:

  • Учёба.
  • Быстрое получение обратной связи.
  • Документирование.
  • Рефакторинг.
  • Проектирование в ходе тестирования.

1. Тесты в начале, а не в конце

Ценности: учёба, быстрое получение обратной связи, документирование, рефакторинг, проектирование в ходе тестирования.

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

Странно слышать о том, чтобы писать тесты до реализации? А представьте, насколько странно реализовать что-то, а при тестировании выяснить, все ваши выражения «дано, когда, тогда» не имеют смысла.

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

Зелёные тесты — идеальная область для рефакторинга. Главная мысль: нет тестов — нет рефакторинга. Рефакторинг без тестов просто опасен.

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

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

2. Лучше без тестов, чем с плохими тестами

Ценности: учёба, документирование, рефакторинг.

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

Мне кажется, нужно уделять больше внимания ситуации, когда новый разработчик начинает работать с этой фичей. О чём расскажут тесты этому человеку?

Тесты часто сбивают с толку, если наименования недостаточно подробны. Что понятнее: testCanFly или testCanFlyReturnsFalseWhenPersonHasNoWings?

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

Даже такие глупости, как присвоение переменным имён $a и $b, или присвоение имён, никак не связанных с конкретным использованием.

Помните: ваши тесты — живые документы, пытающиеся объяснить, как должно вести себя ваше приложение. assertFalse($a->canFly()) мало что документирует. А assertFalse($personWithNoWings->canFly()) — уже достаточно много.

3. Навязчиво прогоняйте тесты

Ценности: учёба, быстрое получение обратной связи, рефакторинг.

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

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

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

Серьёзно, запускайте чёртовы тесты. Так же часто, как вы нажимаете кнопку «сохранить».
PHPUnit Watcher может делать это за вас, и даже отправлять уведомления!

4. Большие тесты — большая ответственность

Ценности: учёба, рефакторинг, проектирование в ходе тестирования.

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

Можно считать примерно так:

  • Один класс = один тестовый случай.
  • Один метод = один или несколько тестов.
  • Одна альтернативная ветка (if/switch/try-catch/exception) = один тест.

Так что для этого простого кода понадобится четыре теста:

// class Person
public function eatSlice(Pizza $pizza): void
{
  // test exception
  if ([] === $pizza->slices()) {
    throw new LogicException('...');
  }

  // test exception
  if (true === $this->isFull()) {
    throw new LogicException('...');
  }

  // test default path (slices = 1)
  $slices = 1;
  // test alternative path (slices = 2)
  if (true === $this->isVeryHungry()) {
    $slices = 2;
  }

  $pizza->removeSlices($slices);
}

Чем больше у вас будет публичных методов, тем больше понадобится тестов.

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

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

5. Поддерживайте набор тестов для решения проблем с регрессией

Ценности: учёба, документирование, быстрое получение обратной связи.

Рассмотрим функцию:

function findById(string $id): object
{
  return fromDb((int) $id);
}

Вы думаете, что кто-то передаёт «10», но на самом деле передаётся «10 bananas». То есть приходят два значения, но одно лишнее. У вас баг.

Что вы сделаете в первую очередь? Напишете тест, который обозначит такое поведение ошибочным!!!

public function testFindByIdAcceptsOnlyNumericIds(): void
{
  $this->expectException(InvalidArgumentException::class);
  $this->expectExceptionMessage(
    'Only numeric IDs are allowed.'
  );

  findById("10 bananas");
}

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

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

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

Заключительное слово

Многое сказанное выше — лишь моё личное мнение, выработанное в течение карьеры. Это не значит, что советы верные или ошибочные, это просто мнение.

Автор: Макс

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js