Тестируем сервис-воркеры

В этой статье мы рассмотрим, как разработчики Chrome тестируют сервис-воркеры при создании библиотек. Цель этого текста – привести практические примеры и описать методы тестирования сервис-воркеров, которые вы можете взять на вооружение и потом применить для рабочих задач.

Быть может, вы хотите убедиться, что сервис-воркер кэширует нужные данные, или проверить, что сайт возвращает правильные ответы в офлайн-режиме, или протестировать какое-нибудь событие на сайте, например push-уведомления. Неважно, что именно вам нужно проверить – это статья в любом случае поможет найти подход к тестированию кода.

Типы тестирования

Существует два подхода к тестированию сервис-воркеров. Мы можем тестировать конкретные функции или сценарии использования, например, как работает веб-приложение оффлайн. Или же мы можем сместить фокус на методологию тестирования сервис-воркеров. В этом случае мы проверяем то, как написан юнит-тест для среды сервис-воркеров, безотносительного того, что именно он тестирует.

Этот текст – про методологию. Рассмотрение методов должно дать нам понять, как применить их к любому API или функции, относящейся к сервис-воркерам, будь то офлайн кэширование, push-уведомления, фоновая синхронизация или ещё не реализованный API.

Тестирование сервис-воркеров можно разделить на группы по типу тестов. На верхнем уровне – интеграционные тесты, которые проверяют общее поведение. Например, тест загружает страницу, регистрирует сервис-воркер, останавливает сервер (имитируя оффлайн-режим), обновляет страницу и проверяет, загрузится ли она из кэша сервис-воркера. Такие тесты занимают много времени и могут периодически прерываться из-за проблем с веб-драйвером и/или состояния гонки.

На следующем вниз по иерархии уровне выполняются юнит-тесты в контексте браузера. Эти тесты регистрируют сервис-воркер и анализируют результаты. Например, тест регистрирует сервис-воркер, ждет, пока он установится, и проверяет, был ли создан определенный кэш с определённым набором данных. Юнит-тесты тоже не особо быстрые, но цикл разработки в браузере все же обычно быстрее, чем у интеграционных тестов.

Опускаемся на ещё один уровень. Тут мы можем создать юнит-тесты в контексте сервис-воркера. Такие тесты особенно полезны для функций, которые используют JavaScript API, доступные только внутри сервис-воркера. В данном случае обычно требуется, чтобы логика проверяемой функции была автономной. Например, тест вызывает функцию, которая показывает уведомление при push-событии: при первом вызове функции должно появиться уведомление, а при втором вызове оно должно смениться новым уведомлением со свернутым текстом.

Можно утверждать, что все эти тесты являются интеграционными, поскольку они требуют запуска в реальном браузере, а это уже означает, что нужно помучиться, чтобы это автоматизировать. Последний уровень тестирования – это имитация API, которые вы пытаетесь проверить. Такие тесты быстрее и надежнее. Единственный реальный риск состоит в том, что имитация может не соответствовать поведению браузера.



Для всех этих тестов есть свое время и место, и вы должны понять, когда вашему проекту нужен тот или иной тест. В интеграционных тестах нет смысла, если не проводить их регулярно.

В этой статье рассматривать методологию каждого теста мы будем в следующем порядке:

  1. Юнит-тесты в браузере
  2. Юнит-тесты в сервис-воркере
  3. Интеграционные тесты
  4. Имитация среды

Я выбрал такую последовательность, потому что мне кажется более естественным начать внутри браузера, а потом выйти за его пределы.

Инструменты

Я буду пользоваться средой тестирования MochaJS. Это не означает, что Mocha – незаменимый или лучший инструмент для тестирования сервис-воркеров. Но это понятная среда, и вы сможете применить методы, которые я использовал в Mocha, в любой тестировочной системе на ваше усмотрение. Если не получится, может, стоит попробовать Mocha.

Юнит-тесты в браузере

Mocha JS поддерживает написание тестов в браузере, поэтому стандартный порядок работы такой: создаете html-страницу, подключаете Mocha с помощью тега script аналогично тому как вы подключите тестируемые скрипты, а далее создаете новый html-файл и сохраняете его как /test/browser/index.html в своем проекте.

<html>
<head>
  <meta charset="utf-8">
  <title>Mocha Tests</title>
  <link href="https://cdn.rawgit.com/mochajs/mocha/2.2.5/mocha.css" rel="stylesheet" />
</head>
<body>
  <!-- Mocha Requires a <div> with ID Mocha to Inject UI -->
  <div id="mocha"></div>

  <script src="/node_modules/mocha/mocha.js"></script>

  <script>mocha.setup('bdd')</script>

  <!-- This Projects Libraries / Pieces of Code Here -->
  <script src=”.....”></script>

  <!-- This Projects Unit Tests Here -->
    <script src=”.....”></script>

  <script>
    mocha.checkLeaks();
    mocha.run();
  </script>
