Skip to content

Использование встроенного средства запуска тестов Node.js

Node.js имеет гибкий и надежный встроенный инструмент запуска тестов. Это руководство покажет вам, как его настроить и использовать.

bash
example/

 src/
 app/…
 sw/…
 test/
 globals/

 IndexedDb.js
 ServiceWorkerGlobalScope.js
 setup.mjs
 setup.units.mjs
 setup.ui.mjs
bash
npm init -y
npm install --save-dev concurrently
json
{
  "name": "example",
  "scripts": {
    "test": "concurrently --kill-others-on-fail --prefix none npm:test:*",
    "test:sw": "node --import ./test/setup.sw.mjs --test './src/sw/**/*.spec.*'",
    "test:units": "node --import ./test/setup.units.mjs --test './src/app/**/*.spec.*'",
    "test:ui": "node --import ./test/setup.ui.mjs --test './src/app/**/*.test.*'"
  }
}

ПРИМЕЧАНИЕ

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

Есть некоторые вещи, которые вам всегда нужны, поэтому поместите их в базовый файл настройки, как показано ниже. Этот файл будет импортироваться другими, более специализированными настройками.

Общая настройка

js
import { register } from 'node:module';
register('some-typescript-loader');
// TypeScript поддерживается далее
// НО другие файлы test/setup.*.mjs все равно должны быть на чистом JavaScript!

Затем для каждой настройки создайте специальный файл setup (убедившись, что базовый файл setup.mjs импортируется в каждый из них). Есть ряд причин для изоляции настроек, но самая очевидная причина — YAGNI + производительность: большая часть того, что вы можете настраивать, — это специфичные для среды имитации/заглушки, которые могут быть довольно дорогостоящими и замедлят выполнение тестов. Вы хотите избежать этих затрат (буквально денег, которые вы платите CI, времени ожидания завершения тестов и т. д.), когда они вам не нужны.

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

Тесты ServiceWorker

ServiceWorkerGlobalScope содержит очень специфичные API, которые не существуют в других средах, а некоторые из его API кажутся похожими на другие (например, fetch), но имеют расширенное поведение. Вы не хотите, чтобы они просачивались в несвязанные тесты.

js
import { beforeEach } from 'node:test';

import { ServiceWorkerGlobalScope } from './globals/ServiceWorkerGlobalScope.js';

import './setup.mjs'; // 💡

beforeEach(globalSWBeforeEach);
function globalSWBeforeEach() {
  globalThis.self = new ServiceWorkerGlobalScope();
}
js
import assert from 'node:assert/strict';
import { describe, mock, it } from 'node:test';

import { onActivate } from './onActivate.js';

describe('ServiceWorker::onActivate()', () => {
  const globalSelf = globalThis.self;
  const claim = mock.fn(async function mock__claim() {});
  const matchAll = mock.fn(async function mock__matchAll() {});

  class ActivateEvent extends Event {
    constructor(...args) {
      super('activate', ...args);
    }
  }

  before(() => {
    globalThis.self = {
      clients: { claim, matchAll },
    };
  });
  after(() => {
    global.self = globalSelf;
  });

  it('should claim all clients', async () => {
    await onActivate(new ActivateEvent());

    assert.equal(claim.mock.callCount(), 1);
    assert.equal(matchAll.mock.callCount(), 1);
  });
});

Снэпшот-тесты

Они были популяризированы Jest; теперь многие библиотеки реализуют такую функциональность, включая Node.js начиная с версии 22.3.0. Существует несколько вариантов использования, таких как проверка вывода рендеринга компонентов и конфигурации Инфраструктура как код. Концепция одинакова независимо от варианта использования.

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

По умолчанию node генерирует имя файла, несовместимое с обнаружением подсветки синтаксиса: .js.snapshot. Созданный файл на самом деле является файлом CJS, поэтому более подходящее имя файла заканчивалось бы на .snapshot.cjs (или более кратко .snap.cjs, как показано ниже); это также будет лучше работать в проектах ESM.

js
import { basename, dirname, extname, join } from 'node:path';
import { snapshot } from 'node:test';

snapshot.setResolveSnapshotPath(generateSnapshotPath);
/**
 * @param {string} testFilePath '/tmp/foo.test.js'
 * @returns {string} '/tmp/foo.test.snap.cjs'
 */
function generateSnapshotPath(testFilePath) {
  const ext = extname(testFilePath);
  const filename = basename(testFilePath, ext);
  const base = dirname(testFilePath);

  return join(base, `${filename}.snap.cjs`);
}

Пример ниже демонстрирует снэпшот-тестирование с помощью тестирующей библиотеки для UI-компонентов; обратите внимание на два разных способа доступа к assert.snapshot:

js
import { describe, it } from 'node:test';

import { prettyDOM } from '@testing-library/dom';
import { render } from '@testing-library/react'; // Любая структура (например, svelte)

import { SomeComponent } from './SomeComponent.jsx';


