eqsMutator capture fetch au chargement du module : vi.stubGlobal('fetch', …) ne s'applique pas, et un test unitaire appelle l'amont pour de vrai #87

Closed
opened 2026-09-22 08:15:17 +00:00 by nicolas-fryder · 0 comments

Ce qui ne va pas

creerMutator résout fetch une fois, à la création :

export function creerMutator(options = {}) {
    const fetchImpl = options.fetchImpl ?? fetch;
    return async function eqsMutator(url, init = {}) { … }
}

eqsMutator étant créé au chargement de @eqs/http, il garde une référence au fetch global tel qu'il était avant que la suite de tests ne l'ait doublé. Or vi.stubGlobal('fetch', …) — la façon normale de doubler le réseau sous Vitest, et celle que la documentation de Vitest recommande — remplace globalThis.fetch après l'import.

Résultat : le double n'est jamais vu. Le test croit intercepter, et l'appel part pour de bon.

Reproduction

import { describe, expect, it, vi } from 'vitest'
import { defineTarget, eqsMutator } from '@eqs/http'

defineTarget({ nom: 'amont', baseUrl: 'https://amont.test', apiKey: 'k' })

it('le double est ignoré', async () => {
  const faux = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }))
  vi.stubGlobal('fetch', faux)
  await eqsMutator('https://amont.test/chose').catch(() => {})
  expect(faux).toHaveBeenCalled()   // ← échoue
})

Obtenu : faux n'est jamais appelé. Sans réseau, l'appel réel échoue en DNS et le mutator le traduit en ApiError(502, UPSTREAM) — soit, dans nos journaux de test, « amont → 502 », qui ressemble exactement à une panne de l'amont. Avec réseau, un test unitaire appelle le vrai service.

Mesuré sur ATLAS : huit cas de tests/hq/client.test.ts sont passés d'un coup de vert à « HQ projects → 502 » en remplaçant fetch(…) par eqsMutator(…) dans src/lib/hq/client.ts. Le diagnostic a pris un moment, précisément parce que le symptôme accuse l'amont.

Ce que ça coûte

Contournable en une ligne, et c'est ce qu'ATLAS a fait :

const mutator = creerMutator({ fetchImpl: (...args) => globalThis.fetch(...args) })

Mais il faut d'abord comprendre qu'eqsMutator n'est pas doublable — ce qui demande d'ouvrir node_modules/@eqs/http/dist/index.js, c'est-à-dire le cas que AGENTS.md désigne : « une API qu'il a fallu lire dans le code du package pour comprendre ».

Et le mode d'échec par défaut est le pire des deux : un test qui passe en faisant un vrai appel réseau est pire qu'un test qui échoue. Sur une cible en lecture c'est du bruit ; sur une cible qui écrit, c'est une écriture depuis une suite de tests.

Ce que je proposerais

  1. Résoudre fetch à l'appel plutôt qu'à la création :
    const appeler = (url, init) => (options.fetchImpl ?? globalThis.fetch)(url, init)
    
    Aucune conséquence en production — globalThis.fetch ne change pas — et le double est vu. C'est le comportement que tout le monde attend d'un module qui utilise fetch.
  2. Sinon, le dire dans le README de @eqs/http, à côté de creerMutator({ fetchImpl }) : « eqsMutator fige fetch à l'import ; sous Vitest, vi.stubGlobal n'a aucun effet sur lui — créez votre mutator avec un fetchImpl tardif ». Un paragraphe évite la lecture du dist/.
  3. Éventuellement un contrôle de conformité : un test du socle qui double globalThis.fetch après l'import et exige que eqsMutator le voie. C'est une propriété mécanique, et elle vaut pour les trente satellites qui écriront des tests autour de leurs cibles.

Versions

@eqs/http 0.12.1, @eqs/errors 0.12.1, Vitest 3.2.7, Node 24.15.0, pnpm 11.8.0, Windows 11. Contexte : rétrofit d'ATLAS (étape 6), branche epic/retrofit de EQS/ATLAS, journal dans docs/retrofit-eqs.md.

Contournement posé

src/lib/hq/client.ts crée son propre mutator avec un fetchImpl qui résout globalThis.fetch à chaque appel, et le commentaire pointe ce ticket. Il n'y aura rien à retirer quand la 1 sera faite : le contournement redeviendra simplement inutile.

## Ce qui ne va pas `creerMutator` résout `fetch` **une fois, à la création** : ```js export function creerMutator(options = {}) { const fetchImpl = options.fetchImpl ?? fetch; return async function eqsMutator(url, init = {}) { … } } ``` `eqsMutator` étant créé au chargement de `@eqs/http`, il garde une référence au `fetch` global tel qu'il était **avant** que la suite de tests ne l'ait doublé. Or `vi.stubGlobal('fetch', …)` — la façon normale de doubler le réseau sous Vitest, et celle que la documentation de Vitest recommande — remplace `globalThis.fetch` *après* l'import. Résultat : le double n'est jamais vu. Le test croit intercepter, et l'appel part pour de bon. ## Reproduction ```ts import { describe, expect, it, vi } from 'vitest' import { defineTarget, eqsMutator } from '@eqs/http' defineTarget({ nom: 'amont', baseUrl: 'https://amont.test', apiKey: 'k' }) it('le double est ignoré', async () => { const faux = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })) vi.stubGlobal('fetch', faux) await eqsMutator('https://amont.test/chose').catch(() => {}) expect(faux).toHaveBeenCalled() // ← échoue }) ``` Obtenu : `faux` n'est jamais appelé. Sans réseau, l'appel réel échoue en DNS et le mutator le traduit en `ApiError(502, UPSTREAM)` — soit, dans nos journaux de test, « amont → 502 », qui ressemble exactement à une panne de l'amont. Avec réseau, **un test unitaire appelle le vrai service**. Mesuré sur ATLAS : huit cas de `tests/hq/client.test.ts` sont passés d'un coup de vert à « HQ projects → 502 » en remplaçant `fetch(…)` par `eqsMutator(…)` dans `src/lib/hq/client.ts`. Le diagnostic a pris un moment, précisément parce que le symptôme accuse l'amont. ## Ce que ça coûte Contournable en une ligne, et c'est ce qu'ATLAS a fait : ```ts const mutator = creerMutator({ fetchImpl: (...args) => globalThis.fetch(...args) }) ``` Mais il faut d'abord *comprendre* qu'`eqsMutator` n'est pas doublable — ce qui demande d'ouvrir `node_modules/@eqs/http/dist/index.js`, c'est-à-dire le cas que `AGENTS.md` désigne : « une API qu'il a fallu lire dans le code du package pour comprendre ». Et le mode d'échec par défaut est le pire des deux : un test qui **passe** en faisant un vrai appel réseau est pire qu'un test qui échoue. Sur une cible en lecture c'est du bruit ; sur une cible qui écrit, c'est une écriture depuis une suite de tests. ## Ce que je proposerais 1. **Résoudre `fetch` à l'appel** plutôt qu'à la création : ```js const appeler = (url, init) => (options.fetchImpl ?? globalThis.fetch)(url, init) ``` Aucune conséquence en production — `globalThis.fetch` ne change pas — et le double est vu. C'est le comportement que tout le monde attend d'un module qui utilise `fetch`. 2. **Sinon, le dire dans le README de `@eqs/http`**, à côté de `creerMutator({ fetchImpl })` : « `eqsMutator` fige `fetch` à l'import ; sous Vitest, `vi.stubGlobal` n'a aucun effet sur lui — créez votre mutator avec un `fetchImpl` tardif ». Un paragraphe évite la lecture du `dist/`. 3. **Éventuellement un contrôle de conformité** : un test du socle qui double `globalThis.fetch` après l'import et exige que `eqsMutator` le voie. C'est une propriété mécanique, et elle vaut pour les trente satellites qui écriront des tests autour de leurs cibles. ## Versions `@eqs/http` 0.12.1, `@eqs/errors` 0.12.1, Vitest 3.2.7, Node 24.15.0, pnpm 11.8.0, Windows 11. Contexte : rétrofit d'ATLAS (étape 6), branche `epic/retrofit` de `EQS/ATLAS`, journal dans `docs/retrofit-eqs.md`. ## Contournement posé `src/lib/hq/client.ts` crée son propre mutator avec un `fetchImpl` qui résout `globalThis.fetch` à chaque appel, et le commentaire pointe ce ticket. Il n'y aura rien à retirer quand la 1 sera faite : le contournement redeviendra simplement inutile.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Infrastructure/eqs-platform#87
No description provided.