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

javascript-unit-testing-performance

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

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

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

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

  • Monorepo At Facebook we have a huge monorepo that contains all of our JavaScript code. There are many reasons why this approach is advantageous for us and there is an awesome talk by a Google engineer that highlights all the benefits and drawbacks of monorepos.
  • Sandboxing Another feature of Jest that's important to Facebook is how it virtualizes the test environment and wraps require in order to sandbox code execution and isolate individual tests. Ми навіть працюємо над тим, щоб зробити Jest більш модульним, щоб ми могли скористатися перевагою цього функціоналу в випадках, які не стосуються тестів.
  • providesModule If you've looked at any of our open source JavaScript projects before, you may have noticed that we use a @providesModule header to assign globally unique IDs to modules. Це потребує користувацьких інструментів, проте дозволяє нам посилатись на модулі без відносних шляхів, що допомогло нам неймовірно швидко просуватися, збільшити масштаби відповідно до зростання нашої інженерної організації, та сприяло обміну кодом всередині всієї компанії. Check out RelayContainer for an example of how this works in practice. Єдина проблема такого підходу - ми змушені читати та аналізувати всю нашу базу JavaScript коду, щоб розв'язати одне єдине require твердження. Це, очевидно, було б надзвичайно витратно без великомасштабного кешування, особливо для нетривалих процесів на зразок Jest.

В результаті цих унікальних обмежень, Jest, можливо, ніколи не працювати так швидко, як інші виконувачі тестів, при виконанні всього нашого набору тестів. Однак, інженерам рідко потрібно запускати Jest на всьому нашому тестовому наборі. Powered by static analysis in the node-haste project – we've been able to make the default mode for running Jest at Facebook jest --onlyChanged, or jest -o. У цьому режимі, ми будуємо графік зворотної залежності, щоб знайти лише ті тести, які, в залежності від змінених модулів, повинні бути виконані.

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

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

Recently we noticed Jest often seemed stuck “Waiting for 3 tests” for up to a minute toward the end of a run. Виявилося, що в нашій базі коду було декілька дуже повільних тестів, які сильно виділялись на фоні часу виконання тестів. Хоча ми значно прискорили ці тести в індивідуальному порядку, ми також змінили для 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);
});
});

One special thing we do in Jest is reset the entire module registry after every single test (call to it) to make sure tests don't depend on each other. До Jest, окремі тести залежали один від одного та внутрішній стан модуля часто просочуються між ними. З часом, інженери видаляли, впорядковували чи переробляли тести, внаслідок чого деякі з них почали викликати помилки; людям було важко зрозуміти, що відбувається.

Кожен тест в Jest отримує нову копію всіх модулів, включно з новими версіями всіх імітованих залежностей, що потребує багато часу при створенні кожного тесту. A side effect of this is that we had to call require manually before every test, like this:

let sum;
describe('sum', () => {
beforeEach(() => {
sum = require('sum');
});
it('works', () => {
expect(sum(5, 4)).toBe(9);
});
it('works too', () => {
// This copy of sum is not the same as in the previous call to `it`.
expect(sum(2, 3)).toBe(5);
});
});

We built a babel transform called inline-requires that removes top-level require statements and inlines them in code. For example, the line const sum = require('sum'); will be removed from code, but every use of sum in the file will be replaced by require('sum'). За допомогою цього перетворення ми можемо написати тести, подібні очікуваним в Jasmine і код перетвориться на це:

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

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

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

We were able to update all tests using a codemod in no time – it was a simple 50,000 line code change. Вбудовані require та лінива імітація покращили часові показники роботи тестів на 50%.

Babel плагін inline-require корисний не тільки для Jest, а й для звичайного JavaScript. It was shipped by Bhuwan to all users of facebook.com just a week ago and significantly improved startup time.

Наразі, якщо ви хочете використовувати це перетворення в 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 допомогла покращити продуктивність та не викликала проблем при оновленні.

We noticed that the path module in Node.js is slow when making thousands of path operations which was fixed in Node 5.7. Until we drop support for Node 4 internally at Facebook, we'll ship our own version of the fastpath module.

We next started questioning the outdated node-haste. As mentioned before, the entire project has to be parsed for @providesModule headers to build a dependency graph. When this system was originally built, node_modules didn't exist and our file system crawler wasn't excluding them properly.

In previous versions, Jest would actually read every file in node_modules – which contributed to the slow startup time of Jest. Коли ми знову взялись за Jest, ми замінили увесь проєкт новою реалізацією на основі пакувальника react-native. Наразі час запуску Jest менше секунди навіть у великих проєктах. The react-native team, specifically David, Amjad and Martin did an outstanding job on this project.

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

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

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

With Jest's recent 0.9 release and performance improvements from the node-haste2 integration, the runtime of the Relay framework's test suite went down from 60 seconds to about 25 and the react-native test suite now finishes in less than ten seconds on a 13” MacBook Pro.

На цю мить, ми дуже задоволені результатами та будемо продовжуватимемо працювати над покращенням роботи Jest. If you are curious about contributing to Jest, feel free get in touch on GitHub, Discord or Facebook :)