Comment j’ai implémenté les mocks de date dans le cœur de Node.js

11 mai 2026

Comment j’ai implémenté les mocks de date dans le cœur de Node.js

Vous avez toujours voulu contribuer à un projet open source ? Alors laissez-moi vous expliquer comment j’ai ajouté mon code dans Node.js !

Il y a un peu plus d’un an, j’ai eu le grand plaisir de travailler sur le cœur de Node.js, et ce fut l’une des expériences les plus intéressantes que j’ai vécues (donc si vous utilisez Node aujourd’hui, il y a mon code là-dedans !), à tel point que je reviens maintenant travailler et aider la communauté à grandir autour de lui aussi. Cependant, ce que je veux vous raconter ici, c’est comment fonctionnent les mocks de Date dans le Node Test Runner, morceau par morceau !

L’objectif de cet article est autant de documenter ce qui a été fait dans cette fonctionnalité que de montrer qu’il n’est pas si complexe de comprendre le code open source que l’on trouve là-bas, et que vous aussi pouvez contribuer au projet que vous aimez le plus.

L’objectif

Tout cela est très séduisant, mais qu’entend-on exactement par mocks et pourquoi en aurait-on besoin ?

Je ne vais pas entrer en détail dans le concept de ce que sont les mocks ici, mais vous pouvez en apprendre davantage sur ce sujet dans cet article (ancien, mais pertinent)

J’aimerais pouvoir faire quelque chose comme ceci :

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

test('mocks Date.now to whatever value the user sets', (context) => {
  const now = Date.now()
  console.log(now) // date actuelle, le temps continue de s'écouler

  // on démarre les mocks
  context.mock.timers.enable({ apis: ['Date'] });

  // maintenant la date est figée à 1000ms après l'époque initiale
  context.mock.timers.setTime(1000)
  assert.strictEqual(Date.now(), 1000) // true
});

Pratiquement, les mocks de dates servent énormément à tester des fonctionnalités sensibles au temps, par exemple une routine ou un cronjob qui serait exécuté après X jours d’un événement. Cela était très courant chez Klarna (et c’est le cas dans la plupart des entreprises) lorsque nous dûmes gérer les cycles de vie des cartes de crédit, par exemple, chaque jour il fallait prendre les cartes qui arrivaient à échéance depuis 30 jours et lancer un certain processus. Comment tester cela ? En remplaçant l’horloge de l’ordinateur par la vôtre, afin de faire croire à Node qu’il se trouve à une date précise.

Grâce à la nature dynamique de JavaScript, ce n’est pas si complexe, mais j’ai découvert qu’il faut connaître en profondeur la spécification pour pouvoir comprendre les implications de ce que l’on peut faire.

Le début

Pour comprendre davantage comment fonctionnent les mocks, il faut revenir un peu sur quelques PRs précédentes. Le travail sur ma PR a commencé sur la suggestion d’un grand ami, Erick Wendel, qui avait effectué une PR il y a quelques mois en implémentant les mocks pour les timers (setTimeout, setInterval, etc.).

la PR originale d’Erick

Lorsque j’ai commencé à utiliser le test runner dans mes projets, j’ai tout de suite rencontré un énorme problème : même si l’on pouvait mocker les timers, je n’arrivais pas à faire de mocks de dates ! Autrement dit, je ne pouvais pas réinitialiser l’horloge de mes tests et contrôler son comportement à ma guise… Quelque chose devait être fait.

J’avais déjà évoqué cette idée auprès de l’équipe (cela aurait pu être tout simplement fait, mais j’ai préféré demander d’abord) et la plupart des gens ont aimé l’idée. Comme d’autres test runners (Jest, Ava, Vitest, mocha, jasmine…) avaient déjà cette fonctionnalité, il serait intéressant de l’avoir également dans le NTR, cela faciliterait l’adoption de la plateforme.

La planification

L’idée est que les mocks de dates se comportent assez comme l’implémentation de Sinon, qui est aussi celle utilisée dans Jest, ce qui signifie une API déjà familière pour la communauté.

J’ai commencé à rechercher quels seraient les principaux éléments et comment j’aurais pu intégrer cette nouvelle API dans l’API existante des mocks. J’en suis arrivé à la conclusion qu’il serait plus simple d’implémenter uniquement la méthode now de la date, qui est plus simple et pourrait être plus utile. Voilà la version initiale de ma PR :

Remarquez que je pensais peut-être qu’il serait préférable de mocker tout l’objet Date, et pas seulement now, ce qui est considérablement plus complexe que le seul module.

Je ne vais pas détailler pas à pas ce que j’ai fait ici, mais le contexte initial est important pour comprendre les décisions futures.

