Меню Закрити

Як написати код, який можна буде легко тестувати

 

Багато розробників ставляться до тестування з неприязню. Основною причиною цього часто є висока зв’язність коду. Такий код дійсно важко тестувати.

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

У статті розглядаються принципи SOLID, Law of Demeter (закон Деметри), інші рекомендації, а на завершення — невеликий приклад, що ілюструє деякі з розглянутих аспектів.

Принципи проектування SOLID

Одним із найвідоміших наборів принципів в індустрії програмного забезпечення є принципи SOLID, задокументовані Робертом Мартіном (також відомим як дядько Боб).

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

Принцип єдиної відповідальності (Single Responsibility Principle — SRP)

Кожен модуль програмного забезпечення має містити лише одну причину для зміни.

Іншими словами — клас повинен мати лише одну функціональність.

Уявіть, що ви пишете програму, яка викликає зовнішній API, виконує певну обробку отриманих даних і записує їх у файл. Наївний підхід полягає в тому, щоб об’єднати це все в одному класі. Однак цей клас має кілька причин для зміни:

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

Із цих причин ви повинні розбити вашу програму на декілька класів та інтерфейсів. Ось приклад:

interface DataInput {
  Data get();
}

interface DataOutput {
  void write(ProcessingResult result);
}

interface DataProcessingStrategy {
  ProcessingResult process(Data data);
}

class DataProcessor {
  DataProcessor(DataInput dataInput, DataOutput dataOutput, DataProcessingStrategy strategy) {
    ...
  }
  process() {
    Data data = this.dataInput.get();
    ProcessingResult result = this.strategy.process(data);
    this.dataOutput.output(result);
  }
}

Цей принцип може застосовуватися як на рівні класу, так і на рівні методів (і певною мірою на рівні пакету, але Robert Martin дає для цього випадку окремі рекомендації).

Утім, будьте обережні, аби не перестаратися. Принцип єдиної відповідальності не передбачає, що модуль має робити тільки щось одне. Тут ідеться про те, що він повинен мати лише одну причину для зміни. Щоб дізнатися більше, будь ласка, прочитайте цей пост у блозі Robert Martin.

Принцип відкритості/закритості (Open/Closed Principle — OCP)

Ваші класи мають бути відкриті для розширення, але закриті для модифікацій.

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

Найкращий спосіб збагнути цей принцип — подумати про архітектуру плагінів. За таким сценарієм плагіни розроблені для додавання поведінки до системи без зміни її коду.

Іще один менший, але конкретніший приклад — метод JavaCollections.sort. Він може сортувати будь-який тип класу, який реалізує Comparable (сумісний) інтерфейс без зміни методу сортування для кожного нового класу. Якщо у вас натомість був метод сортування з оператором switch для кожного типу, який треба співставити, то доводиться змінювати його щоразу, коли потрібно порівнювати новий тип.

Більш детальну інформацію про цей принцип можна прочитати в пості блогу від Robert Martin.

Принцип заміни Ліскова (Liskov Substitution Principle – LSP)

Об’єкти суперкласу мають бути замінювані на об’єкти його підкласів без порушення програми.

Хорошим прикладом для цього принципу є проблема квадратного прямокутника: у вас може бути спокуса мати Shape інтерфейс: Rectangle (прямокутник), який реалізує це, і Square (квадрат), який розширює його, але гарантує, що висота й ширина завжди однакові. На перший погляд, це може мати гарний вигляд. Однак цей приклад порушує принцип заміни Ліскова, тому що, навіть якщо програмний API однаковий, його передумови й постумови такими не є. Якщо у вас є клас або метод, який приймає Rectangle, ви не зможете просто перейти на Square, оскільки код може передбачати, що висоту та ширину можна змінювати незалежно. Для користувача інтерфейсу реалізація не повинна мати значення.

Іншим поширеним прикладом порушення цього принципу є реалізація методів, які викидають UnsupportedOperationException, що вказує на те, що певна операція не підтримується в конкретних реалізаціях.