</body>
</html>

Если мы загрузим такую страницу в браузере, мы увидим вот такой интерфейс:

Впечатляет, да?
Впечатляет, да?

Теперь можно начинать создавать базовый тест для сервис-воркера.
Создадим новый файл и назовем его /test/browser/first-browser-test.js. Добавляя новые тесты, полезно группировать их по «видам» в папках (например, все тесты в браузере размещаются в /test/browser/, все тесты сервис-воркера – в /test/sw/ и т.д.)

describe(‘Service Worker Suite’, function() {
  it(‘should register a service worker and cache file on install’, function() {
    // 1: Register service worker.
    // 2: Wait for service worker to install.
    // 3: Check cache was performed correctly.
  });
});

Далее добавляем новый тест на нашу html-страницу:

<!-- This Projects Unit Tests Here -->
<script src="/test/browser/my-first-test.js"></script>

В результате тест появится на странице и начнет работу:



Пришло время наполнить юнит-тест кодом, который и будет использовать наш сервис-воркер. Для начала регистрируем сервис-воркер:

it(‘should register a service worker and cache file on install’, function() {
  // 1: Register service worker.
  // 2: Wait for service worker to install.
  // 3: Check cache was performed correctly.

  return navigator.serviceWorker.register(‘/test/static/my-first-sw.js’);
});

Если мы просто запишем этот код и запустим его, тест завершится неудачей, потому что /test/static/my-first-sw.js не существует.



Тем не менее, польза от этого теста есть. Если в веб-приложении есть сервис-воркер, вот такой простой тест покажет, все ли в порядке с файлом или регистрацией.

Если мы создадим пустой файл для сервис-воркера /test/static/my-first-sw.js, мы снова получим успешный тест.

Теперь давайте рассмотрим шаг «Wait for service worker to install» («Ждите, пока сервис-воркер установится») из инструкции к юнит-тесту.

Мы должны ждать, потому что, если мы хотим проверить, были ли кэшированы нужные файлы, «установка» сервис-воркера – это сигнал о том, что он кэшировал всё, что требовалось, и мы можем приступать к тестированию.

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

Нам нужно начать установку сервис-воркера и добавить обработчик событий, отслеживающий изменение состояния. Так мы узнаем, когда сервис-воркер установится (или перейдет в любое другое состояние). Далее мы убираем обработчик события statechange и переводим возвращенный промис в состояние «выполнен успешно с результатом» с помощью resolve или «выполнен с ошибкой» с помощью reject.

window.__waitForSWState = (registration, desiredState) => {
  return new Promise((resolve, reject) => {
    let serviceWorker = registration.installing;

    if (!serviceWorker) {
      return reject(new Error('The service worker is not installing. ' +
        'Is the test environment clean?'));
    }

    const stateListener = (evt) => {
      if (evt.target.state === desiredState) {
        serviceWorker.removeEventListener('statechange', stateListener);
        return resolve();
      }

      if (evt.target.state === 'redundant') {
        serviceWorker.removeEventListener('statechange', stateListener);

        return reject(new Error('Installing service worker became redundant'));
      }
    };

    serviceWorker.addEventListener('statechange', stateListener);
  });
}

Сохраняем это в отдельный файл и добавляем к нашей index.html странице.

<!-- This Projects Libraries / Pieces of Code Here -->
<script src="/test/utils/wait-for-sw-state.js"></script>

Теперь переходим ко второму этапу юнит-теста – ждем установку сервис-воркера.

it('should register a service worker and cache file on install', function() {
  // 1: Register service worker.
  // 2: Wait for service worker to install.
  // 3: Check cache was performed correctly.

  return navigator.serviceWorker.register('/test/static/my-first-sw.js')
  .then((reg) => {
    return window.__waitForSWState(reg, 'installed');
  });
});

Но, увы, у нас снова неудачный тест.



Что происходит? Почему появляется ошибка «The service worker is not installing. Is the test environment clean?» («Сервис-воркер не устанавливается. Проверьте, очищена ли тестовая среда»).

Если вы очистите кэш всех сервис-воркеров в DevTools (инструменты Chrome) и запустите тест, он пройдет успешно. Но если вы обновите страницу, снова появится ошибка. В чем дело? Сервис-воркеры так устроены, что стоит их один раз зарегистрировать, они остаются зарегистрированными навсегда. Это означает, что если идет процесс установки, они не будут устанавливаться заново. Чтобы это произошло, нужно изменить файл сервис-воркера или отменить его регистрацию. Перед установкой убедитесь, что все имеющиеся сервис-воркеры не зарегистрированы, и тогда проблем не будет.