À la fin, après de nombreux commentaires, l’intégration avec l’API des timers créée précédemment par Erick a donné ceci :

// Tout ce que l’API avait avant
MockTimers.reset()
MockTimers.tick(100)
MockTimers.runAll()

// Implémentations qui ont été modifiées
MockTimers.enable({ timersToEnable: ['setInterval', 'setTimeout', 'Date' ], now: 1000 })

// Nouveaux méthodes
MockTimers.setTime(100)

J’allais conserver l’utilisation principale, puisqu’elle était déjà en production, mais modifier le paramètre de MockTimers.enable qui était autrefois un tableau de chaînes de caractères, pour en faire un objet, car désormais nous pouvions passer des configurations pour les dates.

De plus, MockTimers aurait une nouvelle méthode setTime, qui permettrait de changer la date dans le mock. Mais, comme c’est la coutume dans Node, les paramètres initiaux de la plupart des API étant optionnels, j’ai modifié l’idée pour qu’elle fonctionne aussi de cette manière :

MockTimers.enable({ now: 1000 }) // sans liste, nous mockons toutes les méthodes

// ou

MockTimers.enable() // commence avec l’époque 0

Avec l’API initiale décidée, la question principale est: Comment puis-je mocker l’une des principales API du langage sans tout casser ?

Le code

Pour étonnamment peu, toute l’ajout du mock de date dans Node a été réalisé dans un seul fichier appelé mock_timers.js dans lib/internal/test_runner/mock. C’est une pratique assez courante dans des projets plus anciens et établis, car elle permet à des PR d’être beaucoup plus petites, le fichier étant volumineux mais incluant toutes les modifications nécessaires.

Il y a eu un petit changement dans un autre fichier, mais j’en parlerai plus tard.

Quand j’ai commencé à coder cette fonctionnalité je me suis dit : « Mais comment diable créer un mock », en réalité c’est assez simple : un mock n’est autre qu’un objet ayant une interface identique à celle de l’objet original, mais avec un comportement différent. Alors, par exemple, si vous vouliez faire manuellement un mock de la méthode now de Date, il vous suffirait de faire quelque chose comme :

const original = Date.now
Date.now = () => 0

console.log(Date.now()) // 0
console.log(original()) // 1720812736744

Bien sûr qu’une méthode n’est pas un objet, alors comment faire ? D’abord, nous devons créer nos nouvelles propriétés, dans le cas présent la date initiale qui sera 0, qui est une propriété privée nommée #now :

//https://github.com/nodejs/node/blob/bb7fc653e9199c5b65a7ed268f9e827d049d7a81/lib/internal/test_runner/mock/mock_timers.js#L123

class MockTimers {
  // ... début du code ici
  #now = kInitialEpoch;
}

kInitialEpoch est une constante (d’où le préfixe k) définie à la ligne 50 comme 0.

Les constantes comme celle-ci sont très courantes dans le cœur du Node, surtout lorsqu’elles utilisent des Symboles, car nous devons garantir des propriétés internes non énumérables. Voyons cela plus tard.

En plus de cette propriété, comme nous l’avons fait dans notre mock manuel, nous devons sauvegarder la méthode originale à l’intérieur de MockTimers. Node le fait déjà pour plusieurs autres propriétés privées :

// https://github.com/nodejs/node/blob/bb7fc653e9199c5b65a7ed268f9e827d049d7a81/lib/internal/test_runner/mock/mock_timers.js#L99
class MockTimers {
  #realSetTimeout;
  #realClearTimeout;
  #realSetInterval;
  #realClearInterval;
  #realSetImmediate;
  #realClearImmediate;

  #realPromisifiedSetTimeout;
  #realPromisifiedSetInterval;

  #realTimersSetTimeout;
  #realTimersClearTimeout;
  #realTimersSetInterval;
  #realTimersClearInterval;
  #realTimersSetImmediate;
  #realTimersClearImmediate;
  #realPromisifiedSetImmediate;
}

Elles le sont ici parce que, lorsque nous appelons le reset, ces mocks doivent disparaître, c’est-à-dire que nous devons les replacer par les méthodes originales. Donc tout le MockTimers n’est autre qu’une classe qui remplace globalThis.<votre objet> par un mock identique et qui garde la valeur originale jusqu’à ce que vous repreniez. Maintenant les choses deviennent plus simples.

Ajoutons une autre propriété qui sera le descripteur de l’objet Date :

//https://github.com/nodejs/node/blob/bb7fc653e9199c5b65a7ed268f9e827d049d7a81/lib/internal/test_runner/mock/mock_timers.js#L118