Принцип сегрегації інтерфейсу (Interface Segregation Principle — ISP)

Жоден клієнт не мусить залежати від методів, які він не використовує.

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

Дотримання цього принципу допомагає зберегти вашу систему відокремленою й не порушувати принципу заміни Ліскова.

Наприклад, якщо ви створюєте інтерфейс для багатофункціонального принтера замість того, щоб мати один інтерфейс MultiFunctionalPrinter із методом print() і scan(), ви повинні мати два інтерфейси: принтер і сканер, кожен із відповідним методом. Тож, якщо клієнту потрібен лише метод print(), ви можете надати йому простий принтер без потреби змінювати код програми.

Принцип інверсії залежностей (Dependency Inversion Principle — DIP)

Модулі високого рівня не повинні залежати від модулів низького рівня; ті й інші мають залежати від абстракцій.

Абстракції не мають залежати від деталей. Деталі мають залежати від абстракцій.

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

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

Приклад використання принципу інверсії залежностей

Для отримання додаткової інформації на тему абстракцій можна прочитати цей пост від Gabriel Candal на Feedzai TechBlog.

Закон Деметри (Law of Demeter — LoD)

Іще один “закон”, корисний, щоб зберігати код роздільним і придатним для тестування — закон Деметри. Цей принцип передбачає таке:

Кожен підрозділ повинен мати лише обмежені знання про інші підрозділи: тільки ті підрозділи, що тісно пов’язані з ним.

Кожен підрозділ повинен спілкуватися лише зі своїми друзями; не розмовляйте з незнайомцями.

Розмовляйте лише з близькими друзями.

Загалом це означає, що ви не повинні отримувати залежності через ваші залежності. Ви не повинні робити такі речі, як this.getA().GetB().DoSomething(). Якщо вам потрібна залежність B, вона повинна бути надана вашому класу через конструктор або як аргумент методу.

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

Більш детальну інформацію про цей принцип можете знайти в цьому пості з блогу Miško Hevery.

Додаткові рекомендації

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

Переконайтеся, що у вашому коді є шви: із визначення в статті “Ефективна робота з  успадкованим кодом (Legacy Code)” від Michael Feathers, “шов — це місце, де ви можете змінити поведінку в програмі без редагування цього місця”. Наявність швів потрібна для того, щоб об’єднати тестовий код тоді, як ви можете замінити поведінку дублерами тестів. Дотримання зазначених принципів допоможе із цим так само, як і практикування Test-Driven-Development, оскільки модульні тести можуть допомогти з розробкою ваших API.

Не змішуйте створення об’єкта з логікою програми: не слід недбало створювати екземпляри об’єктів. Замість цього ви повинні мати два типи класів: класи додатків і фабрики класів. Класи додатків — це ті, що виконують реальну роботу й містять увесь код, що реалізує функціональність додатку, тоді як фабрики використовуються для створення об’єктів і відповідних залежностей. Слід уникати створення нових об’єктів поза фабриками, за винятком створення об’єктів даних (структури даних або об’єктів лише з getters/setters — методами читання/запису), які можна створювати вільно. Якщо ви створюєте інші класи в програмному коді, то не зможете замінити їх дублерами тесту під час тестування юніту (якщо ви не використовуєте monkey-patching — маніпулювання кодом, але це призводить до тестів, які важко створити та підтримувати). Якщо ви маєте динамічно створювати об’єкти в програмному коді, то слід скористатися шаблоном проектування Abstract Factory. Так можна передати конкретну фабрику як залежність, що реалізує цей інтерфейс, і ваш програмний код зможе створювати об’єкти без залежності від конкретної реалізації.

Використовуйте впровадження залежностей: як зазначено вище, ви маєте забезпечити залежності для ваших класів. Клас не має відповідати за вибір своїх залежностей, ані створюючи їх з використанням глобального стану (наприклад, Singletons), ані отримуючи їх через інші залежності (порушуючи закон Деметри). Бажано, щоб залежність надавалася класу через його конструктор. Зауважте, що впровадження залежностей не є синонімом використання фреймворку. Це те, що може бути легко виконано вручну.

