4. Des tests

Adrien Foucart

Retour à l’index

Précédent: 3. Des modules

Comment fait-on des tests unitaires avec Typescript et Node.js?

L’option la plus courante semble être d’utiliser Jest, qui se présente comme “a delightful JavaScript Testing Framework with a focus on simplicity”, ce qui me semble tout à fait correspondre à ce que je recherche.

Jest

Première étape: installation, avec npm install --save-dev jest. Ceci me crée un fichier package.json et un fichier package-lock.json ainsi qu’un dossier node_modules. Avant de continuer, regardons ce que c’est que tout ça.

package.json est visiblement le point central de configuration d’un projet Node. On peut y mettre pleins d’informations dont je me préoccuperai plus tard: pour l’instant, je me contenterai des devDependencies, qui permettent de spécifier les dépendances qui ne sont nécessaires qu’au développement et pas à l’exécution du module. Parfait.

package-lock.json est poétiquement décrit dans la documentation comme “a manifestation of the manifest”. Il est généré automatiquement, et contient si je comprend bien l’arbre de dépendances complet calculé lors de l’installation des packages.

Le dossier node_modules est l’endroit où le “node package manager” npm installe les packages “locaux” (c’est-à-dire utilisés uniquement dans le projet, et pas de manière globale sur l’ordinateur).

Bien, ceci étant compris, je continue. La documentation Jest m’indique ensuite que, pour l’utiliser avec Typescript, j’ai aussi besoin soit d’un truc qui s’appelle babel, soit ts-jest, parce que Jest tout seul ne fait que du Javascript.

Je n’ai pas trop envie de multiplier les dépendances à ce stade, revenons donc en arrière. L’autre option que j’ai vue recommandée par endroits est d’utiliser directement le Node Test Runner, l’outil “built-in” de tests unitaires de Node.

Node Test Runner

La documentation du test runner n’est pas excessivement claire. On va devoir la combiner à l’un ou l’autre exemples pour se faire une idée de comment ça marche.

Il n’y a pas de dépendances à ajouter, mais on peut tout de même jouer avec le package.json pour y créer un “script” qui va lancer les tests avec npm test, plutôt que de devoir spécifier le répertoire et le pattern de fichier de tests à chaque fois:

{
  "scripts": {
    "test":  "node --test tests/**/test_*.ts"
  }
}

Avec cette commande, je peux donc lancer les tests qui seront contenus dans les fichiers Typescript commençant par test_ et se trouvant dans le répertoire tests ou un de ses sous-dossiers. Ca fait beaucoup de t et de s dans cette commande, quand même.

Vérifions que ça marche avec le premier test d’exemple:

// tests/test_test.ts
import test from 'node:test';
import assert from 'node:assert/strict';

test('synchronous passing test', (t) => {
  assert.strictEqual(1, 1);
});

npm test nous donne bien un test passé.

Des vrais tests, maintenant

Premier objectif: test_drawables.ts. Je veux tester que mes Rectangle se comportent bien comme prévu. On va donc commencer par les importer:

// tests/test_drawables.ts

import {Rectangle} from '../src/drawables.ts'

Mais ça ne fonctionne pas:

SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript parameter property is not supported in strip-only mode

Ca valait bien la peine de partir de Jest: visiblement, le Node Test Runner n’aime pas trop le Typescript directement. Je vais tout de même rester dessus pour essayer d’utiliser les built-ins autant que possible, ce que je préfère toujours quand j’apprends un langage. Première solution: importer les fichiers Javascripts directement.

// tests/test_drawables.ts

import {Rectangle} from '../dist/drawables.js'

Là, pas de soucis – si ce n’est qu’il faut maintenant compiler avant de tester. On peut cependant rajouter ça dans le package.json:

{
  "scripts": {
    "test":  "tsc && node --test tests/**/test_*.ts"
  }
}

Difficulté suivante: je voudrais avoir un test qui vérifie que, quand j’appelle la méthode draw de Rectangle, je dessine bien quelque chose sur le <canvas>. Mais je n’ai pas a priori accès au DOM depuis cet environnement de test. De toute façon, le CanvasRenderingContext2D ne semble pas fournir grand-chose pour l’introspection de ce qui y a été dessiné. HTMLCanvasElement non plus.

Créons donc notre propre “mock-up” de CanvasRenderingContext2D pour tester qu’on fait bien tous les appels qu’il faut. On aura certainement besoin de tester d’autres choses que juste les méthodes que Rectangle appellent, je vais donc être relativement générique. L’idée est simplement de garder une trace de tous les appels de fonctions.

// tests/mock_context.ts
interface MethodCall {
    method: string;
    args: any[];
}

export class CanvasRenderingContext2D {
    strokeStyle: string = '';
    fillStyle: string = '';
    calls: MethodCall[] = [];
    nbPaths: number = 0;
    
    private registerCall(_method: string, _args: any[] = []): void {
        this.calls.push({method: _method, args: _args});
    }

    public isRendered(){
        return this.calls.some(e => e.method === "stroke") || 
               this.calls.some(e => e.method === "fill") || 
               this.calls.some(e => e.method === "fillRect") || 
               this.calls.some(e => e.method === 'strokeRect');
    }

