Тестування фікстур в PHPUnit

текст перекладу

Ситуація

Більшість моєї роботи пов'язана з сервісами, що мають API, які виконують дуже мало реальної роботи і багато інтеграційної роботи. Прості API виклики можна уявити, як показано на діаграмі нижче.

pic

Мок-діаграма взаємодії сервісів

Усі ці сервіси є безстанними, що означає, що імпліцитний стан (показаний блакитними стрілками) зберігається поза межами моєї програми. Одночасно, окремі безстанні виклики змінюють стан всієї системи. Це робить тести "цікавими".

Я говорю про функціональні тести, звісно, юніт-тести вирішені на іншому рівні. Цікава частина у моєму випадку в тому, що функціональні тести тісно пов'язані з інтеграційними тестами. Сервіс насправді не може багато чого робити без API з верхнього рівня, тому я тестую його, використовуючи реальні екземпляри цих сервісів. У рідкісних випадках у мене є мокі цих сервісів, але їх створення вимагає багато зусиль, і ще більше — для їх підтримки.

Ось так я і опиняюся з великими функціональними тестами, як цей.

pic

Випадковий скріншот коду з випадковими нотатками

Отже, цей один тест зі своєю налаштуванням займає близько 250 рядків коду. І так, я створював ще гірші. На цей момент люди діляться на дві групи — "Ті, хто осуджує" і "Ті, хто приймає".

Спершу розглянемо "Тих, хто осуджує". Зазвичай їх реакція починається з "Ти робиш це неправильно". Відтак ми можемо обговорити, що тест не повинен виглядати так і має бути перетворений на юніт-тест (що безглуздо, я вже маю їх) або тест із мок-API (що не набагато краще і не тестує інтеграцію насправді) або що він крихкий і швидко зламається (це неправда насправді) або що він виглядає потворно (це правда, але бути потворним — це не злочин) і його слід видалити (неправда, це один з найбільш корисних тестів, що у нас є).

Інша частина аргументів "Шамерів" починається з того, що це справжній інтеграційний тест (дуже вірно!) і він не повинен бути частиною тестів програми (можливо!) і що для цього є кращі інструменти (так, в деяких випадках) і що я повинен використовувати X або Y для цього. Це фактично закінчується переписуванням наведеного вище коду на Bash або Postman тестування, що дуже класно (Postman, а не Bash), але призводить до головного болю, коли виявляється, що інколи потрібно маніпулювати файлами на локальному диску або записами в базі даних.

З іншого боку, "Ті, хто приймає" скажуть щось на кшталт "Так, це те, з чим ти стикаєшся, якщо хочеш тестувати це таким чином". Не важливо. Da igual.

Тестові фікстури та PHPUnit

Тестова фікстура згідно з визначенням у Вікіпедії — це "фікстура тесту (також званий "контекст тесту") використовується для налаштування стану системи та вхідних даних, необхідних для виконання тесту". У наведеному вище прикладі тестова фікстура розподіляється між методом setUpBeforeClass, деяким допоміжним методом і самим тестовим методом.

Більшість моєї роботи зараз виконую в PHP, і ми використовуємо PHPUnit всюди. PHPUnit працює з тестовими фікстурами в документації дещо корисним способом (кашель, кашель).

Реальний зміст тесту губиться у шумі налаштування тестової фікстури.
текст перекладу
Ця проблема стає ще гіршою, коли ви пишете кілька тестів з подібними тестовими фікстурами.

Радий, що можу прочитати це в документації, інакше я б не звернув на це увагу! Є ще кілька важливих порад:

Одна з проблем з методами шаблону setUp() і tearDown() полягає в тому, що вони викликаються навіть для тестів, які не використовують тестову фікстуру, керовану цими методами, як у наведеному вище прикладі з властивістю $this->example.

або

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