Так что давайте начнем с того, что напишем код, которые перед запуском теста проверяет, что все сервис-воркеры не зарегистрированы. Внутри ‘describe’ мы можем указать функцию Mocha beforeEach(), чтобы отменить регистрацию для всех сервис-воркеров перед запуском каждого теста.

describe('Service Worker Suite', function() {

  beforeEach(function() {
    return navigator.serviceWorker.getRegistrations()
    .then((registrations) => {
      const unregisterPromises = registrations.map((registration) => {
        return registration.unregister();
      });
      return Promise.all(unregisterPromises);
    });
  });

  …
});

Этот код будет выполняться перед началом каждого теста. Его задача — отменить регистрацию по всем сервис-воркерам. Таким образом для каждого нового теста состояние сервис-воркеров обновляется.

Перезагрузите страницу, и тест снова будет успешным. Напоследок нужно проверить, что на моменте установки успешно кэшируются необходимые запросы. Для этого мы должны заставить сервис-воркер что-нибудь закешировать во время установки.

Давайте добавим какие-нибудь выдуманные данные в кэш файла my-first-sw.js.

self.addEventListener('install', (event) => {
  const promiseChain = caches.open('test-cache')
  .then((openCache) => {
    return openCache.put(
      new Request('/__test/example'),
      new Response('Hello, World!')
    );
  });
  event.waitUntil(promiseChain);
});

Теперь проверим, что все работает, как и было задумано:

it('should register a service worker and cache file on install', function() {
  // Mocha can handle promises, so as long as the promise doesn’t reject
  // this test will pass.
  return navigator.serviceWorker.register('/test/static/my-first-sw.js')
  .then((reg) => {
    return window.__waitForSWState(reg, 'installed');
  })
  .then(() => {
    return caches.match('/__test/example')
    .then((response) => {
      if (!response) {
        throw new Error('Eek, no response was found in the cache.');
      }

      return response.text();
    })
    .then((responseText) => {
      if (responseText !== 'Hello, World!') {
        throw new Error(`The response text was wrong!: '${responseText}'`);
      }
    });
  });
});

Вот и все, теперь можно проверить, что сервис-воркер зарегистрирован, установлен и кэширует нужный запрос!

Единственная проблема тут в том, что между тестами кэш остается заполненным, а это может привести к тому, что тесты, которые по идее должны провалиться, окажутся удачными. Поэтому отменять регистрацию сервис-воркеров мы будем с помощью вспомогательного метода, а также добавим очистку кэша.

window.__testCleanup = () => {
  const unregisterSW = () => {
    return navigator.serviceWorker.getRegistrations()
    .then((registrations) => {
      const unregisterPromise = registrations.map((registration) => {
        return registration.unregister();
      });
      return Promise.all(unregisterPromise);
    });
  };

  const clearCaches = () => {
    return window.caches.keys()
    .then((cacheNames) => {
      return Promise.all(cacheNames.map((cacheName) => {
        return window.caches.delete(cacheName);
      }));
    });
  };

  return Promise.all([
    unregisterSW(),
    clearCaches(),
  ]);
};

Сохраняем это как /test/utils/sw-test-cleanup.js и добавляем в файл /test/browser/index.html.

<script src="/test/utils/sw-test-cleanup.js"></script>

Теперь добавляем вызов этого метода до и после каждого теста.

beforeEach(function() {
  return window.__testCleanup();
});

after(function() {
  return window.__testCleanup();
});

Благодаря этим мерам мы всегда будем проверять сервис-воркеры в обновленном состоянии, и тесты будут давать надежные результаты.

Такой подход к тестированию хорош, потому что он похож на то, как сервис-воркеры используются в веб-приложениях. Вы регистрируете сервис воркер на веб-странице, он выполняет какие-то полезные действия (в данном случае кэширует запросы во время установки), и дальше браузер пользуется им при необходимости.

Переходим к юнит-тестам в среде сервис-воркеров. Они пригодятся для проверки логики сервис-воркера или тестирования тех API, которые доступны только в среде сервис-воркера.

Юнит-тесты в сервис-воркере

API Mocha хорошо подходит для обычных web-воркеров JavaScript, поэтому сделать так, чтобы он работал в сервис-воркере, тоже несложно.

Проводить юнит-тесты в самом сервис-воркере полезно, потому что так тесты получают доступ к API, которые есть только в среде сервис-воркера.

