На этой странице

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

example/
  ├ …
  ├ src/
    ├ app/…
    └ sw/…
  └ test/
    ├ globals/
      ├ …
      ├ IndexedDb.js
      └ ServiceWorkerGlobalScope.js
    ├ setup.mjs
    ├ setup.units.mjs
    └ setup.ui.mjs

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

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

`test/setup.mjs`
import { register } from 'node:module';

register('some-typescript-loader');
// TypeScript поддерживается далее
// НО остальные файлы test/setup.*.mjs всё равно должны быть на чистом JavaScript!

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

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

Иногда вам может понадобиться динамически генерировать тест-кейсы. Например, вы хотите проверить одно и то же в куче файлов. Это возможно, хотя и слегка «магически». Нужно использовать test (нельзя использовать describe) + testContext.test:

import assert from 'node:assert/strict';
import { test } from 'node:test';

import { detectOsInUserAgent } from '…';

const userAgents = [
  {
    ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.3',
    os: 'WIN',
  },
  // …
];

test('Detect OS via user-agent', { concurrency: true }, t => {
  for (const { os, ua } of userAgents) {
    t.test(ua, () => assert.equal(detectOsInUserAgent(ua), os));
  }
});
import assert from 'node:assert/strict';
import { test } from 'node:test';

import { getWorkspacePJSONs } from './getWorkspacePJSONs.mjs';

const requiredKeywords = ['node.js', 'sliced bread'];

test('Check package.jsons', { concurrency: true }, async t => {
  const pjsons = await getWorkspacePJSONs();

  for (const pjson of pjsons) {
    // ⚠️ `t.test`, НЕ `test`
    t.test(`Ensure fields are properly set: ${pjson.name}`, () => {
      assert.partialDeepStrictEqual(pjson.keywords, requiredKeywords);
    });
  }
});

Примечание: до версии 23.8.0 настройка заметно отличается, потому что testContext.test автоматически не ожидался (await).

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

`test/setup.sw.mjs`
import { beforeEach } from 'node:test';

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

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

beforeEach(globalSWBeforeEach);
function globalSWBeforeEach() {
  globalThis.self = new ServiceWorkerGlobalScope();
}
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 начиная с v22.3.0. Есть несколько сценариев использования, например проверка вывода рендеринга компонента и конфигурации Infrastructure as Code. Концепция одна и та же независимо от сценария.

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

`test/setup.ui.mjs`

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

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`);
}

Пример ниже демонстрирует snapshot-тестирование с testing library для UI-компонентов; обратите внимание на два разных способа доступа к assert.snapshot:

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` (а не «толстая стрелка»).
  });
});

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

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

`test/setup.units.mjs`
import { register } from 'node:module';

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

register('some-plaintext-loader');
// теперь можно импортировать текстовые файлы вроде graphql:
// import GET_ME from 'get-me.gql'; GET_ME = '
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));
  });
});

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

`test/setup.ui.mjs`

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

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 разных уровня UI-тестов: похожий на модульный (где внешние сущности и зависимости замоканы) и более end-to-end (где замоканы только внешние сущности вроде IndexedDb, а остальная цепочка настоящая). Первый обычно более «чистый» вариант, а второй обычно откладывается на полностью end-to-end автоматизированный тест юзабилити через что-то вроде Playwright или Puppeteer. Ниже пример первого.

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', () => {
    // Это не стоит обрабатывать снапшотом, потому что он был бы хрупким:
    // когда в сообщение об ошибке вносятся несущественные изменения,
    // snapshot-тест ошибочно упал бы
    // (и снапшот пришлось бы обновлять без реальной пользы).

    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);
    });
  });
});