company_banner

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

Автор оригинала: Níckolas Da Silva
  • Перевод
  • Tutorial

В экосистеме 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
  );
}

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

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");
}

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

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

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

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


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

Похожие публикации

Комментарии 22

    +2
    На маленьких проектах, где я работаю один и они дорабатываются лишь периодически, тесты я использую в том числе как TODO'шки, пишешь быстро название метода, что хочешь реализовать, и оставляешь пустым. Профиты:
    • Не нужны отдельные TODO листы в разрезе маленьких проектов.
    • Быстро вспоминается что хотел делать.
    • Становится привычкой зайдя в проект машинально запускать тест, чтобы «вспомнить»
    • Дополнительная мотивация все таки начать с теста, даже если проект не приоритетный.

      +1
      Эмм… а это разве не TDD?
      +1
      Вот у меня тесты — слабое место. Читаю все статьи по тестированию, но везде простейшие примеры, от которых и пользы немного. Как только встает вопрос работы с БД и файлами — все пишут нечто вроде «это отдельная сложная тема, есть разные подходы, и тэ дэ и тэ пэ».

      Вот, допустим, мне надо протестировать метод, сохраняющий некую сущность в базе данных, которая задействует несколько таблиц (свойства сущности, её связи с разными категориями, метаданные) — можно ли тут что-то сделать «по уму», а не тупым функциональным тестом, который пишет в базу, читает из нее и сравнивает отправленное с полученным?
        +2

        Если у вас нет слоя абстракции между менеджером этой сущности и БД, то кроме функционального тут ничего особо не придумаешь. По хорошему, должен быть слой (DBL, UnitOfWork,.etc), который берет на себя общение с БД. Он может быть изолированно покрыт функциональными тестами, либо же юнитами на предмет корректного генерирования SQL в разных условиях. Вы же для своего менеджера, который сохраняет сущность, проверяете только то, что он корректно с корректными аргументами дергает методы этого слоя для работы с БД.


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

          +1
          Делайте с фикстурами и не парьтесь. SSD или tmpfs помогут улучшить скорость работы таких тестов.
            +1
            Фикстуры и монтирование БД в RAM. Для MySQL и Postgres это делается довольно легко.
            Только убедитесь, что разные тесты выполняются изолированно друг от друга.
            0
            Да нормально с синглтонами всё тестируется, че все на них так накинулись? Всё и вся через конструкторы тоже не напрокидываешься, есть такие сервисы, которые нужны почти всем классам (пример EventDispatcher), и тут синглтоны неплохо подходят, чтоб конструкторы не засорять.

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

            PS: Если дело касается веб-приложений на PHP я за функциональные тесты, я думаю у них максимальный профит (повышение надежности проекта) при минимальном времени написания. Под них архитектуру чаще не надо менять, в отличие от unitов
              +1
              Научите тестировать Битрикс.
                0
                codeception.com/docs/03-AcceptanceTests — Это не пробовали?
                  +2
                  Проще уйти с него, чем прикручивать к нему тесты, миграции, шаблоны, нормальную ORM, MVC, WebPack, нормальное API и код, работающий на чём-то другом, нежели чем mbstring.func_overload = 2.
                    –1
                    Сергей Рыжиков пожалуйста перелогинтесь
                      +1

                      Я как раз взял кодсепшион и долго мучался над подключением ядра в тесте. Хоть что-то можно тестировать, но уровень, конечно, не Ларавеля. Больше всего печалит отсутствие возможности подключения тестовой БД.
                      Ну и то, что тестить в основном можно только аякс запросы и свои кастомные классы. Компонентный подход Битрикса делает очень сложным проверку логики где-нибудь в result_modifier

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

                        Тоже не согласен с данным утверждением. Получается, чтобы протестировать приватный метод с парой ветвлений мне надо прогнать через публичный все эти ветвления. Ок, а если метод используется в 5 публичных функциях, на основе какой мне тестировать? Как другой разработчик узнает, что данный метод тестируется именной в этой функции, а не напишет свои тесты покрыв еще раз тот же самый код (вскроется только в отчете покрытия тестами). Поэтому я забил на юниты, пишу функциональные — проверяется еще работа со связкой в бд.
                        Юнит тесты остальных только на расчетных и очень критичных функциях, чтобы выявить ошибку быстрее, а не ждать пока прогонятся функциональные.
                        +1
                        Ок, а если метод используется в 5 публичных функциях, на основе какой мне тестировать?
                        Как раз приватный метод тестировать обычно не нужно совсем, потому что он относится к деталям реализации. Если все public/protected методы покрыты тестами, то можно быть уверенным, что этот класс (юнит) работает в соответстветствии со спецификацией/контрактом.
                        Хотя бывают исключения. Иногда надо убедиться, что класс делал или не делал вызовы к внешним API, например через системые вызовы. Если это трудно замокать, то придётся тестировать именно реализацию.
                        Пример.
                        final class RecordStorage {
                        
                            /** Cache backend */
                            private $cache;
                        
                            public function load($id): Record
                            {
                                if ($record = $this->cache->get($id)) {
                                    return $record;
                                }
                                $record = $this->doLoad($id);
                                $this->cache->set($id, $record);
                                return $record;
                            }
                        
                            private function doLoad(): Record
                            {
                                // Call some external API.
                                $record = ...;
                                return $record;
                            }
                        
                        }
                        
                        Тут помимо тестирования RecordStorage:load() желательно ещё проверить что данные корректно кешируются, а для этого в тесте надо убедится, что RecordStorage:doLoadData() не вызывался при повторном запросе.
                          0

                          По хорошему:


                          • $this->cache->get($id) нужно протестировать отдельным тестом.
                          • $this->cache->set($id, $record); тоже нужно протестировать отдельным тестом.
                          • doLoad() должен быть публичным, с вынесением в отдельный класс — тоже должен быть протестирован на граничные случаи.

                          Тогда RecordStorage:load() тестировать нет необходимости. RecordStorage превратиться сервис, который получает данные от протестированных компонентов и делегирует их в другие протестированные компоненты.


                          Так что для пункта 5. Никогда не тестируйте защищённые/приватные методы исключений не бывает ;)

                          +1
                          $this->cache->get($id) нужно протестировать отдельным тестом.
                          $this->cache->set($id, $record); тоже нужно протестировать отдельным тестом.
                          $cache это зависимость, которая само собой должна иметь свой отдельный тест. В данном случае задача состоит только в том, чтобы протестировать логику кеширования в RecordStorage и интеграцию с cache сервисом.

                          doLoad() должен быть публичным, с вынесением в отдельный класс
                          Зачем? doLoad используется только внутри этого класса. Внешним потребителям не нужно знать про него. В общем случае его может вообще не быть.
                          public function load($id): Record
                          {
                              if ($record = $this->cache->get($id)) {
                                  return $record;
                              }
                              $record = $this->db->select('...') ; // Some slow SQL query to the database.
                              $this->cache->set($id, $record);
                              return $record;
                          }
                          
                            0

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


                            В качестве хранилища у вас выступает doLoad() или $this->db->select('...') или т.д., по хорошему этот код должен быть в отдельном классе.


                            Сейчас за хранилище отвечает Сервис API получения данных из хранилища. Это и есть причина того, что есть потребность тестировать RecordStorage:load(). А если переписать код как указано ниже, то и тестировать нечего.


                            final class RecordStorage {
                            
                                /** Cache backend */
                                private $cache;
                                /** Cache backend */
                                private $storage;
                            
                                public function load($id): Record
                                {
                                    if ($record = $this->cache->get($id)) { // протестировано отдельным тестом
                                        return $record;
                                    }
                                    $record = $this->storage->dataById($id); // протестировано отдельным тестом
                                    $this->cache->set($id, $record); // протестировано отдельным тестом
                                    return $record;
                                }
                            }

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

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

                              В качестве хранилища у вас выступает doLoad() или $this->db->select('...') или т.д., по хорошему этот код должен быть в отдельном классе.
                              doLoad() это просто внутренняя обёртка над хранилищем. То что вы убрали, её никак не поможет проверить в тесте, то что данные при повторном запросе берутся из кеша, а не из БД.

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

                              Тема тестирования приватных методов это частный случай более широкой темы тестирования деталей реализации. В общем случае реализация может находится и в публичном методе (как в вашем примере). Нужно ли её тестировать? Зависит от цели тестрования. Если ваша цель просто убедиться в том, что все публичные интерфейсы работают как надо, то тестировать детали реализации нет смысла. Но большое заблуждение при этом думать, то что корректность работы публичного интерфейса сервиса гарантирует отсутствие багов в нём. Сервис может отдавать корректные результаты внешним потребителям, но при этом делать не нужные запросы к внешним API, создавать/удалять сущности в базе, засорять системный лог и много других не запланированных вещей, которые тест публичного интерфейса не обнаружит.
                                0

                                Я полностью согласен — RecordStorage нужно покрыть интеграционным тестом, чтобы гарантировать его работу, как ожидается. Просто с тем кодом, где использовался private, вам нужна магия PHP, чтобы добраться до private, нам требуется знание черного ящика, тела функции, знания о её реализации...


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

                                А с кодом, который без private, мы проверяем результат RecordStorage::load($id) — подсовывая в constructor нужные нам состояния $cache и $storage, проверяем возвращаемый Record — никакой магии и тест будет читаться просто:


                                Дано RecordStorage и когда $cache и $storage такие-то, тогда RecordStorage::load($id) возвращает такой-то Record.


                                При private тестируется: будет ли второй раз при запросе к load отрабатывать кэш. А в третий, а в четвёртый — уверены?
                                Без private тестируется:


                                • будет ли возвращён по id ожидаемый Record, который в cache, если он там имеется
                                • будет ли возвращён по id ожидаемый Record, который в storage если в cache его нет.
                                  0
                                  Просто с тем кодом, где использовался private, вам нужна магия PHP, чтобы добраться до private, нам требуется знание черного ящика, тела функции, знания о её реализации...
                                  В данном конкретном случае магии не требуется. Можно мокая, кеш и задать что то вроде $cache->expects($this->never()). По поводу знания реализации. Конечно для тестирования реализации нужно знать реализацию. Тут возникает интересный вопрос. Должна ли инкапсуляция распостранятся на тесты? Я думаю для этого нет причин.

                                  А с кодом, который без private, мы проверяем результат RecordStorage::load($id) — подсовывая в constructor нужные нам состояния $cache и $storage, проверяем возвращаемый Record — никакой магии и тест будет читаться просто
                                  private это просто способ организовать внутреннее устройство класса. Оно может быть любым, как вам захочется. Отказываться от private методов только из-за того чтобы упростить тестирование, как то не правильно, имхо.

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

                            [удалено] не туда..

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

                            Самое читаемое