Чтобы провести тесты в сервис-воркере, делаем следующее:

  1. На созданной веб-странице с тестами Mocha добавляем один юнит-тест, который регистрирует сервис-воркер, содержащий юнит-тесты.
  2. Отправляем сообщение этому сервис-воркеру, чтобы он начал тестирование в сервис-воркере.
  3. Ждем ответа сервис-воркера с результатами тестирования.
  4. В окне появится сообщение, что тест или пройден, или провалился.

К файлу test/browser/index.html добавляем новый тег скрипта для /test/browser/run-sw-tests.js:

<script src="/test/browser/run-sw-tests.js"></script>

В новом файле создадим тест, который будет отвечать за запуск тестов в сервис-воркере.

Шаг 1 – регистрируем файл сервис-воркера, в котором содержатся юнит-тесты. Создаем пустой файл сервис-воркера /test/sw/sw-unit-tests.js и далее с помощью теста в браузере регистрируем новый сервис-воркер, не забывая при этом чистить кэш между тестами.

describe('Run SW Unit Tests', function() {
  beforeEach(function() {
    return window.__testCleanup();
  });

  after(function() {
    return window.__testCleanup();
  });

  it('should run sw-unit-tests.js unit tests', function() {
    return navigator.serviceWorker.register('/test/sw/sw-unit-tests.js')
    .then((reg) => {
      return window.__waitForSWState(reg, 'activated');
    });
  });
});

После того, как сервис-воркер зарегистрирован, следующий логичный шаг – добавить тест в /test/sw/sw-unit-tests.js.

Чтобы использовать Mocha в среде сервис-воркера, импортируйте mocha.js с помощью функции importScripts(). Так вы сможете настроить Mocha по тому же принципу, как мы делали для файла index.html. Единственное отличие – для параметра reporter нужно установить значение null (иначе Mocha будет писать в DOM-элемент).

importScripts('/node_modules/mocha/mocha.js');

mocha.setup({
  ui: 'bdd',
  reporter: null,
});

describe('First SW Test Suite', function() {
  it('should test something', function() {
    ...
  });
});

const runResults = mocha.run();
runResults.on('end', () => {
  console.log(`${runResults.failures} out of ${runResults.total} failures.`);
});

Если вы запустите этот код, вы увидите лог-сообщение с результатами (а также логи Mocha). Но эти результаты не отправляются в браузер, поэтому если тест в сервис-воркере не будет пройден, тест в браузере все равно окажется успешным. То есть точных данных о том, провалились или удались наши тесты, у нас не будет.

Чтобы учитывать ошибки в тестах сервис-воркера, необходимо изменить тесты в браузере так, чтобы тест в браузере отправлял команду сервис-воркеру начинать свои тесты и ждал ответа от сервис-воркера с результатами.

Для этого в тестах браузера мы добавляем функцию, которая отправляет сообщение сервис-воркеру и ждет ответа:

const sendMessage = (serviceWorker, message) => {
  return new Promise(function(resolve, reject) {
    const messageChannel = new MessageChannel();
    messageChannel.port1.onmessage = function(event) {
      if (event.data.error) {
        reject(event.data.error);
      } else {
        resolve(event.data);
      }
    };

    serviceWorker.postMessage(message, [messageChannel.port2]);
  });
};

В нашем примере тест отправляет сообщение после того, как зарегистрировал /test/sw/sw-unit-tests.js, и генерирует ошибку, если какой-либо тест сервис-воркера провалился.

it('should run sw-unit-tests.js unit tests', function() {
  return navigator.serviceWorker.register('/test/sw/sw-unit-tests.js')
  .then((reg) => {
    return window.__waitForSWState(reg, 'activated');
  })
  .then((serviceWorker) => {
    return sendMessage(serviceWorker, 'run-tests');
  })
  .then((results) => {
    if (results.failures > 0) {
      const pluralFailures = results.failures > 1 ? 's' : '';
      throw new Error(`${results.failures} failing test${pluralFailures}.`);
    }
  });
});

Финальный шаг – изменить код так, чтобы сервис-воркер ждал сообщение, прежде чем начать тест, и затем возвращал результаты.

self.addEventListener('message', (event) => {
  if (event.data !== 'run-tests') {
    return;
  }

  const runResults = mocha.run();
  runResults.on('end', () => {
    event.ports[0].postMessage({
      failures: runResults.failures,
      total: runResults.total,
    });
  });
});

Теперь если тест провалится в сервис-воркере, провалится и в браузере.



Итак, подведем промежуточный результат:

  • У нас есть юнит-тесты, которые работают в контексте браузера
  • У нас есть юнит-тесты, которые в качестве побочного действия регистрируют и тестируют некоторые аспекты поведения сервис-воркеров
  • И у нас есть есть тесты, выполняющиеся непосредственно в контексте сервис-воркера

Теперь вы можете тестировать код как в окне браузера, так и в сервис-воркере.