describe('<SomeComponent>', () => {
  // Для тех, кто предпочитает синтаксис "стрелочных функций", следующее, вероятно, лучше для согласованности
  it('should render defaults when no props are provided', (t) => {
    const component = render(<SomeComponent />).container.firstChild;

    t.assert.snapshot(prettyDOM(component));
  });

  it('should consume `foo` when provided', function() {
    const component = render(<SomeComponent foo="bar" />).container.firstChild;

    this.assert.snapshot(prettyDOM(component));
    // `this` работает только при использовании `function` (не "стрелочная функция").
  });
});

WARNING

assert.snapshot берется из контекста теста (t или this), а не из node:assert. Это необходимо, потому что контекст теста имеет доступ к области видимости, которая недоступна для node:assert (вам пришлось бы вручную предоставлять ее каждый раз при использовании assert.snapshot, например, snapshot (this, value), что было бы довольно утомительно).

Модульные тесты

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

js
import { register } from 'node:module';

import './setup.mjs'; // 💡

register('some-plaintext-loader');
// файлы plain-text, такие как graphql, теперь можно импортировать:
// import GET_ME from 'get-me.gql'; GET_ME = '
js
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';

import { Cat } from './Cat.js';
import { Fish } from './Fish.js';
import { Plastic } from './Plastic.js';

describe('Cat', () => {
  it('should eat fish', () => {
    const cat = new Cat();
    const fish = new Fish();

    assert.doesNotThrow(() => cat.eat(fish));
  });

  it('should NOT eat plastic', () => {
    const cat = new Cat();
    const plastic = new Plastic();

    assert.throws(() => cat.eat(plastic));
  });
});

Тесты пользовательского интерфейса

Тесты пользовательского интерфейса, как правило, требуют DOM и, возможно, других специфичных для браузера API (например, IndexedDb, используемый ниже). Их настройка, как правило, очень сложна и затратна.

Если вы используете такой API, как IndexedDb, но он очень изолирован, глобальный макет, как показано ниже, возможно, не лучший вариант. Вместо этого, возможно, переместите этот beforeEach в конкретный тест, где будет осуществляться доступ к IndexedDb. Обратите внимание, что если модуль, обращающийся к IndexedDb (или чему-либо еще), сам по себе широко используется, либо создайте макет этого модуля (вероятно, лучший вариант), либо оставьте это здесь.

js
import { register } from 'node:module';

// ⚠️ Убедитесь, что создан только 1 экземпляр JSDom; множественные экземпляры приведут к множеству 🤬
import jsdom from 'global-jsdom';

import './setup.units.mjs'; // 💡

import { IndexedDb } from './globals/IndexedDb.js';

register('some-css-modules-loader');

jsdom(undefined, {
  url: 'https://test.example.com', // ⚠️ Отсутствие этого параметра, вероятно, приведет к множеству 🤬
});

// Пример того, как декорировать глобальную переменную.
// `history` JSDOM не обрабатывает навигацию; следующее обрабатывает большинство случаев.
const pushState = globalThis.history.pushState.bind(globalThis.history);
globalThis.history.pushState = function mock_pushState(data, unused, url) {
  pushState(data, unused, url);
  globalThis.location.assign(url);
};

beforeEach(globalUIBeforeEach);
function globalUIBeforeEach() {
  globalThis.indexedDb = new IndexedDb();
}

Вы можете иметь 2 разных уровня тестов пользовательского интерфейса: похожий на модульный (где внешние компоненты и зависимости моделируются) и более сквозной (где моделируются только внешние компоненты, такие как IndexedDb, но остальная часть цепочки реальна). Первый, как правило, является более чистым вариантом, а второй, как правило, откладывается до полностью сквозного автоматизированного теста юзабилити с помощью чего-то вроде Playwright или Puppeteer. Ниже приведен пример первого.

js
import { before, describe, mock, it } from 'node:test';

import { screen } from '@testing-library/dom';
import { render } from '@testing-library/react'; // Любая платформа (например, svelte)

// ⚠️ Обратите внимание, что SomeOtherComponent НЕ является статическим импортом;
// это необходимо для того, чтобы можно было моделировать его собственные импорты.


describe('<SomeOtherComponent>', () => {
  let SomeOtherComponent;
  let calcSomeValue;

  before(async () => {
    // ⚠️ Порядок имеет значение: макет должен быть настроен ДО импорта его потребителя.

    // Требуется установить `--experimental-test-module-mocks`.
    calcSomeValue = mock.module('./calcSomeValue.js', { calcSomeValue: mock.fn() });

    ({ SomeOtherComponent } = await import('./SomeOtherComponent.jsx'));
  });

  describe('when calcSomeValue fails', () => {
    // Этого вы бы не стали обрабатывать с помощью снимка, потому что это было бы хрупким:
    // При несущественных обновлениях сообщения об ошибке
    // тест снимка ошибочно завершится неудачей
    // (и снимок придется обновить без какой-либо реальной пользы).

    it('should fail gracefully by displaying a pretty error', () => {
      calcSomeValue.mockImplementation(function mock__calcSomeValue() { return null });

      render(<SomeOtherComponent>);

      const errorMessage = screen.queryByText('unable');

      assert.ok(errorMessage);
    });
  });
});