Як написати одиничні тести перед рефакторингом?


55

Я прочитав кілька відповідей на запитання в подібному рядку, як-от "Як ви підтримуєте тести своїх пристроїв під час рефакторингу?". У моєму випадку сценарій трохи відрізняється тим, що мені дали проект переглянути та привести у відповідність до деяких стандартів, які ми маємо, на даний момент для проекту немає тестів взагалі!

Я визначив ряд речей, на які я думаю, що можна було б зробити краще, таких як НЕ змішування коду типу DAO на рівні обслуговування.

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

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

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

Так само я бачу аргумент, що якщо я зміню підпис методів, які можна вважати перепроектуванням.

Ось короткий приклад

MyDocumentService.java (поточний)

public class MyDocumentService {
   ...
   public List<Document> findAllDocuments() {
      DataResultSet rs = documentDAO.findAllDocuments();
      List<Document> documents = new ArrayList<>();
      for(DataObject do: rs.getRows()) {
         //get row data create new document add it to 
         //documents list
      }

      return documents;
   }
}

MyDocumentService.java (реконструйовано / перероблено все, що завгодно)

public class MyDocumentService {
   ...
   public List<Document> findAllDocuments() {
      //Code dealing with DataResultSet moved back up to DAO
      //DAO now returns a List<Document> instead of a DataResultSet
      return documentDAO.findAllDocuments();
   }
}

14
Це дійсно рефакторинг, який ви плануєте зробити, або переробити дизайн ? Тому що відповідь може бути різною у двох випадках.
herby

4
Я працюю над визначенням "З рефакторингом, за визначенням, ви не змінюєте те, що робить ваше програмне забезпечення, ви змінюєте, як це робиться". Тож я вважаю, що в цьому випадку це рефакторинг,
сміливо

21
Не пишіть інтеграційні тести. "Рефакторинг", який ви плануєте, знаходиться вище рівня тестування одиниць. Тільки перевіряйте нові класи (або старі, які ви знаєте, що ви їх зберігаєте).
Перестаньте шкодити Моніці

2
Що стосується визначення рефакторингу, чи програмне забезпечення чітко визначає, що воно робить? Іншими словами, це вже "фактор" в модулі з незалежними API? Якщо ні, то ви не можете перефактурувати його, за винятком, можливо, на найвищому (орієнтованому на користувача) рівні. На рівні модуля ви неминуче перероблятимете його. У цьому випадку не витрачайте час на написання тестів на одиниці, перш ніж у вас є одиниці.
Кевін Крумвіде

4
Вам, швидше за все, доведеться зробити трохи рефакторингу без запобіжних мереж тестів, щоб мати змогу потрапити в тестовий джгут. Найкраща порада, яку я можу дати вам, - це якщо ваш IDE або інструмент рефакторингу не зробить це за вас, не робіть цього вручну. Продовжуйте застосовувати автоматизовані рефактори, поки ви не зможете отримати CUT в джгут. Ви обов'язково захочете забрати копію Майкла Пера "Ефективна робота зі застарілим кодом".
RubberDuck

Відповіді:


56

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

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

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


1
Viz, ви, швидше за все, будете робити інтеграцію чи тестування системи, а не тестування одиниць. Ви, ймовірно, все ще будете використовувати для нього інструмент "тестування одиниць", але при кожному тесті ви будете отримувати більше однієї одиниці коду.
Móż

Так. Це дуже сильно так. Ваш тест регресії цілком може робити щось дуже високий рівень, наприклад, REST-запити на сервер і, можливо, наступний тест бази даних (тобто, безумовно, не тестовий блок !)
Брайан Агнев,

40

Рекомендована практика полягає в тому, щоб почати з написання "прив'язних тестів", які перевіряють поточну поведінку коду, можливо, включаючи помилки, але не вимагаючи від вас сходити в божевіллях, щоб дізнатися, чи є така поведінка, яка порушує вимоги до документів, помилка, вирішення того, чого ви не знаєте, або являє собою незадокументовану зміну вимог.

Має сенс, щоб ці тестові випробування були на високому рівні, тобто інтеграція, а не одиничні тести, щоб вони продовжували працювати, коли ви почнете рефакторинг.

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


+1 для інтеграційних тестів. Залежно від програми, ви можете почати з рівня фактичного надсилання запитів у веб-додаток. Те, що додаток надсилає назад, не повинно змінюватися лише через рефакторинг, хоча якщо він надсилає назад HTML, це, звичайно, менш перевірено.
jpmc26

Мені подобаються тести на фразу "притискання".
Брайан Агнеу

12

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

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

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

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


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

1
По-друге, обидві ці книжкові рекомендації - я завжди маю під рукою, коли мені доводиться мати справу з тестовим кодом.
Toby Speight

5

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

Давайте розглянемо ваш приклад:

public class MyDocumentService {
   ...
   public List<Document> findAllDocuments() {
      DataResultSet rs = documentDAO.findAllDocuments();
      List<Document> documents = new ArrayList<>();
      for(DataObject do: rs.getRows()) {
         //get row data create new document add it to 
         //documents list
      }

      return documents;
   }
}

Ваш тест, ймовірно, виглядає приблизно так:

DocumentDao documentDao = Mock.create(DocumentDao.class);
Mock.when(documentDao.findAllDocuments())
    .thenReturn(DataResultSet.create(...))
assertEquals(..., new MyDocumentService(documentDao).findAllDocuments());

Замість знущань над DocumentDao знущайтеся над його залежностями:

DocumentDao documentDao = new DocumentDao(db);
Mock.when(db...)
    .thenReturn(...)
assertEquals(..., new MyDocumentService(documentDao).findAllDocuments());

Тепер ви можете перемістити логіку MyDocumentServiceзсередини, DocumentDaoне порушуючи тестів. Тести покажуть, що функціональність однакова (поки ви її тестували).


Якщо ви тестуєте DocumentService і не знущаєтесь над DAO, це зовсім не одиничний тест. Це щось середнє між унітарним та інтеграційним тестом. Це не воно?
Лаїв

7
@Laiv, насправді існує велика різноманітність у тому, як люди використовують термін одиничний тест. Деякі використовують це для позначення лише строго ізольованих тестів. Інші включають будь-який тест, який швидко працює. Деякі включають все, що працює в тестовій рамці. Але в кінцевому підсумку не має значення, як ви хочете визначити термін одиничний тест. Питання в тому, які тести корисні, тому ми не повинні відволікатися на те, як саме ми визначаємо одиничний тест.
Вінстон Еверт

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

3

Як ви кажете, якщо змінити поведінку, то це перетворення, а не рефактор. На якому рівні ви змінюєте поведінку, саме це має значення.

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

Щоб вирішити ваше питання щодо зміни впроваджень, що вимагають зміни тестових випадків, я б запропонував вам поглянути на Детройт (класичний) проти Лондона (макет) TDD. Мартін Фаулер розповідає про це у своїй чудовій статті Макети - це не заглушки, але багато людей мають думку. Якщо ви починаєте з найвищого рівня, де зовнішні організації не можуть змінитись, і працюєте вниз, тоді вимоги повинні залишатися досить стабільними, поки ви не досягнете рівня, який дійсно потребує змін.

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


3

Ось мій підхід. Він має витрату в часі, оскільки це тест на рефактор в 4 фази.

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

У будь-якому випадку стратегія дійсна для будь-якого компонента-кандидата, який повинен бути нормалізований інтерфейсом (DAO, Services, Controllers, ...).

1. Інтерфейс

Давайте збираємо всі публічні методи з MyDocumentService і дозволяємо скласти їх усі в інтерфейс. Наприклад. Якщо вона вже існує, використовуйте її замість встановлення будь-якої нової .

public interface DocumentService {

   List<Document> getAllDocuments();

   //more methods here...
}

Тоді ми змушуємо MyDocumentService реалізувати цей новий інтерфейс.

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

public class MyDocumentService implements DocumentService {

 @Override
 public List<Document> getAllDocuments(){
         //legacy code here as it is.
        // with no changes ...
  }
}

2. Одиничне випробування застарілого коду

Тут у нас наполеглива робота. Налаштування тестового набору. Ми повинні встановити якомога більше випадків: успішні випадки, а також випадки помилок. Останні - на користь якості результату.

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

Я не збираюся вникати в деталі, тож пробачте, якщо мій код виглядає занадто простим або занадто агностичним

public class DocumentServiceTestSuite {

   @Mock
   MyDependencyA mockDepA;

   @Mock
   MyDependencyB mockDepB;

    //... More mocks

   DocumentService service;

  @Before
   public void initService(){
       service = MyDocumentService(mockDepA, mockDepB);
      //this is purposed way to inject 
      //dependencies. Replace it with one you like more.  
   }

   @Test
   public void getAllDocumentsOK(){
         // here I mock depA and depB
         // wanted behaivors...

         List<Document> result = service.getAllDocuments();

          Assert.assertX(result);
          Assert.assertY(result);
           //... As many you think appropiate
    } 
 }

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

Примітка. Через те, що не було внесено великих змін, поведінка залишається недоторканою. Я пропоную зробити тут тег у SCM. Тег або гілка не має значення. Просто зробіть версію.

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

3. Рефакторинг

Refactor буде впроваджений у новий компонент. Ми не будемо змінювати існуючий код. Перший крок такий же простий, як зробити копіювання та вставлення MyDocumentService та перейменувати його у CustomDocumentService (наприклад).

Новий клас продовжує впроваджувати DocumentService . Потім перейдіть і рефакторизуйте getAllDocuments () . (Дозволяє запустити по одному. Pin-refactors)

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

