Instructions asynchrones

L'asynchrone en JavaScript

Pourquoi l'asynchrone existe-t-il?

JavaScript est mono-thread : il ne peut exécuter qu'une seule instruction à la fois. Or, certaines opérations prennent du temps — charger un fichier, appeler une API, lire une base de données. Si JavaScript attendait chaque opération avant de continuer, la page serait figée pendant ce temps.

L'asynchrone permet de lancer une opération longue et continuer à exécuter le reste du code pendant que cette opération se déroule en arrière-plan. Quand elle se termine, JavaScript reprend pour traiter le résultat.

Synchrone (bloquant)        Asynchrone (non-bloquant)
──────────────────          ──────────────────────────
1. Instruction A            1. Instruction A
2.  Attendre...             2. Lancer opération longue → part en arrière-plan
3.  Attendre...             3. Instruction B  ← continue sans attendre
4. Résultat reçu            4. Instruction C
5. Instruction B            5.  Résultat reçu → traiter

Les 3 générations de l'asynchrone en JavaScript

Génération 1 — Les callbacks (à éviter aujourd'hui)

La première approche : passer une fonction à appeler quand l'opération se termine.

// Exemple avec setTimeout (simuler une attente)
console.log("Début");

setTimeout(function () {
    console.log("Résultat après 2 secondes");
}, 2000);

console.log("Fin");

// Affiche dans cet ordre :
// Début
// Fin
// Résultat après 2 secondes  ← arrive après !

Le problème : le callback hell

Quand les opérations s'enchaînent, le code devient illisible :

 // Cauchemar de maintenance
chargerUtilisateur(id, function (user) {
    chargerCommandes(user, function (commandes) {
        chargerProduits(commandes, function (produits) {
            calculerTotal(produits, function (total) {
                afficher(total, function () {
                    // ...
                });
            });
        });
    });
});

Génération 2 — Les Promises

Une Promise (promesse) représente une valeur qui sera disponible dans le futur. Elle a trois états possibles :

// Une Promise ressemble à ça
const promesse = new Promise((resolve, reject) => {
    // Si tout va bien :
    resolve("La valeur");

    // Si ça échoue :
    reject(new Error("Quelque chose a mal tourné"));
});

Chaîner les Promises avec .then() et .catch()

fetch("https://api.example.com/users/1")
    .then((response) => response.json()) // quand la réponse arrive, la convertir en JSON
    .then((user) => console.log(user.name)) // quand le JSON est prêt, afficher le nom
    .catch((erreur) => console.error(erreur)); // si n'importe quelle étape échoue

C'est mieux que les callbacks, mais le chaînage de .then() reste verbeux pour des cas complexes.


Génération 3 — async / await (syntaxe moderne, à utiliser)

async / await est du sucre syntaxique par-dessus les Promises. Cela permet d'écrire du code asynchrone qui ressemble à du code synchrone, sans callbacks ni .then().

// Avec .then() (verbose)
function chargerUtilisateur(id) {
    return fetch(`/api/users/${id}`)
        .then((response) => response.json())
        .then((user) => user.name);
}

// Avec async/await (lisible)
async function chargerUtilisateur(id) {
    const response = await fetch(`/api/users/${id}`);
    const user = await response.json();
    return user.name;
}

await ne bloque pas tout JavaScript — il suspend seulement la fonction courante et laisse le reste du programme continuer.


Le mot-clé async

async se place devant une fonction pour indiquer qu'elle est asynchrone.

// Fonction classique
async function maFonction() { ... }

// Fonction fléchée
const maFonction = async () => { ... }

// Méthode dans un objet
const obj = {
  async maMethode() { ... }
}

Une fonction async retourne toujours une Promise, même si vous ne le voyez pas explicitement :

async function getMessage() {
    return "Bonjour";
}

// Équivalent à :
function getMessage() {
    return Promise.resolve("Bonjour");
}

// Pour lire la valeur :
getMessage().then((msg) => console.log(msg)); // 'Bonjour'
// ou
const msg = await getMessage(); // 'Bonjour' (dans un contexte async)

Le mot-clé await

await se place devant une Promise pour attendre son résultat avant de continuer.

async function exemple() {
    console.log("1 - Avant");

    const resultat = await unePromesse(); // attendre ici

    console.log("2 - Après"); // s'exécute seulement quand la promesse est résolue
    console.log(resultat);
}

Règles importantes :

 // INTERDIT — await en dehors d'une fonction async
function maFonction() {
  const data = await fetch('/api') // SyntaxError
}

 // CORRECT
async function maFonction() {
  const data = await fetch('/api')
}

Gestion des erreurs avec try / catch

Avec les callbacks et les Promises, on utilisait .catch(). Avec async/await, on utilise le traditionnel try/catch :

async function chargerUtilisateur(id) {
    try {
        const response = await fetch(`/api/users/${id}`);

        if (!response.ok) {
            throw new Error(`Erreur HTTP : ${response.status}`);
        }

        const user = await response.json();
        return user;
    } catch (erreur) {
        console.error("Impossible de charger l'utilisateur :", erreur.message);
        return null;
    }
}

Cas classiques capturés par catch :


Exemples concrets

Appel API avec fetch

async function getUtilisateur(id) {
    try {
        const response = await fetch(
            `https://jsonplaceholder.typicode.com/users/${id}`,
        );
        const user = await response.json();
        return user;
    } catch (erreur) {
        console.error("Erreur :", erreur);
    }
}

// Utilisation
const user = await getUtilisateur(1);
console.log(user.name); // 'Leanne Graham'

Opérations enchaînées

async function commanderProduit(userId, produitId) {
    // Étape 1 — vérifier l'utilisateur
    const user = await getUtilisateur(userId);
    if (!user) throw new Error("Utilisateur introuvable");

    // Étape 2 — vérifier le stock
    const produit = await getProduit(produitId);
    if (produit.stock === 0) throw new Error("Produit épuisé");

    // Étape 3 — créer la commande
    const commande = await creerCommande(user.id, produit.id);

    // Étape 4 — envoyer la confirmation
    await envoyerEmail(user.email, commande.id);

    return commande;
}

Sans async/await, ces 4 étapes enchaînées auraient nécessité 4 .then() imbriqués ou une pyramide de callbacks.

Opérations en parallèle avec Promise.all

Par défaut, les await s'exécutent l'un après l'autre. Si les opérations sont indépendantes, lancez-les en parallèle pour gagner du temps :

 // Séquentiel — chaque fetch attend le précédent
async function chargerDonneesSequentiel() {
    const users = await fetch("/api/users").then((r) => r.json()); // attend
    const produits = await fetch("/api/produits").then((r) => r.json()); // attend
    const commandes = await fetch("/api/commandes").then((r) => r.json()); // attend
    // Temps total ≈ t(users) + t(produits) + t(commandes)
}

 // Parallèle — les trois fetches partent en même temps
async function chargerDonneesParallele() {
    const [users, produits, commandes] = await Promise.all([
        fetch("/api/users").then((r) => r.json()),
        fetch("/api/produits").then((r) => r.json()),
        fetch("/api/commandes").then((r) => r.json()),
    ]);
    // Temps total ≈ max(t(users), t(produits), t(commandes))
}

Règle : utilisez Promise.all quand les opérations ne dépendent pas les unes des autres.

Promise.allSettled — continuer même si une échoue

Promise.all s'arrête dès qu'une Promise échoue. Promise.allSettled attend toutes les Promises, qu'elles réussissent ou non :

const resultats = await Promise.allSettled([
    fetch("/api/users").then((r) => r.json()),
    fetch("/api/inexistant").then((r) => r.json()), // va échouer
    fetch("/api/produits").then((r) => r.json()),
]);

resultats.forEach((resultat) => {
    if (resultat.status === "fulfilled") {
        console.log("Succès :", resultat.value);
    } else {
        console.log("Échec :", resultat.reason);
    }
});

L'asynchrone dans Vue.js

Dans un composant — onMounted

import { ref, onMounted } from "vue";

const user = ref(null);
const isLoading = ref(true);
const erreur = ref(null);

onMounted(async () => {
    try {
        const response = await fetch("/api/users/1");
        user.value = await response.json();
    } catch (e) {
        erreur.value = "Impossible de charger l'utilisateur";
    } finally {
        isLoading.value = false;
    }
});
<template>
    <div v-if="isLoading">Chargement...</div>
    <div v-else-if="erreur">{{ erreur }}</div>
    <div v-else>{{ user.name }}</div>
</template>

Dans un store Pinia - action asynchrone

import { defineStore } from "pinia";
import { ref } from "vue";

export const useUserStore = defineStore("user", () => {
    const user = ref(null);
    const isLoading = ref(false);

    async function charger(id) {
        isLoading.value = true;
        try {
            const response = await fetch(`/api/users/${id}`);
            user.value = await response.json();
        } finally {
            isLoading.value = false;
        }
    }

    return { user, isLoading, charger };
});

Erreurs classiques à éviter

1. Oublier await — la valeur est une Promise, pas le résultat

async function exemple() {
    const data = fetch("/api/users");  // oubli de await
    console.log(data); // Promise { <pending> } — pas les données !

    const data2 = await fetch("/api/users");  //
    console.log(data2); // Response { ... }
}

2. await dans une boucle forEach — ça ne fonctionne pas

 // forEach n'attend pas les await
const ids = [1, 2, 3];
ids.forEach(async (id) => {
    const user = await getUtilisateur(id); // ignoré par forEach !
    console.log(user.name);
});

 // Utiliser for...of
for (const id of ids) {
    const user = await getUtilisateur(id);
    console.log(user.name);
}

 // Ou Promise.all pour la parallélisation
const users = await Promise.all(ids.map((id) => getUtilisateur(id)));

3. Ne pas gérer les erreurs

 // Si fetch échoue, l'erreur est silencieuse
async function charger() {
    const data = await fetch("/api/data").then((r) => r.json());
    afficher(data);
}

 // Toujours un try/catch
async function charger() {
    try {
        const data = await fetch("/api/data").then((r) => r.json());
        afficher(data);
    } catch (e) {
        afficherErreur(e.message);
    }
}

4. Async/await et le top-level (hors fonction)

await ne peut pas s'utiliser directement à la racine d'un fichier .js classique. Dans les modules ES (.mjs ou type="module"), c'est possible depuis 2022 :

 //  Top-level await (modules ES seulement)
const data = await fetch("/api").then((r) => r.json());

Résumé

Concept Ce que ça fait
async function Déclare une fonction asynchrone qui retourne une Promise
await Attend le résultat d'une Promise avant de continuer
try / catch Capture les erreurs d'un bloc asynchrone
Promise.all([...]) Lance plusieurs Promises en parallèle, attend toutes
Promise.allSettled([...]) Comme all, mais continue même si une échoue
for...of + await Boucle asynchrone séquentielle
.map() + Promise.all Boucle asynchrone parallèle

En une image mentale

Imaginez un restaurant :

L'asynchrone permet au serveur (JavaScript) de rester productif pendant que les opérations longues (cuisine = réseau, fichiers, base de données) se déroulent en parallèle.

Atelier asynchrone (en devoir)

Prérequis

Écouter la vidéo suivante :

Fichiers associés exemple-asynchrone.zip

Consignes

À partir de lab03-depart.zip :

Où est le serveur backend ?
Le backend, est le serveur Json-Server qui s'exécute localement. Ce serveur s'installe comme librairie de développement du projet et se démarre avec un script (voir package.json). C'est un serveur qui permet d'avoir rapidement un API REST pour le développement et qui fournit des données provenant d'un fichier json (voir dossier backend).

Remise

Aucune remise, mais conserver le lab03 il sera d'une grande utilité pour le TP02!

L'asynchrone dans les tests Vitest

Pourquoi l'asynchrone existe dans les tests?

Quand vous interagissez avec un composant Vue dans un test (cliquer un bouton, modifier un champ), Vue ne met pas à jour le DOM instantanément. Il attend le prochain cycle de rendu (appelé "tick") avant d'appliquer les changements.

Si votre assertion s'exécute avant ce cycle, le DOM n'est pas encore mis à jour — le test échoue, même si le composant fonctionne parfaitement.

 //  INCORRECT — l'assertion s'exécute trop tôt
wrapper.find("button").trigger("click");
expect(wrapper.text()).toContain("1"); // Le DOM n'est pas encore mis à jour !

 //  CORRECT — on attend la mise à jour avant de vérifier
await wrapper.find("button").trigger("click");
expect(wrapper.text()).toContain("1"); // Le DOM est à jour

La règle de base

Toujours await les interactions avec le DOM dans un test Vue.

Cela implique deux choses :

  1. Ajouter await devant trigger(), setValue(), setProps(), etc.
  2. Déclarer le test avec async pour pouvoir utiliser await à l'intérieur.
// La signature d'un test asynchrone
it("description du test", async () => {
    // ... le test avec des await
});

Les méthodes qui nécessitent await

trigger() — simuler un événement

it("incrémente le compteur au clic", async () => {
    const wrapper = mount(Counter);

    await wrapper.find("button").trigger("click");

    expect(wrapper.find('[data-testid="count"]').text()).toBe("1");
});

setValue() — modifier la valeur d'un champ de formulaire

it("filtre la liste selon la saisie", async () => {
    const wrapper = mount(SearchBox);

    await wrapper.find("input").setValue("Alice");

    expect(wrapper.text()).toContain("Alice");
    expect(wrapper.text()).not.toContain("Bob");
});

setProps() — changer les props après le montage

it("met à jour l'affichage quand la prop change", async () => {
    const wrapper = mount(WelcomeMessage, {
        props: { name: "Alice" },
    });

    expect(wrapper.text()).toContain("Alice");

    await wrapper.setProps({ name: "Bob" });

    expect(wrapper.text()).toContain("Bob");
});

nextTick() — attendre manuellement un cycle de rendu

Parfois, une action interne au composant déclenche une mise à jour sans que vous interagissiez directement. Dans ce cas, importez nextTick de Vue :

import { nextTick } from "vue";

it("met à jour après une action interne", async () => {
    const wrapper = mount(MonComposant);

    // Déclencher une action interne (ex: via le store)
    wrapper.vm.incrementer();

    // Attendre le prochain cycle de rendu
    await nextTick();

    expect(wrapper.text()).toContain("1");
});

Les erreurs classiques

Oublier async sur le test

 // await dans un test non-async — erreur de syntaxe
it('test incorrect', () => {
  await wrapper.find('button').trigger('click') // SyntaxError
})

 //
it('test correct', async () => {
  await wrapper.find('button').trigger('click')
})

Oublier await devant trigger()

it("test qui échoue pour de mauvaises raisons", async () => {
    const wrapper = mount(Counter);

    wrapper.find("button").trigger("click"); // Pas de await !

    // Le DOM n'est pas encore mis à jour — le test échoue
    expect(wrapper.find('[data-testid="count"]').text()).toBe("1");
});

Ce type d'erreur est particulièrement difficile à déboguer : le test échoue, mais le composant fonctionne bien dans le navigateur. La cause est presque toujours un await manquant.

Plusieurs interactions dans un test

Chaque interaction doit avoir son propre await :

it("ajoute deux éléments à la liste", async () => {
    const wrapper = mount(TodoList);

    await wrapper.find("input").setValue("Tâche 1");
    await wrapper.find('button[type="submit"]').trigger("click");

    await wrapper.find("input").setValue("Tâche 2");
    await wrapper.find('button[type="submit"]').trigger("click");

    expect(wrapper.findAll("li")).toHaveLength(2);
});

Tester des appels API (fetch / axios)

Quand un composant fait un appel réseau, il ne faut pas faire de vrai appel dans les tests — c'est lent, fragile, et dépendant du réseau. On utilise vi.fn() pour simuler la réponse.

Mocker fetch avec vi.fn()

import { describe, it, expect, vi, beforeEach } from "vitest";
import { mount } from "@vue/test-utils";
import UserCard from "./UserCard.vue";

describe("UserCard", () => {
    beforeEach(() => {
        // Remplacer fetch par une fausse fonction
        global.fetch = vi.fn();
    });

    it("affiche le nom de l'utilisateur après chargement", async () => {
        // Simuler une réponse réseau réussie
        global.fetch.mockResolvedValue({
            json: () =>
                Promise.resolve({ name: "Alice", email: "alice@example.com" }),
        });

        const wrapper = mount(UserCard, {
            props: { userId: 1 },
        });

        // Attendre que le composant finisse de charger
        await nextTick();
        await nextTick(); // parfois deux ticks sont nécessaires (fetch + rendu)

        expect(wrapper.text()).toContain("Alice");
    });

    it("affiche un message d'erreur si le chargement échoue", async () => {
        // Simuler une erreur réseau
        global.fetch.mockRejectedValue(new Error("Réseau indisponible"));

        const wrapper = mount(UserCard, {
            props: { userId: 1 },
        });

        await nextTick();
        await nextTick();

        expect(wrapper.text()).toContain("Erreur");
    });
});

Mocker un module entier avec vi.mock()

Si votre composant importe un service (api.ts, userService.ts, etc.), vous pouvez remplacer tout le module :

import { vi } from "vitest";

// Ce mock remplace le fichier entier pour tous les tests du fichier
vi.mock("@/services/userService", () => ({
    getUser: vi
        .fn()
        .mockResolvedValue({ name: "Alice", email: "alice@example.com" }),
}));

import { getUser } from "@/services/userService";

it("appelle le service et affiche le résultat", async () => {
    const wrapper = mount(UserCard, { props: { userId: 1 } });

    await nextTick();

    expect(getUser).toHaveBeenCalledWith(1);
    expect(wrapper.text()).toContain("Alice");
});

Tester les emits asynchrones

Quand un composant émet un événement à la suite d'une interaction, vérifiez avec wrapper.emitted() après l'await :

it('émet l\'événement "submit" avec les bonnes données', async () => {
    const wrapper = mount(ContactForm);

    await wrapper.find('input[name="email"]').setValue("test@example.com");
    await wrapper.find("form").trigger("submit");

    // emitted() retourne un objet : { nomEvenement: [[args1], [args2], ...] }
    expect(wrapper.emitted("submit")).toBeTruthy();
    expect(wrapper.emitted("submit")[0]).toEqual([
        { email: "test@example.com" },
    ]);
});

Rappel sur la structure de emitted() :

  • wrapper.emitted('submit') → tableau de tous les appels à cet événement
  • wrapper.emitted('submit')[0] → arguments du premier appel (tableau)
  • wrapper.emitted('submit')[0][0] → premier argument du premier appel

Résumé visuel

Situation Solution
Cliquer un bouton await trigger('click')
Modifier un input await setValue('valeur')
Changer une prop await setProps({ prop: valeur })
Action interne au composant await nextTick()
Appel fetch/axios vi.fn().mockResolvedValue(...) + await nextTick()
Module entier à mocker vi.mock('@/services/...')
Tester un emit wrapper.emitted('nom')[0] après le await

Récapitulatif - liste de vérification

Avant de soumettre vos tests, vérifiez :