    beginPath(): void {
        this.registerCall("beginPath");
        this.nbPaths++;
    }
    closePath(): void {
        this.registerCall("closePath");
    }
    moveTo(x: number, y: number): void {
        this.registerCall("moveTo", [x, y]);
    }
    lineTo(x: number, y: number): void {
        this.registerCall("lineTo", [x, y]);
    }
    stroke(): void {
        this.registerCall("stroke");
    }
    fill(): void {
        this.registerCall("fill");
    }
    rect(x: number, y: number, w: number, h: number): void {
        this.registerCall("rect", [x, y, w, h]);
    }
    arc(x: number, y: number, radius: number, startAngle: number, endAngle: number, counterclockwise?: boolean): void {
        this.registerCall("arc", [x, y, radius, startAngle, endAngle, counterclockwise]);
    }
    arcTo(x1: number, y1: number, x2: number, y2: number, radius: number): void {
        this.registerCall("arcTo", [x1, y1, x2, y2, radius])
    }
    ellipse(x: number, y: number, radiusX: number, radiusY: number, rotation: number, startAngle: number, endAngle: number, counterclockwise?: boolean) {
        this.registerCall("ellipse", [x, y, radiusX, radiusY, rotation, startAngle, endAngle, counterclockwise])
    }
    clearRect(x: number, y: number, w: number, h: number): void {
        this.registerCall("clearRect", [x, y, w, h]);
    }
    fillRect(x: number, y: number, w: number, h: number): void {
        this.registerCall("fillRect", [x, y, w, h]);
    }
    strokeRect(x: number, y: number, w: number, h: number): void {
        this.registerCall("strokeRect", [x, y, w, h]);
    }
    fillText(text: string, x: number, y: number, maxWidth?: number): void {
        this.registerCall("fillText", [text, x, y, maxWidth]);
    }
    strokeText(text: string, x: number, y: number, maxWidth?: number): void {
        this.registerCall("strokeText", [text, x, y, maxWidth]);
    }
    reset(): void {
        this.registerCall("reset");
        this.nbPaths = 0;
    }
};

Est-ce la façon la plus élégante de faire? Sans doute pas, mais ça ira pour le moment. Voyons enfin ce test. Je vais le découper en deux pour commencer: est-ce que j’ai bien rajouté un chemin (appel à beginPath, et donc nbPaths incrémenté dans le mock-up), et est-ce que je l’ai bien affiché (appel à stroke, fill, strokeRect ou fillRect).

// tests/test_drawables.ts
import test from 'node:test';
import assert from 'node:assert/strict';
import {Rectangle} from '../dist/drawables.js'
import { CanvasRenderingContext2D } from './mock_context.ts';

test('rectangle is added to context', () => {
    const rectangle = new Rectangle(0, 0, 0, 0);
    const ctx = new CanvasRenderingContext2D();
    rectangle.draw(ctx);

    assert.strictEqual(ctx.nbPaths, 1);
});

test('rectangle is rendered', () => {
    const rectangle = new Rectangle(0, 0, 0, 0);
    const ctx = new CanvasRenderingContext2D();
    rectangle.draw(ctx);

    assert(ctx.isRendered());
});

npm test: ça passe!

Rajoutons-en un peu: est-ce que la bonne couleur a été mise, et est-ce que l’on peut rajouter plusieurs rectangles?

// tests/test_drawable.js
// ...

test('rectangle colors are set', () => {
    const rectangle = new Rectangle(0, 0, 0, 0, 'red', 'white');
    const ctx = new CanvasRenderingContext2D();
    rectangle.draw(ctx);

    assert(ctx.strokeStyle === 'red');
    assert(ctx.fillStyle === 'white');
});

test('multiple rectangles', () => {
    const ctx = new CanvasRenderingContext2D();

    const rectangle = new Rectangle(0, 0, 0, 0);
    rectangle.draw(ctx);
    const rectangle2 = new Rectangle(0, 0, 0, 0);
    rectangle2.draw(ctx);

    assert.strictEqual(ctx.nbPaths, 2);
});

npm test: tout passe.

J’ai un Warning à chaque fois que je lance le test.

(node:18344) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///C:/Users/8Utilisateur/workspace/pongekke/tests/test_drawables.ts is not specified and it doesn't parse as CommonJS.
Reparsing as ES module because module syntax was detected. This incurs a performance overhead.
To eliminate this warning, add "type": "module" to C:\Users\8Utilisateur\workspace\pongekke\package.json.

La solution est dans le message: rajouter "type": "module" dans le package.json. Faisons cela: problème réglé.

On peut maintenant tester le Board. Cette fois-ci, je veux vérifier que je dessine bien tous les éléments qui lui sont envoyés.

// tests/test_board.js
import test from 'node:test';
import assert from 'node:assert/strict';
import { DrawingBoard } from '../dist/board.js';
import { CanvasRenderingContext2D } from './mock_context.ts';

class FakeDrawable {
    isDrawn: boolean = false;

