Перейти до основного змісту

Продуктивність модульного тестування JavaScript

· 9 хвилин читання

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

Однак, з додаванням все більшої кількості тестів, ми помітили, що продуктивність Jest переставала масштабуватись. Крім того, за останній рік, екосистема JavaScript кардинально змінилася після введенням речей на кшталт npm3 і Babel, чого ми не очікували. Ми сформували нову команду Jest для розв'язання цих проблем, а також відтепер ділитимемось своїм прогресом і планами в цьому блозі.

Jest трішки відрізняється від більшості виконавців тестів. Його проєктували в контексті інфраструктури Facebook:

  • Монорепозиторій У Facebook, ми маємо величезний монорепозиторій, що містить весь наш JavaScript код. Є багато причин, чому такий підхід є перевагою для нас, а також є чудова промова інженера Google, яка висвітлює всі переваги та недоліки монорепозиторіїв.
  • Пісочниця Ще одна функція Jest, яка важлива для Facebook через віртуалізацію тестового середовища, а також огортання require для ізоляції виконання коду та окремих тестів. Ми навіть працюємо над тим, щоб зробити Jest більш модульним, щоб ми могли скористатися перевагою цього функціоналу в випадках, які не стосуються тестів.
  • providesModule Якщо ви до цього бачили будь-який з наших JavaScript проєктів з відкритим вихідним кодом, ви могли помітити, що ми використовуємо заголовок @providesModule для призначення глобальних унікальних ID для модулів. Це потребує користувацьких інструментів, проте дозволяє нам посилатись на модулі без відносних шляхів, що допомогло нам неймовірно швидко просуватися, збільшити масштаби відповідно до зростання нашої інженерної організації, та сприяло обміну кодом всередині всієї компанії. Приклад використання на практиці можна переглянути в RelayContainer. Єдина проблема такого підходу - ми змушені читати та аналізувати всю нашу базу JavaScript коду, щоб розв'язати одне єдине require твердження. Це, очевидно, було б надзвичайно витратно без великомасштабного кешування, особливо для нетривалих процесів на зразок Jest.

В результаті цих унікальних обмежень, Jest, можливо, ніколи не працювати так швидко, як інші виконувачі тестів, при виконанні всього нашого набору тестів. Однак, інженерам рідко потрібно запускати Jest на всьому нашому тестовому наборі. Використовуючи статичний аналіз проєкту node-haste, ми змогли встановити режим jest --onlyChanged (або jest -o) за замовчуванням для запуску Jest в Facebook. У цьому режимі, ми будуємо графік зворотної залежності, щоб знайти лише ті тести, які, в залежності від змінених модулів, повинні бути виконані.

Оптимальне планування тестового запуску

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

Нещодавно ми помітили, що Jest став часто застрягати на моменті “Очікування трьох тестів” приблизно на хвилину ближче до кінця запуску. Виявилося, що в нашій базі коду було декілька дуже повільних тестів, які сильно виділялись на фоні часу виконання тестів. Хоча ми значно прискорили ці тести в індивідуальному порядку, ми також змінили для Jest процес планування запуску тестів. Раніше ми планували запуски тестів на основі обходу файлової системи, який насправді працював досить випадковим чином. Нижче наведений приклад 11 тестів у сірих блоках, розділені між двома робочими процесами. Розмір блоку - це контрольний час тесту:

perf-basic-scheduling

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

Після внесення змін, ми плануємо запуски тести на основі файлових розмірів, які, як правило, є хорошими показниками часу виконання тесту. Тест з кількома тисячами рядків коду, ймовірно, займе більше часу, ніж тест з 15 рядками коду. І хоча даний підхід прискорює весь тестовий запуск на приблизно 10%, цей досвід дозволив нам знайти навіть краще рішення: тепер Jest кешує контрольний час кожного тесту та, при повторному запуску, планує найповільніші тести першими для виконання. Загалом, це покращило контрольний час всіх тестів приблизно на 20%.

Ось приклад запуску тесту, ідентичного попередньому, з кращим алгоритмом планування:

perf-improved-scheduling

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

Лінійні require та ліниві імітації

Якщо ви раніше вже писали тести з використанням Jasmine, вони, скоріш за все, виглядають наступним чином:

const sum = require('sum');
describe('sum', () => {
it('works', () => {
expect(sum(5, 4)).toBe(9);
});
});

Одна цікава особливість Jest в тому, що ми скидаємо весь реєстр модулю після кожного тесту (виклик it), аби переконатись у взаємній незалежності тестів. До Jest, окремі тести залежали один від одного та внутрішній стан модуля часто просочуються між ними. З часом, інженери видаляли, впорядковували чи переробляли тести, внаслідок чого деякі з них почали викликати помилки; людям було важко зрозуміти, що відбувається.

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