Ну, я згоден з усіма цими пунктами. Але які ж у нас є варіанти? PHPUnit побудовано за аналогією з JUnit і пропонує мати текстові фікстури безпосередньо в тестовому методі, або методі setUp (виконується перед кожним тестом), або методі setUpBeforeClass (виконується перед кожним тестовим набором) або розкиданими по всіх місцях одночасно (як показано в наведеному прикладі).

Реальна проблема

Зазвичай я починаю з того, що пишу всю фікстуру безпосередньо в тестовому методі. Суцільний розмір тесту не є найбільшою проблемою. Він довгий, важко орієнтуватися, повільний і заплутаний. Але справжні проблеми починаються, коли люди приходять і намагаються це виправити.

pic

Зазвичай хід думок і дій такий:

  • Є багато майже дубльованого коду в більшості тестів, давайте помістимо це в метод setUp.
  • Добре, тепер тести реально сповільнилися, оскільки фікстури створюються перед кожним тестом. Давайте перенесемо їх у метод setUpBeforeClass.
  • Ну, це насправді не допомогло, оскільки багато тестових методів зовсім не потребують фікстур, і вони ініціалізуються без необхідності. Давайте перемістимо ініціалізацію фікстур у кастомний метод initialize і викликатимемо його лише за необхідністю.
  • Добре, код фікстури тепер не зовсім однаковий, давайте додамо параметри до кастомних методів initialize, щоб зробити фікстуру трохи іншою.
  • Добре, код тепер — це повний безлад.

Тоді хтось усвідомлює, що фікстури потрібно очищати. Пам’ятайте — це результати API викликів на сервісах upstream. Є деякий віддалений стан, пов'язаний з нашими фікстурами. І після кількох років поліпшень ми отримуємо тестовий клас, як цей:

class SomeUglyTest extends TestCase {  
 private string $configuration;  
 private string $configuration2;  
 private string $tableWithData;  
 private string $emptyTable;  
 private string $tableWithDataForTestXAndY;  
 private static string $workspace;  

 protected setUp() {...} // ініціалізує $configuration та $tableWithData  
 protected setUpBeforeClass() {...} // ініціалізує $workspace  
 private initData() {...) // ініціалізує $tableWithDataForTestXAndY  
 // не викликайте це в setUp()!  
 private initTests() {...} // ініціалізує $configuration2 та $emptyTable  
 private tearDown() {...} // очищає щось  
 // не працює, тому що дещо іноді залишається в робочому просторі  
 // private tearDownAfterClass() {...}   
}

І потім хтось усвідомлює, що метод initData дуже схожий у трьох чи чотирьох тестових наборах і створює клас BaseDataTest. Тепер визначення фікстури розподіляється не тільки між кількома методами, але і між кількома класами.

До цього часу тести працюють так повільно, що всі просять виконувати тести паралельно, що насправді неможливо через частково спільні фікстури. І навіть якщо вони не спільні, ніколи не знаєш, чи випадково вони не залежать від тих самих ресурсів upstream. Тим часом ніхто не має жодної ідеї, чи фікстури очищаються правильно, і які тести використовують які фікстури.

pic

Не тягніть більше волосся, змініть підхід

Переосмислення фікстур

Давайте на деякий час ігнорувати стандартний підхід PHPUnit і уявимо, що тестові фікстури насправді є першокласними об'єктами.
текст перекладу
Якщо ви чули про це десь, то так, це частково натхненно Playwright (і набагато простіше в порівнянні (але все ще корисно (принаймні трохи (на мою думку))))). Уявіть тест, який виглядає ось так:

class NiceTest extends TestCase {  
 public function testComplicatedStuff(): void  
 {  
 $fixture = new SampleDataAndConfigurationFixture();  
 $emptyTable = $fixture->getEmptyTableId();  
 $configuration1 = $fixture->getConfigurationIdForEmptyTable();  
 $configuration2 = $fixture->getStandardConfigurationId();  
 ... виконуємо тест ...  
 }  
}

Налаштування фікстури зникло, воно приховане в класі SampleDataAndConfigurationFixture. На перший погляд, мені це не подобається, тому що неможливо побачити, який стан системи використовує тест. З іншого боку, якщо мені доведеться вибирати між 200+ рядків коду налаштування фікстури на початку тесту і приховати це, то я вибираю приховати. Код фікстури простий:

class SampleDataAndConfigurationFixture  
{  
 public function __construct() {  
 ... виконуємо налаштування  
 }  
 public function __destruct() {  
 ... виконуємо очищення  
 }   
 public function getEmptyTableId() {...}  
 ... інші геттери  
}

Ключовою частиною дизайну є те, що фікстура представляє стан системи, який створюється. Вона більш загальна, ніж testComplicatedStuffFixture, або, принаймні, повинна бути такою. Цей підхід відкриває двері для повторного використання класів фікстур (а не інстанцій фікстур). Це також робить написання тестів трохи цікавішим — чи ці тести потребують того самого стану системи? Чому стан системи має відрізнятися для цих тестів? Як ці стани системи відрізняються?

З таким підходом тести мають такі властивості:

  • Фікстура створюється безпосередньо в тестовому методі (методи setUp відсутні) і інкапсульована в одному класі/методі.
  • Немає методу tearDown для очищення — це проблема фікстури.
  • Весь стан системи, необхідний для тесту, чітко представлений у фікстурі.
  • Інстанції фікстур не розділяються (не навіть частково) між тестами, що робить методи тестів повністю незалежними.
  • Класи фікстур можливо можна використовувати спільно. Це вирішує проблему дублювання коду. Тобто будь-який тестовий метод може використовувати фікстуру SampleDataAndConfigurationFixture.
  • Фікстура незалежна від тестового набору. Це робить організацію тестів значно простішою, оскільки їх можна організувати логічно (за тим, що вони тестують), а не випадковими загальними частинами стану системи.

Звичайно, є кілька недоліків:

  • Налаштування фікстури виведено з тесту. Іноді це може зробити тести важкими для розуміння. Я з цим змирився.
  • Класи фікстур можна повторно використовувати, але це трохи складно (див. нижче).
  • Інстанції фікстур не повторно використовуються, що зазвичай робить тести повільнішими.
  • Треба вирішити, наскільки загальними мають бути фікстури. Чи можу я зробити одну фікстуру для цих п’яти тестів? Чи повинно бути дві фікстури або п’ять? Це, по суті, ті самі проблеми, що й з використанням методів setUp, тепер вони просто чітко сформульовані. Чи є це насправді недоліком?

Також є одна властивість, яка пройшла майже непоміченою. Фікстури стають доступними за запитом! Незалежно від того, що містить тестовий набір, тести створюють лише ті фікстури, які їм потрібні.

pic

Давайте додамо ще

Повторне використання класів фікстур

З описаним підходом найбільша можливість — це повторне використання класів фікстур. В принципі, один клас фікстури може використовуватися кількома тестами. Але! Важлива частина фікстури полягає в тому, що її інстанції також повинні бути незалежними. Іноді це важко помітити.
текст перекладу
Розглянемо систему з такою таблицею:

CREATE TABLE items (  
 id INT,  
 name VARCHAR(255),  
 description TEXT  
);  
ALTER TABLE items ADD PRIMARY KEY (id);  
ALTER TABLE items ADD UNIQUE (name);

Якщо ініціалізація фікстури в конструкторі виконує запит, подібний до цього:

INSERT INTO items (id, name, description)   
 VALUES (1, 'Sample Item', 'This is a sample description.');

Швидше за все, інтуїтивно зрозуміло, що вставка запису з id=1 стане проблемою, якщо будуть створені дві інстанції фікстури. Навіть без повторного використання фікстури, це буде проблемою, якщо очищення фікстури не спрацює з якої-небудь причини. Однак унікальне обмеження на стовпець name — це те, що набагато легше пропустити.

Одночасно, мені також потрібно зберігати згенерований ID елемента для очищення фікстури. Тому тривіальний код для вставки одного рядка в базу даних стає трохи складнішим, щоб він був безпечним для повторного використання:

class SampleDataAndConfigurationFixture  
{  
 public function __construct() {  
 $this->conn = ...  
 $this->itemId = rand(1, 1000); //можливо занадто слабко в паралельному середовищі  

 $this->conn->insert('items', [  
 'id' => $this->itemId,  
 'name' => 'Prefix_' . substr(md5(mt_rand()), 0, 8),  
 'description' => 'This is a sample description.',  
 ]);  

 }  
 public function __destruct() {  
 $conn->delete('items', ['id' => $this->itemId]);  
 }  
 public function getItemId() {  
 return $this->itemId;  
 }   
}

Написання фікстур тестів таким чином може зайняти багато часу, і точна техніка залежить від системи, яка знаходиться вгорі (вищезгаданий приклад з простою базою даних ще досить простий). В основному, мені потрібно позбутися більшості постійних значень фікстур. Але результат того вартий.

pic

Я оголошую незалежність

Повторне використання фікстур

Повторне використання класів фікстур зменшує обсяг тестового (або фікстурного) коду. Тепер я хочу повторно використовувати інстанції фікстур. Загалом це небезпечно і є поганою практикою, тому що інстанція фікстури може бути повторно використана тільки в тому випадку, якщо стан системи, який представляє фікстура, не змінюється жодним із тестів, що використовують цю фікстуру.

Хоча це загалом ризиковано, в моєму випадку фікстура інкапсулює об’єкти, які створюються на віддалених сервісах вгорі і займають значний час для створення (ми говоримо про десятки секунд мінімум). Тому варто спробувати.

З незалежними інстанціями фікстур я можу безпечно запускати тести паралельно. Для повторного використання інстанцій фікстур, мені потрібно створити певний тип кешу.

Кешування

Давайте додамо трохи коду:

abstract class FixtureAwareTestCase extends WebTestCase  
{  
 private function isReusableTestMethod(): bool  
 {  
 $reflection = new ReflectionObject($this);  
 return count(  
 $reflection->getMethod($this->name())->getAttributes(ReusableFixtures::class),  
 ) > 0;  
 }  

 /**  
 * @param class-string $fixtureName  
 */  
 private function createNewFixture(string $fixtureName): FixtureInterface  
 {  
 $fixture = new $fixtureName();  
 $fixture->initialize();  
 return $fixture;  
 }  

 /**  
 * @template T of FixtureInterface  
 * @param class-string $fixtureName  
 * @return T  
 */  
 protected function getFixture(string $fixtureName): FixtureInterface  
 {  
 $isReusable = $this->isReusableTestMethod();  

 if ($isReusable) {  
 $fixture = FixtureCache::getReusable($fixtureName);  
 if ($fixture !== null) {  
 // @phpstan-ignore-next-line  
 return $fixture;  
 }  
 }  

 $fixture = $this->createNewFixture($fixtureName);  
 FixtureCache::add(  
 $fixture,  
 $fixtureName,  
 $isReusable,  
 $this->name(),  
 (string) $this->dataName(),  
 );  
 // @phpstan-ignore-next-line  
 return $fixture;  
 }  
}

Клас FixtureAwareTestCase використовується як базовий клас для тестового набору. Тест починається з виклику методу getFixture.
текст перекладу
Якщо метод тесту має атрибут ReusableFixtures, і кешована інстанція фікстури існує, вона буде повернена, в іншому випадку буде створена нова інстанція фікстури.

Приклад використання цього в тесті:

class GetAutomationsActionTest extends FixtureAwareTestCase  
{  
 #[ReusableFixtures]  
 public function testGetAutomations(): void  
 {  
 $fixture = $this->getFixture(AutomationFixture::class);  
 ... тест  
 }  
}

Це дуже примітивний підхід. Я впевнений, що його можна покращити за допомогою якоїсь магічної інтеграції з PHPUnit, але я не люблю магію, і ця базова реалізація дійсно вирішує мою проблему.