Осталось рассмотреть неочевидный момент, который может очень пригодиться для юнит-тестов в сервис-воркере – возможность имитировать события.

Имитация событий в сервис-воркере

Вы можете вызвать фальшивые события внутри сервис-воркера. Просто создайте новое событие и вызовите его. Для этого вам нужно использовать правильный конструктор для события, которое вы хотите вызвать. Для события push это делается так:

const fakePushEvent = new PushEvent('push', {
  data: JSON.stringify(pushData),
});

Теперь вы можете вызвать событие в сервис-воркере:

self.dispatchEvent(fakePushEvent);

Для облегчения тестирования мы можем переопределить метод waitUntil() для события. Так мы можем обнаружить, когда выполниться возвращенный промис, прежде чем проверять результат события.

it('should be able to test a push event', function() {
  const pushData = {
    title: 'Example Title.',
    body: 'Example Body.',
  };

  return new Promise((resolve, reject) => {
    const fakePushEvent = new PushEvent('push', {
      data: JSON.stringify(pushData),
    });

    // Override waitUntil so we can detect when the notification
    // has been show by the push event.
    fakePushEvent.waitUntil = (promise) => {
      promise.then(resolve, reject);
    };

    self.dispatchEvent(fakePushEvent);
  })
  .then(() => {
    // TODO: Test what the push event had done
  })
});

Резюмируем: тест, который проверяет, было ли показано то или иное уведомление, должен выглядеть примерно так:

// This could be in our test directly or imported using importScripts();
self.addEventListener('push', (event) => {
  const data = event.data.json();
  const promiseChain = self.registration.showNotification(data.title, data);
  event.waitUntil(promiseChain);
});


describe('First SW Test Suite', function() {
  it('should be able to test a push event', function() {
    const pushData = {
      title: 'Example Title.',
      body: 'Example Body.',
    };

    return new Promise((resolve, reject) => {
      const fakePushEvent = new PushEvent('push', {
        data: JSON.stringify(pushData),
      });

      // Override waitUntil so we can detect when the notification
      // has been show by the push event.
      fakePushEvent.waitUntil = (promise) => {
        promise.then(resolve, reject);
      };

      self.dispatchEvent(fakePushEvent);
    })
    .then(() => {
      return self.registration.getNotifications();
    })
    .then((notifications) => {
      if (notifications.length !== 1) {
        throw new Error('Unexpected number of notifications shown.');
      }

      if (notifications[0].title !== pushData.title) {
        throw new Error('Unexpected notification title.');
      }

      if (notifications[0].body !== pushData.body) {
        throw new Error('Unexpected notification body.');
      }
    });
  });
});

Надеюсь, на этом элементарном примере удалось показать, как можно организовать более сложные сценарии. Например, как обнаружить непредвиденные или плохие данные в push-сообщениях, или как проверить определённое поведение push-уведомлений (например, несколько сообщений сворачиваются в одно).

Мы можем провести аналогичные тесты для событий типа fetch, notificationclick и notificationclose.

Событие Fetch

const event = new FetchEvent('fetch', {
  request: new Request('/index.html'),
});

Главное отличие здесь в том, что в зависимости от того, что вы пытаетесь проверить, вы можете переопределить метод event.waitUntil методом event.respondWidth. Это позволит вам протестировать ответ, который будет возвращен браузеру.

return new Promise((resolve, reject) => {
  const event = new FetchEvent('fetch', {
    request: new Request('/index.html'),
  });
  event.respondWith = (promiseChain) => {
    if (promiseChain) {
      // Check if promise was returned - otherise
      // it could be a response
      if (promiseChain instanceof Promise) {
        promiseChain.then(resolve, reject);
      } else {
        resolve(promiseChain);
      }
      return;
    }

    resolve();
  };
  self.dispatchEvent(event);
})

Событие NotificationClick

const event = new NotificationEvent('notificationclick', {
  notification: notifications[0],
});

Чаще всего событие notificationclick тестируют, чтобы проверить уведомление, по которому выполнен клик, было закрыто, а также чтобы убедиться, что выполнены определенные действия (например, отслеживание события с помощью аналитики или открытие окна).

Событие NotificationClose

const event = new NotificationEvent('notificationclick', {
  notification: notifications[0],
});

Примечание о структуре тестирования

Мы выяснили, что с помощью имитации событий можно создавать тесты, которые будут проверять действия браузера в сервис-воркере. Но нужно отдельно отметить ещё несколько моментов.

Если вы решите использовать этот путь тестирования, вам стоит поместить обработчики событий в отдельный javascript-файл и добавить его в тестовый файл с помощью importScripts(). Так вы сможете использовать один и тот же код и для «боевого» сервис-воркера, и для тестов.