let sum;
describe('sum', () => {
beforeEach(() => {
sum = require('sum');
});
it('works', () => {
expect(sum(5, 4)).toBe(9);
});
it('works too', () => {
// Ця копія sum відрізняється від попереднього виклику `it`.
expect(sum(2, 3)).toBe(5);
});
});

Ми створили babel перетворення під назвою inline-requires який видаляє require вимоги верхнього рівня та вбудовує їх у код. Наприклад, рядок const sum = require('sum'); буде видалено з коду, але кожне використання sum у файлі буде замінено на require('sum'). За допомогою цього перетворення ми можемо написати тести, подібні очікуваним в Jasmine і код перетвориться на це:

describe('sum', () => {
it('works', () => {
expect(require('sum')(5, 4)).toBe(9);
});
});

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

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

Ми мали змогу швидко оновити всі тести за допомогою codemod - змінено було всього-на-всього 50,000 рядків коду. Вбудовані require та лінива імітація покращили часові показники роботи тестів на 50%.

Babel плагін inline-require корисний не тільки для Jest, а й для звичайного JavaScript. Він був опублікований Bhuwan для всіх користувачів facebook.com тиждень тому та значно покращив час запуску.

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

Синхронізація репозиторію та кешування

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

Першою функцією, якою ми скористались після розгалуження, було препроцесорне кешування. Якщо ви користуєтесь Babel разом з Jest, Jest повинен попередньо обробити кожен JavaScript файл, перш ніж він зможе його виконати. Ми створили шар кешу для того, щоб кожен файл, у разі відсутності змін, мав трансформуватись лише один раз. Після розгалуження Jest, ми змогли легко виправити реалізацію з відкритим вихідним кодом й опублікувати її в Facebook. Це призвело до чергового покращення продуктивності на 50%. Через те, що кеш спрацьовує лише на другий запуск, час холодного старту Jest не змінився.

Також стало зрозуміло, що ми робимо багато шляхових операцій при вирішенні відносних require. Оскільки реєстр модулів скидається для кожного тесту, можна мемоізувати тисячі викликів. Велика оптимізація полягала в тому, щоб додати значно більше кешування не тільки для одного тесту, а й для всіх тестових файлів. Раніше ми генерували модуль метаданих для опції автоімітації в кожному тестовому файлі. При цьому, експортований модуль ніколи не змінюється, тому тепер єдиний код поширюється на всі тестові файли. На жаль, через те, що JavaScript і Node.js не мають спільного використання пам'яті, ми повинні виконувати цей процес принаймні один раз для кожного робочого процесу.

Все піддавайте сумнівам

При спробі покращити продуктивність, важливо також зазирнути в системи, які розташовані рівнем вище та нижче вашої системи. Наприклад, Node.js і самі тестові файли, як у випадку Jest. Одним з перших кроків було оновлення Node.js в Facebook від стародавнього 0.10 до iojs, а потім до Node 4. Нова версія V8 допомогла покращити продуктивність та не викликала проблем при оновленні.

Ми помітили, що модуль path в Node.js сповільнений при створенні тисяч шляхових операцій, що було виправлено в Node 5.7. Доки ми не зупинимо підтримку Node 4 всередині Facebook, ми публікуватимемо нашу власну версію модуля fastpath.

Далі ми звернули увагу на застарілу node-haste. Як описувалось раніше, весь проєкт повинен обробитись, аби заголовки @providesModule побудували графік залежності. Коли ця система будувалась, node_modules не існувало та наш сканер файлових систем не виключав їх належним чином.

У попередніх версіях, Jest читав би кожен файл в node_modules, що збільшувало б його час запуску. Коли ми знову взялись за Jest, ми замінили увесь проєкт новою реалізацією на основі пакувальника react-native. Наразі час запуску Jest менше секунди навіть у великих проєктах. Команда react-native, зокрема David, Amjad та Martin, виконала неперевершену роботу.

Підсумовуючи

Зміни, вказані вище, покращили контрольний час всіх тестів на 10%, а іноді навіть на 50%. Ми розпочинали з часом виконання всіх тестів в межах 10 хвилин, і без цих вдосконалень ми, скоріш за все, були б вже ближче до 20 хвилин. Однак, після цих вдосконалень, зараз стабільний час виконання всіх тестів становить близько 1 хвилини та 35 секунд!

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

З нещодавнім релізом Jest 0.9 та покращенням продуктивності шляхом інтеграції node-haste2, час виконання набору тестів фреймворку Relay зменшився з 60 секунд до 25, а набір тестів react-native тепер виконується менш ніж за 10 секунд на 13” MacBook Pro.

На цю мить, ми дуже задоволені результатами та будемо продовжуватимемо працювати над покращенням роботи Jest. Якщо у вас з'явилось бажання зробити свій внесок у Jest, нас можна знайти в GitHub, Discord чи Facebook :)