FixtureCache — це просто колекція в пам'яті, хороше в ній те, що я бачу, як вона може стати ще більш тривалою, якщо фікстури будуть серіалізованими. Але це робота на майбутнє.

Реалізація фікстури

Деякі з моїх фікстур дуже складні (200+ рядків коду). Спробуючи зробити їх простішими для створення, я все ж додав трішки магії. Ось фікстура, яка створює сутність у тестовій базі даних:

class ConversationFixture implements FixtureInterface  
{  
 use EntityManagerTrait;  
 private Uuid $conversationId;  

 public function initialize(): void {  
 $conversation = new Conversation(  
 projectId: rand(0, 1000),  
 );  

 $this->conversationId = $conversation->id;  
 $em = $this->getEntityManager();  
 $em->persist($conversation);  
 $em->flush();  
 }  

 public function cleanUp(): void {  
 $em = $this->getEntityManager();  
 $conversation = $em->find(Conversation::class, $this->conversationId);  
 if ($conversation !== null) {  
 $em->remove($conversation);  
 $em->flush();  
 }  
 }  

 public function getConversationId(): Uuid {  
 return $this->conversationId;  
 }  
}

Щоб пропустити ініціалізацію Doctrine EntityManager, я інжектую його у фікстуру при її створенні. Оновлений метод createNewFixture виглядає так:

private function createNewFixture(string $fixtureName): FixtureInterface {  
 $fixture = new $fixtureName();  
 $class = new ReflectionClass($fixtureName);  
 $traits = $class->getTraits();  

 foreach ($traits as $trait) {  
 if ($trait->getName() === EntityManagerTrait::class) {  
 $container = self::getContainer();  
 // @phpstan-ignore-next-line  
 $fixture->setEntityManager($container->get('doctrine')->getManager());  
 }  
 ... інші трейти ...  
 }  
 /** @var FixtureInterface $fixture */  
 $fixture->initialize();  
 return $fixture;  
}

Трейті EntityManagerTrait є простим гетером/сетером в цьому випадку

trait EntityManagerTrait  
{  
 private EntityManagerInterface $em;  

 public function setEntityManager(EntityManagerInterface $em): void {  
 $this->em = $em;  
 }  

 public function getEntityManager(): EntityManagerInterface {  
 return $this->em;  
 }  
}

Це, очевидно, додатковий концепт, але я знайшов його корисним. Чим менше зусиль потрібно для створення нової фікстури, тим менше я схильний до "викручування" існуючих фікстур якимись дивними способами.

Інші варіанти

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

Тест (або фікстура) з параметрами — це запрошення приховати логіку керування потоком у параметрах. Існує величезна різниця в значенні між:

$this->conn->insert('items', [  
 'id' => $this->itemId,  
 'name' => 'Prefix_' . substr(md5(mt_rand()), 0, 8),  
 'description' => $description,  
 ]);

і:

$this->conn->insert('items', [  
 'id' => $this->itemId,  
 'name' => 'Prefix_' . substr(md5(mt_rand()), 0, 8),  
 'description' => $description ?? 'Default description',  
 ]);

Але код настільки схожий, так легко змінюється з чисто декларативного стану системи, що я намагаюсь повністю уникати параметрів у фікстурах.
текст перекладу
Досі мені вдавалося обходитися без них.

Інший варіант — використовувати кілька фікстур для одного тесту. Знову ж таки, технічно це легко можливо. Однак це означає, що фікстура не представляє повний стан системи, лише частково. І ці частини можуть перезаписувати або конфліктувати одна з одною. Заради простоти, я утримуюсь від використання кількох фікстур для одного тесту.

pic

Підсумок

Підхід до тестування фікстур не є новим чи революційним. Але в той же час він суперечить тому, як більшість людей (принаймні тих, хто в моєму соціальному колі) пишуть тести. З мінімальними технічними змінами це призводить до величезних концептуальних змін, що дає:

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

Сподіваюся, це хоча б надихне.

Перекладено з: Fixture Testing in PHPUnit

Leave a Reply

Your email address will not be published. Required fields are marked *