Важливо не порушувати / змінювати існуючу реалізацію. Ми хочемо паралельно виконувати обидві служби, а потім порівнювати результати.

public class CustomDocumentService implements DocumentService {

 @Override
 public List<Document> getAllDocuments(){
         //new code here ...
         //due to im refactoring service 
         //I do the less changes possible on its dependencies (DAO).
         //these changes will come later 
         //and they will have their own tests
  }
 }

4. Оновлення DocumentServiceTestSuite

Гаразд, тепер легша частина. Щоб додати тести нового компонента.

public class DocumentServiceTestSuite {

   @Mock
   MyDependencyA mockDepA;

   @Mock
   MyDependencyB mockDepB;

   DocumentService service;
   DocumentService customService;

  @Before
   public void initService(){
       service = MyDocumentService(mockDepA, mockDepB);
        customService = CustomDocumentService(mockDepA, mockDepB);
       // this is purposed way to inject 
       //dependencies. Replace it with the one you like more
   }

   @Test
   public void getAllDocumentsOK(){
         // here I mock depA and depB
         // wanted behaivors...

         List<Document> oldResult = service.getAllDocuments();

          Assert.assertX(oldResult);
          Assert.assertY(oldResult);
           //... As many you think appropiate

          List<Document> newResult = customService.getAllDocuments();

          Assert.assertX(newResult);
          Assert.assertY(newResult);
           //... The very same made to oldResult

          //this is optional
Assert.assertEquals(oldResult,newResult);
    } 
 }

Тепер у нас oldResult і newResult перевірені незалежно, але ми також можемо порівняти один з одним. Останнє підтвердження не є обов'язковим і залежить від результату. Можливо, це не порівнянно.

Можливо, не буде занадто сенситивним порівняння двох колекцій таким чином, але було б дійсним для будь-якого іншого виду об’єктів (pojos, об'єкти моделі даних, DTO, Wrappers, нативні типи ...)

Примітки

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

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

Нарешті, від вас залежить видалення застарілого коду та перенаправлення старих залежностей на нову.

Видаліть також застарілі тести і робота виконана. Якщо ви впорядкували старе рішення з його тестами, ви можете будь-коли перевірити і порівняти один одного.

Як наслідок такої кількості робіт, у вас є перевірений, підтверджений та переосмислений застарілий код. І новий код, перевірений, перевірений і готовий до версії.


3

tl; dr Не пишіть одиничні тести. Пишіть тести на більш відповідному рівні.


З огляду на ваше робоче визначення рефакторингу:

ви не змінюєте те, що робить ваше програмне забезпечення, ви змінюєте, як це робить

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

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

Автоматизовані тести часто класифікуються за рівнем:

  • Одиничні тести - окремі компоненти (класи, методи)

  • Інтеграційні тести - взаємодія між компонентами

  • Системні тести - Повна програма

Напишіть рівень тесту, який може перенести рефакторинг, по суті, недоторканим.

Подумайте:

Яку істотну, загальнодоступну поведінку матиме програма як до, так і після рефакторингу? Як я можу перевірити, що річ все ще працює так само?


2

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

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

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


Завжди цікавляться причинами заборони!
topo morto

1

Ваше перше завдання - спробувати створити "ідеальний метод підпису" для ваших тестів. Прагніть зробити це чистою функцією . Це має бути незалежним від коду, який фактично перевіряється; це невеликий адаптерний шар. Введіть свій код у цей адаптерний шар. Тепер, коли ви перефактуруєте код, вам потрібно лише змінити шар адаптера. Ось простий приклад:

[TestMethod]
public void simple_addition()
{
    Assert.AreEqual(7, Eval("3 + 4"));
}

[TestMethod]
public void order_of_operations()
{
    Assert.AreEqual(52, Eval("2 + 5 * 10"));
}

[TestMethod]
public void absolute_value()
{
    Assert.AreEqual(9, Eval("abs(-9)"));
    Assert.AreEqual(5, Eval("abs(5)"));
    Assert.AreEqual(0, Eval("abs(0)"));
}

static object Eval(string expression)
{
    // This is the code under test.
    // I can refactor this as much as I want without changing the tests.
    var settings = new EvaluatorSettings();
    Evaluator.Settings = settings;
    Evaluator.Evaluate(expression);
    return Evaluator.LastResult;
}

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

static object Eval(string expression)
{
    // After refactoring...
    var settings = new EvaluatorSettings();
    var evaluator = new Evaluator(settings);
    return evaluator.Evaluate(expression);
}

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

Звичайно, ця методика може виявитися недоцільною у всіх ситуаціях. Наприклад, не було б причин писати адаптери для POCO / POJO, оскільки вони насправді не мають API, який міг би змінюватися незалежно від тестового коду. Крім того, якщо ви пишете невелику кількість тестів, відносно великий шар адаптера, ймовірно, буде витрачено на максимум зусиль.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.