class MockTimers {
  // ... début du code ici
  #nativeDateDescriptor // L118
  #now = kInitialEpoch; // L123
}
JavaScript
💡
Il est important de noter que Date n’est pas une fonction, donc on ne peut pas stocker seulement sa valeur ; comme il s’agit d’un objet, JavaScript va passer cette variable par référence, il faut donc stocker le descripteur obtenu avec Object.getOwnPropertyDescriptor

Quand j’ai commencé à observer comment les timeouts étaient implémentés, j’ai eu une idée. Aujourd’hui ils sont tous créés chacun par une fonction, comme ceci :

  #setTimeout = FunctionPrototypeBind(this.#createTimer, this, false);
  #clearTimeout = FunctionPrototypeBind(this.#clearTimer, this);
  #setInterval = FunctionPrototypeBind(this.#createTimer, this, true);
  #clearInterval = FunctionPrototypeBind(this.#clearTimer, this);
  #clearImmediate = FunctionPrototypeBind(this.#clearTimer, this);

Un point important est que Node ne peut pas utiliser les primordiaux (comme uneFuncao.bind(this)) directement, car cela est implémenté par le moteur. Donc il existe des fonctions internes qui vont directement à la racine d’où ces méthodes sont exécutées (là-bas dans V8) et font la même chose, mais avec un nom différent, ainsi le bind serait FunctionPrototypeBind, mais l’idée reste la même.

Donc je suivrais le même pattern, et c’est là que notre histoire commence.

C’est juste une fonction

Notre fonction qui crée un objet de date est relativement simple :

#createDate() { // L279
    kMock ??= Symbol('MockTimers');
    const NativeDateConstructor = this.#nativeDateDescriptor.value;
}
#createDate() { // L279
    kMock ??= Symbol('MockTimers');
    const NativeDateConstructor = this.#nativeDateDescriptor.value;
}

Nous créons deux objets initiaux : le premier sera une constante associée à un Symbol qui représentera l’objet des mocks dans son ensemble. Nous en aurons besoin plus tard, car nous devons retourner le timestamp actuel lorsque l’objet n’est pas utilisé comme constructeur (type Date(), vous vous souvenez ?), car nous devons accéder aux propriétés que l’utilisateur a définies, comme le kInitialEpoch. Cette propriété apparaît tout au début, mais elle ne sera utilisée qu’à la fin de notre fonction.

Le second est le constructeur natif de Date sans modifications, car nous allons devoir retourner certaines fonctions qui n’ont pas besoin des mocks, par exemple toString.

Ensuite, nous allons créer une fonction à l’intérieur de cette fonction: l’idée est de pouvoir créer notre objet de mock dans cette fonction et le retourner à l’utilisateur, nous le faisons simplement car il doit être capable de créer la date comme une instance avec new Date, et cela n’est possible que si nous créons une classe ou une fonction. De plus, des closures comme celle-ci permettent d’empaqueter notre logique interne des mocks et la garder privée :

#createDate() { // L279
    kMock ??= Symbol('MockTimers');
    const NativeDateConstructor = this.#nativeDateDescriptor.value;
    // Notre fonction qui sera le mock
    function MockDate(year, month, date, hours, minutes, seconds, ms) {
      const mockTimersSource = MockDate[kMock];
      const nativeDate = mockTimersSource.#nativeDateDescriptor.value;
      ...
    }
}
#createDate() { // L279
    kMock ??= Symbol('MockTimers');
    const NativeDateConstructor = this.#nativeDateDescriptor.value;
    // Notre fonction qui sera le mock
    function MockDate(year, month, date, hours, minutes, seconds, ms) {
      const mockTimersSource = MockDate[kMock];
      const nativeDate = mockTimersSource.#nativeDateDescriptor.value;
      ...
    }
}

À l’intérieur, nous récupérons déjà notre constante nouvellement définie plus haut et créons un objet avec sa valeur.

Toute cette partie sur le Symbol et le kMock sera expliquée plus loin dans une section séparée, donc pas besoin de tout comprendre ici.

Abordons maintenant le premier et unique cas d’utilisation différent que nous avons lorsque nous accédons à la propriété statique now, c’est-à-dire lorsque la date n’est pas une instance et que nous devons pouvoir identifier cela.

🥵
C’était l’une des parties les plus difficiles à programmer dans ce code, car il s’agit d’un type de métaprogrammation où l’on regarde la propriété d’un objet comme si nous étions l’agent externe, c’est-à-dire que l’objet lui-même doit savoir s’il a été appelé comme une instance ou comme une méthode statique

J’ai regardé attentivement l’implémentation de Sinon pour cela, avec deux parties de la spécification ECMA. Premièrement, l’ECMA 262 édition 5.2 – Section 15.9.2 décrit essentiellement le comportement lorsque nous appelons la fonction comme Date(), elle doit renvoyer la date complète au format UTC.

Parfait, mais comment savoir si elle a été appelée comme une fonction ? On peut utiliser if (!(this instanceof MockDate)), non ? Cela devrait fonctionner car si la date n’est pas une instance de notre objet mock, alors c’est une fonction, qui est la seule autre forme d’appel. Ensuite, il nous suffit d’implémenter notre résultat, qui est la date en chaîne de caractères :

#createDate() { // L279
    kMock ??= Symbol('MockTimers');
    const NativeDateConstructor = this.#nativeDateDescriptor.value;
    // Notre fonction qui sera le mock
    function MockDate(year, month, date, hours, minutes, seconds, ms) {
      const mockTimersSource = MockDate[kMock];
      const nativeDate = mockTimersSource.#nativeDateDescriptor.value;

      if (!(this instanceof MockDate)) {
        return DatePrototypeToString(new nativeDate(mockTimersSource.#now))
      }
    }
}

Ce que nous voulons faire, c’est simplement renvoyer la date réelle sous forme de chaîne, mais à une époques spécifique, celle que nous avons définie comme initiale ou le now que l’utilisateur passe dans le mock, c’est pourquoi il nous faut le kMock et aussi le NativeDateConstructor, afin que nous puissions obtenir l’objet DATE RÉEL et le construire comme s’il s’agissait de new Date(Date.now()), puis obtenir sa représentation en chaîne.

Malheureusement, cela ne fonctionne pas. Pour plusieurs raisons, dans notre fonction nous aurons un grand problème avec this, car il va se retrouver dans un état incohérent, mais le plus criant est que instanceof n’est pas fiable. On peut falsifier l’instance d’un objet si l’on remplace son prototype par ce que l’on veut.

En réalité, c’est l’un des nombreux commentaires où l’on discute de ce point. C’était aussi le dernier problème que j’ai résolu avant de fusionner le code, même s’il s’agissait de la première chose que fait la fonction.

Après de très nombreuses recherches, j’ai trouvé une autre partie de la spécification plus récente (édition 14, section 21.4.2.1) qui décrit plus ou moins comment elle doit être implémentée :

La version 5.2 et la version 14.0 de la spécification sont des extensions l’une de l’autre; la version 14 est la plus récente de 2023 tandis que la 5.2 est très ancienne. En raison de cela, toutes les spécifications de la 5.2 ont été déplacées, mais tout le contenu de la 5.2 existe dans la 14.

Ici, nous avons une piste de ce qu’il faut faire. Qu’est-ce que NewTarget ? C’est une propriété native de toute fonction ou classe qui permet de savoir le contexte d’exécution de cet objet. Elle est représentée comme new.target, c’est-à-dire l’objet qui se situe devant le mot-clé new. Lorsque nous appelons Date sous forme new Date, le new.target sera un constructeur de Date ; alors que lorsque nous exécutons Date() comme une fonction, new.target est undefined car il n’existe pas de new pour avoir une cible (documentation ici). Donc, maintenant, c’est simple : remplaçons notre instanceof par :

#createDate() { // L279
    kMock ??= Symbol('MockTimers');
    const NativeDateConstructor = this.#nativeDateDescriptor.value;
    // Notre fonction qui sera le mock
    function MockDate(year, month, date, hours, minutes, seconds, ms) {
      const mockTimersSource = MockDate[kMock];
      const nativeDate = mockTimersSource.#nativeDateDescriptor.value;

      if (!new.target) {
        return DatePrototypeToString(new nativeDate(mockTimersSource.#now))
      }
    }
  }

La suite de l’implémentation devient alors nettement plus simple. La prochaine étape consiste à déterminer laquelle des 11 façons d’appeler Date nous utilisons. Pour cela, j’ai simplement copié l’implémentation de Sinon et apporté quelques modifications :

#createDate() { // L279
    kMock ??= Symbol('MockTimers');
    const NativeDateConstructor = this.#nativeDateDescriptor.value;
    // Notre fonction qui sera le mock
    function MockDate(year, month, date, hours, minutes, seconds, ms) {
      const mockTimersSource = MockDate[kMock];
      const nativeDate = mockTimersSource.#nativeDateDescriptor.value;

      if (!(this instanceof MockDate)) {
        return DatePrototypeToString(new nativeDate(mockTimersSource.#now))
      }
      switch (arguments.length) {
        case 0:
          return new nativeDate(MockDate[kMock].#now);
        case 1:
          return new nativeDate(year);
        case 2:
          return new nativeDate(year, month);
        case 3:
          return new nativeDate(year, month, date);
        case 4:
          return new nativeDate(year, month, date, hours);
        case 5:
          return new nativeDate(year, month, date, hours, minutes);
        case 6:
          return new nativeDate(year, month, date, hours, minutes, seconds);
        default:
          return new nativeDate(year, month, date, hours, minutes, seconds, ms);
      }
    }
}

Rappelons que nous devons compter le nombre d’arguments et que tous les arguments sont positionnels; nous n’avons besoin de traiter que les cas spécifiques, car si l’utilisateur passe une date précise, nous n’avons pas besoin de retourner la date qu’il a donnée, puisqu’il est en train de créer un nouvel objet. Donc, lorsque nous avons 1 argument, cela vaut aussi bien pour créer un objet de Date à partir d’un autre Date comme new Date(new Date()), ou une chaîne comme new Date('2024-05-10'), et tout autre, parce que nous déléguons à la date originale l’exécution de cette fonction.

Aujourd’hui, lorsque nous avons terminé notre fonction MockDate, nous devons définir toutes les propriétés supplémentaires que Date possède (toString, toISOString, etc.) car elles resteront les mêmes et je ne veux pas tout implémenter à la main. Cependant, notre objet date ne peut pas remplacer le prototype de notre objet actuel, c’est pourquoi nous allons retirer le prototype et n’associer que les propriétés :

#createDate() { // L279
    kMock ??= Symbol('MockTimers');
    const NativeDateConstructor = this.#nativeDateDescriptor.value;
    // Notre fonction qui sera le mock
    function MockDate(year, month, date, hours, minutes, seconds, ms) {
      const mockTimersSource = MockDate[kMock];
      const nativeDate = mockTimersSource.#nativeDateDescriptor.value;

      if (!(this instanceof MockDate)) {
        return DatePrototypeToString(new nativeDate(mockTimersSource.#now))
      }

      switch (arguments.length) {
        case 0:
          return new nativeDate(MockDate[kMock].#now);
        case 1:
          return new nativeDate(year);
        case 2:
          return new nativeDate(year, month);
        case 3:
          return new nativeDate(year, month, date);
        case 4:
          return new nativeDate(year, month, date, hours);
        case 5:
          return new nativeDate(year, month, date, hours, minutes);
        case 6:
          return new nativeDate(year, month, date, hours, minutes, seconds);
        default:
          return new nativeDate(year, month, date, hours, minutes, seconds, ms);
      }
  }

    // removemos o protótipo
    const { prototype, ...dateProps } = ObjectGetOwnPropertyDescriptors(NativeDateConstructor);
    // associamos as propriedades
    ObjectDefineProperties(MockDate, dateProps);

}

La seule méthode que nous devons remplacer est now, qui doit toujours renvoyer ce que l’utilisateur a mis dans le mock. Or, c’est assez simple car now est une méthode statique. Nous pouvons donc simplement faire MockDate.now = ... :

#createDate() { // L279
    kMock ??= Symbol('MockTimers');
    const NativeDateConstructor = this.#nativeDateDescriptor.value;
    // Notre fonction qui sera le mock
    function MockDate(year, month, date, hours, minutes, seconds, ms) {
      const mockTimersSource = MockDate[kMock];
      const nativeDate = mockTimersSource.#nativeDateDescriptor.value;

      if (!(this instanceof MockDate)) {
        return DatePrototypeToString(new nativeDate(mockTimersSource.#now))
      }

      switch (arguments.length) {
        case 0:
          return new nativeDate(MockDate[kMock].#now);
        case 1:
          return new nativeDate(year);
        case 2:
          return new nativeDate(year, month);
        case 3:
          return new nativeDate(year, month, date);
        case 4:
          return new nativeDate(year, month, date, hours);
        case 5:
          return new nativeDate(year, month, date, hours, minutes);
        case 6:
          return new nativeDate(year, month, date, hours, minutes, seconds);
        default:
          return new nativeDate(year, month, date, hours, minutes, seconds, ms);
      }
  }

  // removemos o protótipo
  const { prototype, ...dateProps } = ObjectGetOwnPropertyDescriptors(NativeDateConstructor);
  // associamos as propriedades
  ObjectDefineProperties(MockDate, dateProps);

  // mantém o this correto dentro da função
  MockDate.now = function now() {
    return MockDate[kMock].#now
  }

}

La prochaine étape consiste à une petite modification afin d’éviter que, lorsque vous faites Date.toString(), vous obteniez le code natif réel qui est 'function Date() { [native code] }', et non l’implémentation de notre Mock. Pour cela, nous écrasons la fonction toString avec le code original :

#createDate() { // L279
    kMock ??= Symbol('MockTimers');
    const NativeDateConstructor = this.#nativeDateDescriptor.value;
    // Notre fonction qui sera le mock
    function MockDate(year, month, date, hours, minutes, seconds, ms) {
      const mockTimersSource = MockDate[kMock];
      const nativeDate = mockTimersSource.#nativeDateDescriptor.value;

      if (!(this instanceof MockDate)) {
        return DatePrototypeToString(new nativeDate(mockTimersSource.#now))
      }

      switch (arguments.length) {
        case 0:
          return new nativeDate(MockDate[kMock].#now);
        case 1:
          return new nativeDate(year);
        case 2:
          return new nativeDate(year, month);
        case 3:
          return new nativeDate(year, month, date);
        case 4:
          return new nativeDate(year, month, date, hours);
        case 5:
          return new nativeDate(year, month, date, hours, minutes);
        case 6:
          return new nativeDate(year, month, date, hours, minutes, seconds);
        default:
          return new nativeDate(year, month, date, hours, minutes, seconds, ms);
      }
  }

  // removemos o protótipo
  const { prototype, ...dateProps } = ObjectGetOwnPropertyDescriptors(NativeDateConstructor);
  // associamos as propriedades
  ObjectDefineProperties(MockDate, dateProps);

  // mantém o this correto dentro da função
  MockDate.now = function now() {
    return MockDate[kMock].#now
  }

  MockDate.toString = function toString() {
      return FunctionPrototypeToString(MockDate[kMock].#nativeDateDescriptor.value);
    };

}

Nous approchons de la fin: ce que nous devons maintenant faire est définir la seule propriété que nous utilisons beaucoup mais qui n’a pas encore été définie, kMock, vous l’avez peut-être remarqué ?

kMock

kMock est un symbole au sein de notre implémentation qui, essentiellement, est une référence à notre objet global des Mocks afin que nous puissions accéder aux propriétés privées telles que #now et le constructeur original de la date. Mais il n’a pas été défini jusqu’ici, est-ce que cela ne posera pas problème ?

En réalité non, car chaque fois que nous appelons MockDate[kMock], nous étions à l’intérieur d’une fonction, et MockDate n’existera pas avant la fin de notre fonction #createDate. Ainsi, il est sûr de le définir seulement à la fin, d’autant plus que nous avons besoin à la fois de MockDate et du symbole pour cela. Nous avons simplement défini le symbole plus haut pour maintenir la référence que nous allons utiliser, car maintenant nous pouvons faire ceci :

  ObjectDefineProperties(MockDate, {
    __proto__: null,
    [kMock]: {
      __proto__: null,
      enumerable: false,
      configurable: false,
      writable: false,
      value: this,
    },

    isMock: {
      __proto__: null,
      enumerable: true,
      configurable: false,
      writable: false,
      value: true,
    },
  });

Ce que nous faisons ici est double : nous prenons notre fonction MockDate et nous créons des propriétés dessus. Tout d’abord, en définissant le prototype comme nul afin d’éviter des problèmes d’héritage, puis en indiquant que [kMock] est un autre objet qui n’est ni énumérable, ni modifiable et ne peut être configuré — autrement dit, il est totalement immuable et pointe vers MockTimers, qui est le this dans le contexte de #createDate.

Puis nous ajoutons une autre propriété, mon petit touche personnelle dans ce code : une façon de savoir si une date est une instance d’un mock en appelant MockDaData.isMock. Cette valeur est énumérable mais non modifiable. Cela est nécessaire parfois lorsque nous traitons des tests qui utilisent plusieurs mocks de dates.

Pour l’instant, notre fonction ressemble à ceci :

#createDate() { // L279
    kMock ??= Symbol('MockTimers');
    const NativeDateConstructor = this.#nativeDateDescriptor.value;
    // Notre fonction qui sera le mock
    function MockDate(year, month, date, hours, minutes, seconds, ms) {
      const mockTimersSource = MockDate[kMock];
      const nativeDate = mockTimersSource.#nativeDateDescriptor.value;

      if (!(this instanceof MockDate)) {
        return DatePrototypeToString(new nativeDate(mockTimersSource.#now))
      }

      switch (arguments.length) {
        case 0:
          return new nativeDate(MockDate[kMock].#now);
        case 1:
          return new nativeDate(year);
        case 2:
          return new nativeDate(year, month);
        case 3:
          return new nativeDate(year, month, date);
        case 4:
          return new nativeDate(year, month, date, hours);
        case 5:
          return new nativeDate(year, month, date, hours, minutes);
        case 6:
          return new nativeDate(year, month, date, hours, minutes, seconds);
        default:
          return new nativeDate(year, month, date, hours, minutes, seconds, ms);
      }
  }

  // removemos o protótipo
  const { prototype, ...dateProps } = ObjectGetOwnPropertyDescriptors(NativeDateConstructor);
  // associamos as propriedades
  ObjectDefineProperties(MockDate, dateProps);

  // mantém o this correto dentro da função
  MockDate.now = function now() {
    return MockDate[kMock].#now
  }

  MockDate.toString = function toString() {
      return FunctionPrototypeToString(MockDate[kMock].#nativeDateDescriptor.value);
    };

  ObjectDefineProperties(MockDate, {
    __proto__: null,
    [kMock]: {
      __proto__: null,
      enumerable: false,
      configurable: false,
      writable: false,
      value: this,
    },

    isMock: {
      __proto__: null,
      enumerable: true,
      configurable: false,
      writable: false,
      value: true,
    },
  });
}

Toques finales

Le dernier point est de définir le prototype de notre MockDate sur le prototype original de Date, afin de ne pas « casser » les applications qui font instanceof Date, et de plus, nous définissons les méthodes statiques globales communes que nous n’allons pas remplacer et nous rendons tout notre travail accessible :

#createDate() { // L279
    kMock ??= Symbol('MockTimers');
    const NativeDateConstructor = this.#nativeDateDescriptor.value;
    // Notre fonction qui sera le mock
    function MockDate(year, month, date, hours, minutes, seconds, ms) {
      const mockTimersSource = MockDate[kMock];
      const nativeDate = mockTimersSource.#nativeDateDescriptor.value;

      if (!(this instanceof MockDate)) {
        return DatePrototypeToString(new nativeDate(mockTimersSource.#now))
      }

      switch (arguments.length) {
        case 0:
          return new nativeDate(MockDate[kMock].#now);
        case 1:
          return new nativeDate(year);
        case 2:
          return new nativeDate(year, month);
        case 3:
          return new nativeDate(year, month, date);
        case 4:
          return new nativeDate(year, month, date, hours);
        case 5:
          return new nativeDate(year, month, date, hours, minutes);
        case 6:
          return new nativeDate(year, month, date, hours, minutes, seconds);
        default:
          return new nativeDate(year, month, date, hours, minutes, seconds, ms);
      }
  }

  // removemos o protótipo
  const { prototype, ...dateProps } = ObjectGetOwnPropertyDescriptors(NativeDateConstructor);
  // associamos as propriedades
  ObjectDefineProperties(MockDate, dateProps);

  // mantém o this correto dentro da função
  MockDate.now = function now() {
    return MockDate[kMock].#now
  }

  MockDate.toString = function toString() {
      return FunctionPrototypeToString(MockDate[kMock].#nativeDateDescriptor.value);
    };

  ObjectDefineProperties(MockDate, {
    __proto__: null,
    [kMock]: {
      __proto__: null,
      enumerable: false,
      configurable: false,
      writable: false,
      value: this,
    },

    isMock: {
      __proto__: null,
      enumerable: true,
      configurable: false,
      writable: false,
      value: true,
    },
  });

  MockDate.prototype = NativeDateConstructor.prototype;
  MockDate.parse = NativeDateConstructor.parse;
  MockDate.UTC = NativeDateConstructor.UTC;
  MockDate.prototype.toUTCString = NativeDateConstructor.prototype.toUTCString;
  return MockDate;
}

Méthodes du mock

Maintenant que nous avons le mock principal, nous pouvons définir les autres fonctions qui l’accompagnent. Premièrement, nous devons modifier notre méthode enable afin qu’elle puisse accepter la nouvelle API. La modification consiste essentiellement à la validation :

// nous créons le `now` comme paramètre dans les options
enable(options = { __proto__: null, apis: SUPPORTED_APIS, now: 0 }) {
  // clonons l’objet options
  const internalOptions = { __proto__: null, ...options };

  // ... code original

  // definissons la valeur si elle n'existe pas
  if (!internalOptions.now) {
    internalOptions.now = 0;
  }

  // Si les API ne sont pas passées, nous avons toutes les APIs activées
  if (!internalOptions.apis) {
    internalOptions.apis = SUPPORTED_APIS;
  }

  // ... Code original

  // Now peut être une instance de Date, nous vérifions cela
  if (this.#isValidDateWithGetTime(internalOptions.now)) {
    this.#now = DatePrototypeGetTime(internalOptions.now);
  } 
  // Autrement c’est un nombre
  else if (validateNumber(internalOptions.now, 'initialTime') === undefined) {
    this.#assertTimeArg(internalOptions.now);
    this.#now = internalOptions.now;
  }

  this.#toggleEnableTimers(true);
}

Notre fonction #isValidDateWithGetTime ne vérifie pas réellement si c’est une instance de Date; en réalité, elle vérifie seulement si cet objet possède une propriété getTime, ce qui nous suffit :

#isValidDateWithGetTime(maybeDate) { // L512
  try {
    DatePrototypeGetTime(maybeDate);
    return true;
  } catch {
    return false;
  }
}

Notre fonction #toggleEnableTimers est essentiellement un grand objet avec deux propriétés : toFake et toReal, qui contiennent les fonctions nécessaires pour transformer l’objet en mock et le ramener au natif :

#toggleEnableTimers(activated) { // L522
  const options = {
    __proto__: null,
    toFake: {
      __proto__: null,
      // ... code des timers originaux
      Date: () => {
        this.#nativeDateDescriptor = ObjectGetOwnPropertyDescriptor(globalThis, 'Date')
        // la magie se produit ici
        globalThis.Date = this.createDate()
      }
    },
    toReal: {
      __proto__: null,
      // ... timers
      Date: () => {
        ObjectDefineProperty(globalThis, 'Date', this.#nativeDateDescriptor)
      }
    }
  }

  const target = activate ? options.toFake : options.toReal
  ArrayPrototypeForEach(this.#timersInContext, (timer) => target[timer]())
  this.#isEnabled = activate
}

Par ailleurs, nous avons trois autres méthodes associées aux mocks de temporalité : setTime, qui est exclusive aux dates, tick et runAll.

setTime va changer la valeur de #now, c’est donc assez direct :

setTime(time = kInitialEpoch) { // L690
  validateNumber(time, 'time');
  this.#assertTimeArg(time);
  this.#assertTimersAreEnabled();

  this.#now = time;
}

tick existait déjà, mais nous devons apporter une petite modification. Cette méthode avance le temps d’un nombre donné de millisecondes, nous devons donc aussi avancer le #now :

tick(time = 1) { // L613
  // ... code de validation 

  this.#now += time;

  // ... reste du code non modifié
}

La dernière méthode est runAll, qui a reçu une petite modification dans un autre fichier. L’idée de cette méthode est d’exécuter tous les timers programmés. Pour cela, nous utilisons une structure appelée PriorityQueue, qui est essentiellement une file ordonnée par le temps, c’est-à-dire le timer ayant le plus petit délai est en tête et celui avec le plus grand délai est à la fin.

La PriorityQueue est définie dans lib/internal/priority_queue.js, elle contenait déjà une méthode appelée peek qui récupère le premier élément de la file sans le retirer, nous avons besoin d’obtenir le dernier, car nous devons désormais connaître quel timer a le plus grand délai, soustraire du temps déjà passé (notre #now) et appeler la méthode tick avec cette différence, afin d’exécuter tous les timers sans ajouter du temps additionnel à notre date (car maintenant tick va ajouter des millisecondes à notre #now). Pour cela j’ai créé une méthode nommée peekBottom.

Je ne vais pas inclure ici l’implémentation de PriorityQueue, mais le lien ci-dessus vous mènera là-bas

L’implémentation en elle-même est assez directe :

runAll() { // L728
  this.#assertTimersAreEnabled();
  const longestTimer = this.#executionQueue.peekBottom();
  if (!longestTimer) return; // file vide
  // Avance le temps
  this.tick(longestTimer.runAt - this.#now);
}

Et cela s’arrête-t-il là ?

Voilà la fin de l’implémentation des timers, mais le travail ne s’arrête pas là. Comme l’article est long, je ne vais pas publier plus sur ce sujet. Les tests pour cette fonctionnalité ont été une autre étape à part, au total j’ai passé au moins 13 heures sur ce projet, en plus d’environ 3 mois de discussions, de résolutions et tout le reste. Finalement, cette fonctionnalité a été intégrée dans la version 21.2 de Node (il existe même un article expliquant comment l’utiliser).

On voit que j’étais vraiment content

De plus, si vous regardez l’historique de la PR, vous verrez que j’ai passé des jours à lutter contre le CI de GitHub car il y avait des flaky tests, qui ont été corrigés par Yagiz un peu après.

Je devenais fou

Ces flaky tests empêchaient mon application d’obtenir le feu vert, mais les erreurs n’avaient pas grand-chose à voir avec le code que j’avais modifié. C’est pourquoi il est si important que des contributeurs comme nous puissions aider avec la couverture des tests et la vérification du processus.

Conclusion

Ce fut un article long, mais je tenais à partager ce contenu ici pour que vous réalisiez qu’il est tout à fait possible de participer à de grands projets open source et de faire une différence même avec de petites contributions comme celle-ci.

Le processus de contribution à un projet conséquent est complexe: il implique de nombreuses variables et de nombreux jours et semaines de discussions avec tout le monde, mais c’est extrêmement stimulant et enrichissant lorsque tout se termine !

J’espère que vous vous êtes senti(e) inspiré(e) à essayer de contribuer à l’Open Source !

À bientôt !

Fabien Delpont

Auteur

Fabien Delpont

Fabien Delpont, développeur et créateur du site Python Doctor.