Например, модифицировав тест push-уведомлений из примера выше, мы можем создать файл /test/static/example-push-listener.js и импортировать его в тесты в начале файла с помощью importScripts(‘/test/static/example-push-listener.js’);.

Польза тут в том, что тот же самый файл можно импортировать и использовать на сайте.

Альтернативный подход – отказаться от вызова событий, а вместо этого перенести код в отдельную функцию или класс и напрямую протестировать этот метод. Это устраняет необходимость создавать фальшивые события. Единственный риск – можно неправильно связать события с этой логикой (что кажется незначительной проблемой)

Для примера, создадим push-событие:

self.addEventListener('push', (event) => {
  const data = event.data.json();
  const promiseChain = self.registration.showNotification(data.title, {
    body: data.body,
  });
  event.waitUntil(promiseChain);
});

Можно переписать код в виде функции, для которой событие станет аргументом:

self._handlePushEvent = (event) => {
  const data = event.data.json();
  const promiseChain = self.registration.showNotification(data.title, {
    body: data.body,
  });
  event.waitUntil(promiseChain);
};

В этом случае тесты вызовут self._handlePushEvent() напрямую вместо того, чтобы вызвать фальшивое push-событие.

Код обработчика событий для «боевого» сервис-воркера мы напишем так:

importScripts(‘/<Path to library file>.js’);

self.addEventListener('push', (event) => {
  self._handlePushEvent(event);
});

Теперь должно быть понятно, как создавать логику и код, которые могут использовать сервис-воркеры, и не беспокоиться о том, когда и как это происходит (то есть мы исключаем зависимость от событий).

Примечание: когда используете importScripts() для «боевого» сервис-воркера, убедитесь, что у импортируемых файлов изменяется имя. Это нужно, чтобы в случае изменения содержимого происходило обновление сервис-воркера (то есть пользователи всегда будут видеть новейшие скрипты сервис-воркера и импортированные скрипты). Вместо importScripts(‘/scripts/fetch-manager.js’) воспользуйтесь инструментами или логикой сервиса, чтобы добавить номер версии, например, importScripts(‘/scripts/fetch-manager.1234.js’).

Не думаю, что есть какое-то единственно верное правило, которое дает понять, какой юнит-тест проводить: в браузере или в среде сервис-воркера. Для меня основная цель – написать эффективный и полезный тест, а где он будет находиться – это уже вопрос личных предпочтений.

Настоящие события сервис-воркера

Тем, кто использует сервис-воркеры для кэширования и обработки fetch-событий, фальшивые события могут показаться не соответствующей реальности и неестественной имитацией того, как на браузер на самом деле использует сервис-воркер.

Для комплексной проверки настоящих событий типа fetch есть специальные методы. Порядок действий зависит от того, какое именно событие вы хотите протестировать.

Настоящие события Fetch

Сначала давайте рассмотрим, как тестировать fetch-события, когда браузер использует сервис-воркер в качестве proxy для запросов.

Основная проблема такого типа тестирования состоит в том, что для него нужно, чтобы сервис-воркер контролировал веб-страницу. Это значит, что все запросы будут идти через сервис-воркер. Нам не нужно, чтобы тестовая страница браузера пропускала все запросы через сервис-воркер. Это вызовет путаницу, так как тесты начнут вести себя по-другому, если сервис-воркер будет перехватывать запросы.

По моему мнению, самый лучший подход к тестированию fetch-событий с помощью тестовой среды типа Mocha состоит в том, чтобы воспользоваться возможностью обозначить область видимости сервис-воркера. Для каждого сервис-воркера это будет уникальная страница. Так все тесты будут независимыми, и мы получим достоверные данные о том, провалился или удался тест.

Если вы пока не знаете, что такое область видимости сервис-воркера, вот краткий обзор основных фактов.

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

Например, мы регистрирует сервис-воркер с помощью такого кода:

navigator.serviceWorker.register('/sw.js', {scope: '/blog/'});

Из кода следует, что сервис-воркер может контролировать только те страницы сайта, URL которых начинается на /blog/. Если не указывать параметр scope, то областью видимости будет считаться /, потому что файл сервис-воркера размещён в корне веб-сервера. В результате сервис-воркер будет контролировать любую страницу, начинающуюся на /, то есть все страницы сайта.

Важно отметить, что область видимости может быть шире, чем размещение файла сервис-воркера, но она не может быть короче или полностью отличаться.

navigator.serviceWorker.register('/sw.js', {scope: '/blog/'});

Однако это ограничение можно обойти, если сервер, обслуживающий сервис-воркер, отправляет заголовок Service-Worker-Allowed. В значении заголовка можно указать минимальную область видимости.