Не використовуйте глобальний стан: глобальний стан робить код складним для розуміння, оскільки користувач таких класів може не знати, які змінні мають бути створені. Це також ускладнює написання тестів з тієї ж причини, а також через те, що тести можуть впливати один на одного. Крім того, пам’ятайте, що Singletons є прикладом глобального стану, тому його слід уникати в більшості випадків. Зверніть увагу, що тут під “Singleton” маємо на увазі шаблон проектування Singleton, в якому клас обмежує своє створення лише одним екземпляром. Натомість рекомендуються класи, які мають єдиний екземпляр без реалізації, іноді називають “singletons” без  великої літери “S”. Запровадження залежностей слід використовувати для передавання екземплярів об’єктам, які від них залежать.

Уникайте статичних методів: статичні методи є процедурним кодом, їх слід уникати в об’єктно-орієнтованій парадигмі, оскільки вони не забезпечують шви, потрібні для модульного тестування. Винятками з цього є прості й чисті методи, як Math.min(). Проте, ви, можливо, захочете уникнути безпосереднього використання таких статичних методів, як System.currentTimeMillis(), оскільки не зможете замінити його дублером тесту. Натомість, якщо у вас є інтерфейс TimestampSupplier або просто Supplier<Long>, то можна вбудувати  реалізацію, яка використовує System.currentTimeMillis(), у ваш остаточний код і використовувати фіктивну реалізацію в тестовому коді, що може допомогти створити кращі твердження (asserts) в тестах.

Перевага композиції над успадкуванням: ви маєте надавати перевагу використанню композиції над успадкуванням. Композиція дає змогу вашому коду краще дотримуватися принципу єдиної відповідальності, робить код простішим для тестування. Композиція забезпечує більшу гнучкість, оскільки поведінка системи моделюється різними інтерфейсами, які співпрацюють, замість створення ієрархії класів, що розподіляє поведінку серед класів предметної області через успадкування. Це також робить систему гнучкішою, оскільки компоненти можуть бути зібрані різними способами під час виконання, без зміни коду. Існують шаблони проектування, які допомагають у цьому, зокрема шаблони Strategy та Decorator.

Невеликий приклад

Нижче наведено приклад коду, складного для тестування, оскільки він має високий ступінь зв’язаності з багатьма аспектами системи. Цей невеликий зразок коду порушує більшість принципів SOLID, закон Деметри й зазначені принципи. Щоб його протестувати, доведеться використовувати mock-фреймворк (наприклад, PowerMock), що призводить до створення тестів, які важко писати й підтримувати.

// Difficult to test - coupled to the application, database and file system
public class MyClass {
  
  public void writeUserName(int id) {
    String userName = App.getDatabaseManager().getUserDatabase().getUserName(id);
    try (FileWriter writer = new FileWriter("user.txt")) {
      writer.write(userName);
    }
  }
}

А ось інший зразок коду, хоч і не ідеальний, але його значно простіше тестувати. У цьому прикладі можна просто надати фіктивні дані UserDatabase і StringWriter замість FileWriter.

// Easy to test - you can easily replace UserDatabase and Writer with test doubles
public class MyClass {
  private final UserDatabase userDatabase;
  
  public MyClass(final UserDatabase userDatabase) {
    this.userDatabase = userDatabase
  }
  
  public void writeUserName(int id, Writer writer) {
    final String userName = this.userDatabase.getUserName(id);
    writer.write(userName);
  }
}

Якщо хочете дізнатися більше про ці поняття — рекомендуємо:

Clean Code (Чистий код) and Clean Architecture (Чиста архітектура) — це дві книжки Роберта Мартіна, які навчать вас наведених тут принципів, а також багато іншого.

Якщо ви знайшли помилку, будь ласка, виділіть фрагмент тексту та натисніть Ctrl+Enter.

Джерело:

Writing Testable Code

0

Повідомити про помилку

Текст, який буде надіслано нашим редакторам: