Utilizzo del test runner di Node.js
Node.js dispone di un test runner integrato flessibile e robusto. Questa guida mostrerà come configurarlo e utilizzarlo.
example/
├ …
├ src/
├ app/…
└ sw/…
└ test/
├ globals/
├ …
├ IndexedDb.js
└ ServiceWorkerGlobalScope.js
├ setup.mjs
├ setup.units.mjs
└ setup.ui.mjs
npm init -y
npm install --save-dev concurrently
{
"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.*'"
}
}
NOTA
I glob richiedono node v21+ e i glob stessi devono essere racchiusi tra virgolette (senza, si otterrà un comportamento diverso da quello previsto, in cui potrebbe inizialmente sembrare funzionante ma non lo è).
Ci sono alcune cose che si desiderano sempre, quindi inserirle in un file di configurazione di base come il seguente. Questo file verrà importato da altre configurazioni più specifiche.
Configurazione generale
import { register } from 'node:module';
register('some-typescript-loader');
// TypeScript è supportato da ora in poi
// MA altri file test/setup.*.mjs devono comunque essere in semplice JavaScript!
Quindi, per ogni configurazione, creare un file di setup
dedicato (assicurandosi che il file di base setup.mjs
sia importato in ognuno). Esistono numerosi motivi per isolare le configurazioni, ma il motivo più ovvio è YAGNI + prestazioni: gran parte di ciò che si potrebbe configurare sono mock/stub specifici dell'ambiente, che possono essere piuttosto costosi e rallentare le esecuzioni dei test. Si desidera evitare questi costi (denaro letterale pagato a CI, tempo di attesa per il completamento dei test, ecc.) quando non sono necessari.
Ogni esempio di seguito è stato tratto da progetti reali; potrebbero non essere appropriati/applicabili al tuo, ma ognuno dimostra concetti generali ampiamente applicabili.
Test dei Service Worker
ServiceWorkerGlobalScope
contiene API molto specifiche che non esistono in altri ambienti, e alcune delle sue API sono apparentemente simili ad altre (es. fetch
), ma hanno un comportamento potenziato. Non si desidera che queste si riversino in test non correlati.
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);
});
});
Test degli snapshot
Questi sono stati resi popolari da Jest; ora, molte librerie implementano tale funzionalità, incluso Node.js dalla v22.3.0. Esistono diversi casi d'uso, come la verifica dell'output del rendering dei componenti e la configurazione Infrastructure as Code. Il concetto è lo stesso indipendentemente dal caso d'uso.
Non è richiesta alcuna configurazione specifica, eccetto l'abilitazione della funzionalità tramite --experimental-test-snapshots
. Ma per dimostrare la configurazione opzionale, probabilmente si aggiungerebbe qualcosa di simile a uno dei file di configurazione dei test esistenti.
Per impostazione predefinita, Node genera un nome file incompatibile con il rilevamento dell'evidenziazione della sintassi: .js.snapshot
. Il file generato è in realtà un file CJS, quindi un nome file più appropriato terminerebbe con .snapshot.cjs
(o più succintamente .snap.cjs
come di seguito); questo gestirà anche meglio i progetti 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`);
}
L'esempio seguente mostra il test degli snapshot con la testing library per i componenti UI; notare i due modi diversi di accedere a assert.snapshot
:
import { describe, it } from 'node:test';
import { prettyDOM } from '@testing-library/dom';
import { render } from '@testing-library/react'; // Qualsiasi framework (es. svelte)
import { SomeComponent } from './SomeComponent.jsx';
describe('<SomeComponent>', () => {
// Per chi preferisce la sintassi "fat-arrow", la seguente è probabilmente migliore per coerenza
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` funziona solo quando viene utilizzata la funzione `function` (non "fat arrow").
});
});
WARNING
assert.snapshot
proviene dal contesto del test (t o this), non da node:assert
. Questo è necessario perché il contesto del test ha accesso a un ambito impossibile per node:assert
(dovresti fornirlo manualmente ogni volta che viene utilizzato assert.snapshot
, come snapshot (this, value)
, il che sarebbe piuttosto noioso).
Test unitari
I test unitari sono i test più semplici e generalmente non richiedono nulla di speciale. La stragrande maggioranza dei tuoi test saranno probabilmente test unitari, quindi è importante mantenere questa configurazione minima perché una piccola diminuzione delle prestazioni di configurazione si magnificherà e si propagherà a cascata.
import { register } from 'node:module';
import './setup.mjs'; // 💡
register('some-plaintext-loader');
// i file plain-text come graphql possono ora essere importati:
// 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));
});
});
Test dell'interfaccia utente
I test dell'interfaccia utente generalmente richiedono un DOM e possibilmente altre API specifiche del browser (come IndexedDb
usato di seguito). Questi tendono ad essere molto complicati e costosi da configurare.
Se si utilizza un'API come IndexedDb
ma è molto isolata, una mock globale come quella di seguito potrebbe non essere la soluzione migliore. Invece, forse sposta questo beforeEach
nel test specifico in cui verrà accesso IndexedDb
. Si noti che se il modulo che accede a IndexedDb
(o qualsiasi altro) è esso stesso ampiamente accessibile, o si mocka quel modulo (probabilmente l'opzione migliore), o si mantiene questo qui.
import { register } from 'node:module';
// ⚠️ Assicurarsi che venga istanziata solo 1 istanza di JSDom; più istanze porteranno a molti 🤬
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', // ⚠️ La mancata specificazione di questo probabilmente porterà a molti 🤬
});
// Esempio di come decorare un globale.
// La `history` di JSDOM non gestisce la navigazione; quanto segue gestisce la maggior parte dei casi.
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();
}
È possibile avere 2 diversi livelli di test dell'interfaccia utente: uno simile a un'unità (in cui gli elementi esterni e le dipendenze sono mockati) e uno più end-to-end (in cui vengono mockati solo gli elementi esterni come IndexedDb, ma il resto della catena è reale). Il primo è generalmente l'opzione più pura, e il secondo è generalmente rinviato a un test di usabilità automatizzato completamente end-to-end tramite qualcosa come Playwright o Puppeteer. Di seguito è riportato un esempio del primo.
import { before, describe, mock, it } from 'node:test';
import { screen } from '@testing-library/dom';
import { render } from '@testing-library/react'; // Qualsiasi framework (es. svelte)
// ⚠️ Si noti che SomeOtherComponent NON è un'importazione statica;
// questo è necessario per facilitare il mocking delle proprie importazioni.
describe('<SomeOtherComponent>', () => {
let SomeOtherComponent;
let calcSomeValue;
before(async () => {
// ⚠️ L'ordine è importante: il mock deve essere impostato PRIMA che il suo consumer venga importato.
// Richiede che sia impostato `--experimental-test-module-mocks`.
calcSomeValue = mock.module('./calcSomeValue.js', { calcSomeValue: mock.fn() });
({ SomeOtherComponent } = await import('./SomeOtherComponent.jsx'));
});
describe('when calcSomeValue fails', () => {
// Questo non si vorrebbe gestire con uno snapshot perché sarebbe fragile:
// Quando vengono apportati aggiornamenti irrilevanti al messaggio di errore,
// il test dello snapshot fallirebbe erroneamente
// (e lo snapshot dovrebbe essere aggiornato senza un reale valore).
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);
});
});
});