// OK when ‘Service-Worker-Allowed’ header is set to ‘/’
navigator.serviceWorker.register(‘/blog/sw.js’, {scope: ‘/’});

Настоящие события Fetch + сервис-воркеры зомби

Какое отношение область видимости имеет к тестированию fetch-событий?

Оказывается, когда мы регистрируем, снимаем с регистрации и заново регистрируем сервис-воркер, мы создаем то, что я нежно называю «сервис-воркер зомби». По сути зомби появляются, когда браузер отменяет регистрацию сервис-воркера (то есть отмечает, что его необходимо отключить и удалить), но потом возвращает к жизни этот же сервис-воркер, когда снова регистрирует его. В этой ситуации сервис-воркер не пройдет через обычный жизненный цикл из установки, ожидания и активации. Вместо этого он начинает вести себя странно.

С помощью области видимости мы можем вновь зарегистрировать сервис-воркер и присвоить ему уникальную область видимости. Это дает нам возможность снимать регистрации и заново регистрировать сервис-воркер столько раз, сколько требуется, и не создавать зомби (т.е. уникальная область видимости = уникальный сервис-воркер).

Зная, в чём суть области видимости, в ходе одного юнит-теста мы можем сделать следующее:

  1. Регистрируем сервис-воркер с уникальной областью видимости. В своих недавних проекта я использовал /test/iframe/, но на самом деле область видимости может быть какой угодно, лишь бы была уникальной.
    Добавляем iframe на страницу.
    Устанавливаем атрибут src на область видимости с шага 1 (то есть iframe.src = ‘/test/iframe/’).
    В юнит-тесте делаем ссылку на iframe и используем iframeElement.contentWindow.fetch(). В результате сетевые запросы пойдут через страницу iframe, которую контролирует сервис-воркер, а значит запрос пойдет через сервис-воркер.
    После каждого теста удаляем все созданные iframe и снимаем с регистрации сервис-воркеры.


    Это, конечно, не самое элегантное решение, но у него есть свой плюс. Такой метод ограничивает/сдерживает поведение каждого теста.

    Теперь рассмотрим в деталях, как это реализовать. Мне нужно было сделать так, чтобы тестовый сервер отвечал на /test/iframe/, а это легко осуществить с помощью express.js:

    app.get('/test/iframe/:random', function(req, res) {
      res.sendFile(path.join(__dirname, 'test-iframe.html'));
    });

    Чтобы тестовый сервер начал отдавать заголовок Service-Worker-Allowed, можно добавить middleware, которое будет присоединять этот заголовок к каждому запросу. Так мы можем помещать сервис-воркеры где угодно и определять их область видимости с помощью любого пути.

    app.use('/', express.static(rootDirectory, {
      setHeaders: (res) => {
        res.setHeader('Service-Worker-Allowed', '/');
      },
    }));

    Я не уверен, стоит ли рекомендовать этот метод для тестирования сервис-воркеров. Было время, я заморачивался и все это проделывал из-за различий в реализациях браузеров. Возможность протестировать весь процесс была полезной, ведь так можно было обнаружить все странные моменты и аспекты поведения. По мере того, как сервис-воркеры становятся надежнее и все более широко поддерживаются, надобность в таком тестировании отмирает. Будем надеяться, что скоро можно будет спокойно пользоваться другими вариантами.

    Настоящие события Push


    Мы разобрали, как тестировать fetch-события с помощью ухищрений с областью видимости и iframe. Чтобы полностью протестировать push-уведомления, область видимости не понадобится. Тут другая задача: автоматизировать выдачу разрешений на показ уведомлений.

    Это можно сделать в Chrome и Firefox с помощью веб-драйвера, настроив профиль браузера на выдачу разрешения (для тестирования или для определенного URL-адреса).

    В Firefox разрешить получение уведомлений для тестов можно вот так:

    app.use('/', express.static(rootDirectory, {
      setHeaders: (res) => {
        res.setHeader('Service-Worker-Allowed', '/');
      },
    }));

    В Chrome мы можем настроить профиль для тестового браузера, который включает уведомления для локального тестового сервера-источника (то же самое работает и в Opera):

    const webdriverChrome = require('selenium-webdriver/chrome');
    
    const notificationPermission = {};
        notificationPermission[testServerAddress + ',*'] = {
          setting: 1,
        };
        const chromePreferences = {
          profile: {
            content_settings: {
              exceptions: {
                notifications: notificationPermission,
              },
            },
          },
        };
    
        // Write to a file
        const tempPreferenceDir = path.join(__dirname, 'tmp', 'chrome-prefs');
        mkdirp.sync(tempPreferenceDir + '/Default');
        const preferenceFilePath = path.join(tempPreferenceDir, 'Preferences');
        fs.writeFileSync(
          preferenceFilePath,
          JSON.stringify(chromePreferences));
    
    
    const options = new webdriverChrome.Options();
    options.addArguments('user-data-dir=' + tempPreferenceDir);
    
    const builder = new webdriver
      .Builder()
      .forBrowser(‘chrome’)
      .setChromeOptions(options);

    Теперь можно создавать тесты, которые подписываются на push-уведомления и используют PushSubscription для вызова push-сообщений.

    С помощью сервиса для тестирования push-событий я проверяю, получено ли push-сообщение, ожидая сообщение от сервис-воркера. На странице браузера (или в юнит-тесте) я устанавливаю обработчик событий для сообщений:

    navigator.serviceWorker.addEventListener('message', function(event) {
      // Service worker received a push message
      // TODO: Perform assertions / check test passed.
    });

    Теперь, когда сервис-воркер получит push-сообщение, он даст об этом знать.

    self.addEventListener('push', function(event) {
      let pushData = null;
      if (event.data) {
        pushData = event.data.text();
      }
    
      // Send message to page
      const promiseChain = self.clients.matchAll({
        includeUncontrolled: true
      })
      .then(function(clients) {
        const sendMsgPromises = clients.map(function(client) {
          return client.postMessage(pushData);
        });
        return Promise.all(sendMsgPromises);
      });
      event.waitUntil(promiseChain);
    });

    Если и другие способы проверить, что все работает. Все зависит от того, что конкретно вы тестируете, и как вам проще действовать. Например, вы можете подождать, пока getNotifications() вернется с уведомлением о том, что было получено push-сообщение и в результате было показано уведомление.

    Имитация сервис-воркеров

    Напоследок обсудим, пожалуй, самый простой вариант тестирования, когда вы создаёте mock-объект API сервис-воркера.

    Вообще желательно писать максимально быстрые модульные тесты. Это станет стимулом регулярно проводить их на стадии разработки. Создание mock-объектов среды сервис-воркеров – это самый быстрый путь. Никакой траты времени на запуск браузера, никаких веб-запросов. По сути это самый незатейливый способ протестировать код наиболее предсказуемым образом.

    У тестирования mock-объекта есть единственный недостаток. Если API mock-объекта неправильный, это обнаружится только при ручном или интеграционном тестировании (впрочем, эту ошибку легко исправить). Кроме того, при таком тестировании предполагается, что все браузеры ведут себя одинаково.

    После того, как вышла эта статья, Зак Арджайл опубликовал mock-объекты API сервис-воркеров, которые он использует для тестов. Также вы можете написать свои mock-объекты, с помощью которых можно инспектировать код.

    Один из способов тестирования кода, которым пользуюсь я – создаю переменную «self» между юнит-тестам, которая имитирует конкретный API. Это очень простой и понятный подход к написанию кода тестов. Представим, что у меня есть библиотека, созданная для работы в сервис-воркерах. Вызвав метод setUpEventListeners(), я регистрирую обработчики событий установки, активации и fetch-событий. Юнит-тест будет выглядеть так:

    it('should register install, activate and fetch event listeners', function() {
      const eventsListenedTo = [];
      global.self = {
        // Mock methods here
        addEventListener: (eventName, cb) => {
          eventsListenedTo.push(eventName);
        },
      };
      const myServiceWorkerLib = new Lib();
      myServiceWorkerLib.setUpEventListeners();
      eventsListenedTo.should.deep.equal(['install', 'activate', 'fetch']);
    });

    У этого подхода есть много плюсов и минусов. Если коротко: он невероятно быстрый, но игнорирует реальное поведение браузера.

    Заключительные замечания

    Основные проблемы, с которыми вы поначалу столкнетесь при тестировании, касаются настройки тестовой среды. Надеюсь, что со временем эти трудности постепенно перестанут быть актуальными благодаря использованию макетов и развитию тестовых программ, которые будут поддерживать юнит-тесты в различных средах без необходимости ручной настройки инструментов.

    У большинства популярных платформ тестирования нет плагинов, которые поддерживают юнит-тесты сервис-воркеров. Именно поэтому все приходится делать руками, как описано в этой статье. Тем не менее, некоторые полезные инструменты уже есть. Например, инструмент Karma помогает проводить тесты в контексте сервис-воркеров, используя тестовые платформы Mocha и Jasmine.

    Надеюсь, эта статья поможет вам найти собственный подход к тестированию и понять, что будет работать для вас лучше всего. Весь код из этой статьи есть на GitHub в примерах веб-тестов. Там также есть самые базовые примеры, как запускать все из командной строки и как тестировать самую простую страницу.

    Автор: Matt Gaunt

Нет комментариев