    draw(ctx: CanvasRenderingContext2D): void{
        this.isDrawn = true;
    }
}

test('all drawables are drawn', () => {
    const ctx = new CanvasRenderingContext2D();
    const board = new DrawingBoard(ctx);
    const to_draw = [new FakeDrawable(), new FakeDrawable(), new FakeDrawable()];

    to_draw.forEach((e) => {board.add(e)});

    board.draw();

    to_draw.forEach((e) => {assert(e.isDrawn)});
});

Tout ce qui se trouve dans pongekke.ts, par contre, ne va pas vraiment être testable sans le DOM. Il faudra donc bien garder en tête d’avoir aussi peu de choses possible qui s’y passent… ou de pouvoir faire intervenir le DOM dans les tests !

Une balle?

On a des tests, continuons sur notre lancée en rajoutant maintenant une fonctionnalité, et les tests correspondant.

On va procéder en “test-driven”: on commence par une classe vide, et des tests:

// src/drawables.ts
export class Ball {
    constructor(private x: number,
                private y: number,
                private radius: number,
                private borderColor: string = "white",
                private fillColor: string = "black"){}
    
    public draw(ctx: CanvasRenderingContext2D): void {

    }
}

Les tests sont les mêmes que pour le Rectangle, pour l’instant.

// tests/test_drawables.ts
import { Rectangle, Ball } from '../dist/drawables.js';

// ...
test('ball is added to context', () => {
    const ctx = new CanvasRenderingContext2D();
    
    const ball = new Ball(0, 0, 0);
    ball.draw(ctx);

    assert.strictEqual(ctx.nbPaths, 1);
});

test('ball is rendered', () => {
    const ctx = new CanvasRenderingContext2D();
    
    const ball = new Ball(0, 0, 0);
    ball.draw(ctx);

    assert(ctx.isRendered());
});

test('ball colors are set', () => {
    const ball = new Ball(0, 0, 0, 'red', 'white');
    const ctx = new CanvasRenderingContext2D();
    ball.draw(ctx);

    assert(ctx.strokeStyle === 'red');
    assert(ctx.fillStyle === 'white');
});

test('multiple balls', () => {
    const ctx = new CanvasRenderingContext2D();

    const ball = new Ball(0, 0, 0);
    ball.draw(ctx);
    const ball2 = new Ball(0, 0, 0);
    ball2.draw(ctx);

    assert.strictEqual(ctx.nbPaths, 2);
});

npm test nous indique bien 4 tests échoués. Corrigeons ça:

// src/drawables.ts
class Ball {
    // ...
    public draw(ctx: CanvasRenderingContext2D): void {
        ctx.beginPath();
        ctx.arc(this.x, this.y, this.radius, 0, 2*Math.PI);
        ctx.strokeStyle = this.borderColor;
        ctx.fillStyle = this.fillColor;
        ctx.stroke();
        ctx.fill();
    }
}

Et les tests passent au vert. Ajoutons maintenant la balle dans le canvas.

// src/pongekke.ts

function main(){
    // ...
    board.add(new Ball(w/2, h/2, 5, 'blue', 'blue'));
    board.draw();
}

Voyons si ça marche:

Parfait.

Tester du Typescript

Le fait de devoir tester le Javascript compilé et pas les sources Typescript m’embête quand même. En cherchant un peu, je trouve la solution expliquée par Matthew Brown: on peut rajouter tsx dans nos dépendances (on y échappera pas), ainsi que l’option --import tsx dans la commande du script test dans package.json. Avec ça, on peut remplacer tous nos imports pour directement récupérer les fichiers .ts. Ouf! On peut au passage retirer le tsc:

{
  "scripts": {
    "test": "node --import tsx --test tests/**/test_*.ts"
  },
  "type": "module",
  "devDependencies": {
    "tsx": "^4.20.6"
  }
}

Maintenant qu’il peut vérifier les types, mon IDE n’est pas très heureux avec rectangle.draw(ctx): il se rend bien compte que mon CanvasRenderingContext2D n’est pas le même que celui attendu par draw. Je pourrais faire partir l’avertissement en faisant rectangle.draw(ctx as any as globalThis.CanvasRenderingContext2D), ce qui est un pattern particulièrement horrible je trouve. Mon CanvasRenderingContext2D et le vrai n’ont pas d’héritage entre eux, je ne peux pas faire de as directement, mais puisqu’ils héritent tous deux de any et qu’on peut faire le as dans les deux sens de l’héritage, ça fonctionne. Je crois que je préfère quand même garder la petite ligne rouge dans VSCode pour l’instant, elle me rappellera que je dois quand même regarder à un moment si je peux faire mieux, comme solution. Matthew Brown mentionne la possibilité d’utiliser --import global-jsdom/register dans le script de test (et d’installer les packages global-jsdom et jsdom). Je regarderai plus tard.

Pour vérifier que je n’ai rien cassé: je commente la ligne ctx.strokeStyle = this.borderColor; dans Ball.draw. Sans recompiler, le test rate bien.

Le code au 28/11/2025

À suivre…

Suite: 5. Un peu d’animation