Jest le framework de test

Introduction

Jest est un framework JavaScript conçu pour offrir aux développeurs un moyen simple et rapide de configurer des tests. Maintenu par Meta, la flexibilité du framework est ce qui le rend populaire. En effet, ce framework s'adapte facilement à divers autres frameworks tels que:

  • ReactJS
  • VueJS
  • Angular
  • NodeJS
  • SvelteJS
  • et bien d'autres.

Dans cet article, nous allons tester une application ReactJS en utilisant Jest. Consultez la documentation pour en savoir plus sur Jest. Je vous invite également à cloner le projet situé dans la barre latérale pour suivre ce tutoriel.

Tester une application avec Jest

Pour tester l'application, nous devons la lancer en utilisant la commande: npm run dev. Vous devriez voir l'application se lancer comme indiqué dans l'image suivante:

Lancement de l'application Goblin

L'application vous permet de:

  • Ajouter un article à votre panier
  • Supprimer un article de votre panier
  • Payer pour vos articles via un formulaire

La première chose à faire est d'installer l'extension Jest dans notre Visual Studio Code: Jest Plugin. Vous devriez voir l'extension disponible dans la barre latérale gauche. En cliquant dessus, vous verrez tous vos tests:

Extension Jest

Si l'un de vos tests échoue, vous devriez voir le test apparaître en rouge, et une explication apparaîtra lorsque vous survolerez avec votre souris.

Testons !

Tout d'abord, examinons le contenu de notre fichier src/index.tsx :

 import React from "react"
import ReactDOM from "react-dom"
import { BrowserRouter } from "react-router-dom"
import { App } from "./App"
import { CartProvider } from "./CartContext"
import "./index.css"

ReactDOM.render(
  <React.StrictMode>
    <CartProvider>
      <BrowserRouter>
        <App />
      </BrowserRouter>
    </CartProvider>
  </React.StrictMode>,
  document.getElementById("root")
)

ProductsProvider récupère les informations à partir du backend et les contient. CartProvider gère l'état de votre panier d'achat et le stocke dans votre localstorage. BrowserRouter facilite le routage. Cette présentation vise à présenter la structure de base de notre application. Nous allons maintenant nous plonger plus en détail dans notre composant App.tsx, qui est notre composant fonctionnel d'entrée.

 import React from "react"
import { Switch, Route } from "react-router-dom"
import { Checkout } from "./Checkout"
import { Home } from "./Home"
import { Cart } from "./Cart"
import { Header } from "./shared/Header"
import { OrderSummary } from "./OrderSummary"

export const App = () => {
  return (
    <>
      <Header />
      <div className="container">
        <Switch>
          <Route exact path="/">
            <Home />
          </Route>
          <Route path="/checkout">
            <Checkout />
          </Route>
          <Route path="/cart">
            <Cart />
          </Route>
          <Route path="/order">
            <OrderSummary />
          </Route>
          <Route>Page not found</Route>
        </Switch>
      </div>
    </>
  )
}

Le composant App.tsx n'accepte pas de props et ne contient aucune logique métier. Sa seule responsabilité est d'afficher la mise en page de l'application. Nous allons maintenant créer un test pour cette classe qui vérifiera si notre mise en page est correctement affichée. Pour cela, nous allons créer le fichier src/App.spec.tsx :

 import React from "react"
import { App } from "./App"
import { render } from "@testing-library/react"

describe("App", () => {
  it("renders successfully", () => {
    const { container } = render(<App/>)
    expect(container.innerHTML).toMatch("Goblin Store")
  })

})

Quelques explications :

  • describe permet de regrouper une série de tests. Souvent, on en utilise plusieurs pour découper précisément les composants d'une classe.
  • it contient un test individuel, identifié par un nom le plus court possible.
  • render est une fonction fournie par @testing-library qui simule la fonction de rendu pour les tests.

Lorsque vous exécuterez ce test, il échouera. Pourquoi ? Notre composant a un enfant Switch, qui permet d'afficher les différentes pages en fonction de notre URL. Il doit être utilisé dans un contexte de routage, mais dans notre test, nous lançons App sans utiliser le router. Pour corriger cela, nous devons ajouter App dans un router de test. Cependant, attention : nous utilisons un DOM simulé provenant de jsdom, ce qui signifie que nous n'utilisons pas le navigateur et donc que certaines fonctionnalités peuvent manquer (comme l'API history). Pour remédier à cela, nous devons installer history :

 npm install --save-dev history

Maintenant nous sommes capable de fixer le test en ajout l'History API :

 import React from "react"
import { App } from "./App"
import { render } from "@testing-library/react"
//Router
import { createMemoryHistory } from "history"
import { Router } from "react-router-dom"

describe("App", () => {
  it("renders successfully", () => {
    //Create history
    const history = createMemoryHistory()
    const { container } = render(
      <Router history={history}>
        <App/>
    </Router>    
    )
    expect(container.innerHTML).toMatch("Goblin Store")
  })

  it("renders Home component on root route", () => {
    //Create history
    const history = createMemoryHistory()
    history.push("/")
    const { container } = render(
      <Router history={history}>
        <App/>
    </Router>    
    )
    expect(container.innerHTML).toMatch("Home")
  })
})
  • Nous créons un objet history et nous passons le composant Router.

  • Nous appelons la méthode de rendering pour en retirer l'instance du conteneur. Ce conteneur représente le conteneur contenant le DOM.

  • Nous espérons que, le conteneur en question, contiennent le texte "Goblin Store". Notre layout affiche toujours le composant header qui contient ce texte.

Mockons les dépendances.

Lors de l'exécution de nos tests, nous avons vu que le dernier test ne fonctionnait pas. Le souci est que notre composant Home dépend des données de ProductsProvider afin d'afficher la liste des produits (et bien d'autres dépendances).

Une approche serait de mocker les dépendances de ce composant et à un autre test la tache de tester les fonctionnalités en profondeur (on parle d'isolation). Mockons avec jest.mock qui nous permet de mocker la plupart des modules.

 import React from "react"
import { App } from "./App"
import { render } from "@testing-library/react"
//Router
import { createMemoryHistory } from "history"
import { Router } from "react-router-dom"
//Mock
jest.mock("./Home", () => ({Home: () => <div>Home</div>}))

Nous avons appris comment mocker un module. Notre test maintenant passe avec succès.

Testons notre routing.

Alors cela peut paraître dérisoire, mais c'est un test que bien souvent nous oublions. Très souvent, la stratégie de test consiste à :

  1. Tester les fonctionnalités
  2. Tester la logique

Le routing peut faire partie des erreurs futures que l'on peut rencontrer. Dans notre cas, notre application utilise le composant Switch afin d'afficher les routes :

 type RenderWithRouter = (
  renderComponent: () => React.ReactNode,
  route?: string
) => RenderResult & { history: MemoryHistory }

declare global {
  namespace NodeJS {
    interface Global {
      renderWithRouter: RenderWithRouter
    }
  }

  namespace globalThis {
    const renderWithRouter: RenderWithRouter
  }
}

global.renderWithRouter = (renderComponent, route) => {
  const history = createMemoryHistory()
  if (route) {
    history.push(route)
  }
  return {
    ...render(
      <Router history={history}>{renderComponent()}</Router>
    ),
    history
  }
}
  • Nous créons un objet history et nous ajoutons la route via un argument.
  • Nous appelons la méthode Render de la librairie testing-library/react et nous retournons tous les champs que nous avons de l'objet history.
  • La méthode est dite globale pour le namespace.
  • Nous définissons un type comprenant notre fonction, cela nous permettra de typer.
  • Nous déclarons ce namespace comme état global, cela nous permettra d'informer NodeJs de l'existence du namespace lors des imports.

Importons notre helper et définissons nos mocks :

 import React from "react"
import { App } from "./App"
import { render } from "@testing-library/react"
//Helper
import "./testHelpers"
//Router
import { createMemoryHistory } from "history"
import { Router } from "react-router-dom"
//Mock
jest.mock("./Home", () => ({Home: () => <div>Home</div>}))
jest.mock("./Cart", () => ({Cart: () => <div>Cart</div>}))
jest.mock("./Checkout", () => ({Checkout: () => <div>Checkout</div>}))
jest.mock("./OrderSummary", () => ({OrderSummary: () => <div>Order Summary</div>}))

Nous allons maintenant implémenter le test pour chacune de ces routes :

 it("renders checkout page on '/cart'", () => {
    const {
        container
    } = renderWithRouter(
        () => < App / > ,
        "/cart"
    )
    expect(container.innerHTML).toMatch("Cart")
})
it("renders checkout page on '/Checkout'", () => {
    const {
        container
    } = renderWithRouter(
        () => < App / > ,
        "/checkout"
    )
    expect(container.innerHTML).toMatch("Checkout")
})
it("renders checkout page on '/order'", () => {
const {
    container
} = renderWithRouter(
    () => < App / > ,
    "/order"
)
expect(container.innerHTML).toMatch("Order Summary")
})
})

Notre App.tsx est testé, avant d'aller plus loin nous pouvons tester les composants partagés :

Header.spec.tsx :

 import React from "react"
import { Header } from "./Header"
import { fireEvent } from "@testing-library/react"

jest.mock("./CartWidget", () => ({
  CartWidget: () => <div>Cart widget</div>
}))

describe("Header", () => {
  it("renders correctly", () => {
    const { container } = renderWithRouter(() => <Header />)
    expect(container.innerHTML).toMatch("Goblin Store")
    expect(container.innerHTML).toMatch("Cart widget")
  })

  it("navigates to / on header title click", () => {
    const { getByText, history } = renderWithRouter(() => <Header />)
    fireEvent.click(getByText("Goblin Store"))
    expect(history.location.pathname).toEqual("/")
  })
})

Quand nous cliquons sur l'élément qui a le texte "Goblin Store", nous espérons revenir sur le chemin root.

Loader.spec.tsx :

 import React from "react"
import { Loader } from "./Loader"
import { render } from "@testing-library/react"

describe("Loader", () => {
  it("renders correctly", () => {
    const { container } = render(<Loader />)
    expect(container.innerHTML).toMatch("Loading")
  })
})

CartWidget.spec.tsx :

 import React from "react"
import { CartWidget } from "./CartWidget"
import { fireEvent } from "@testing-library/react"

describe("CartWidget", () => {
  it("shows the amount of products in the cart", () => {
    const stubCartHook = () => ({
      products: [
        {
          name: "Product foo",
          price: 0,
          image: "image.png"
        }
      ],
    })

    const { container } = renderWithRouter(() => (
      <CartWidget useCartHook={stubCartHook} />
    ))

    expect(container.innerHTML).toMatch("1")
  })

  it("navigates to cart summary page on click", () => {
    const { getByRole, history } = renderWithRouter(() => (
      <CartWidget />
    ))

    fireEvent.click(getByRole("link"))
    
    expect(history.location.pathname).toEqual("/cart")
  })
})

Ce composant nous permet d'afficher le nombre de produits de notre panier d'achats. Le composant est représenté comme un lien. Si l'on clique dessus, nous sommes redirigés vers le récapitulatif de notre panier.

Dans un premier temps, nous allons créer un test permettant de vérifier que le composant retourne bien le nombre d'articles dans le panier.

Dans un deuxième temps, nous allons nous assurer de la bonne redirection lors d'un clic.

getByRole : permet d'utiliser l'attribut aria-role pour chercher un élément. Ici, nous cherchons l'aria-role de l'élément. Nous pouvons alors tester l'interaction du clic pour tester notre redirection.

Nous passons les données (mockées) via la prop du composant pour tester la réaction.

Bien, nous avons testé tous nos Shared components !

Home page

Ouvrons le dossier Home pour voir les 3 fichiers :

index.tsx : permet de donner la visibilité du module. Il exporte le composant Home et non Product.tsx.

Home.tsx : reçoit les données via les props, que nous ne mockerons pas (j'ai facilité la chose). Notre composant se divise en 3 phases : le loader est lancé quand nous chargeons les données, si nous avons une erreur nous affichons le message d'erreur, nous affichons la liste si nous avons les données.

Nous pouvons écrire le test :

 jest.mock("./ProductCard", () => ({
  ProductCard: ({ datum }: ProductCardProps) => {
    const { name, price, image } = datum
    return (
      <div>
        {name} {price} {image}
      </div>
    )
  }
}))

describe("Home", () => {
  describe("while loading", () => {
    it("renders loader", () => {
      const mockUseProducts = () => ({
        categories: [],
        isLoading: true,
        error: false
      })

      const { container } = render(
        <Home useProductsHook={mockUseProducts} />
      )

      expect(container.innerHTML).toMatch("Loading")
    })
  })

  describe("with data", () => {
    const category: Category = {
      name: "Category Foo",
      items: [
        {
          name: "Product foo",
          price: 55,
          image: "/test.jpg"
        }
      ]
    }

    it("renders categories with products", () => {
      const mockUseProducts = () => ({
        categories: [category],
        isLoading: false,
        error: false
      })

      const { container } = render(
        <Home useProductsHook={mockUseProducts} />
      )

      expect(container.innerHTML).toMatch("Category Foo")
      expect(container.innerHTML).toMatch(
        "Product foo 55 /test.jpg"
      )
    })
  })

  describe("with error", () => {
    it("renders error message", () => {
      const mockUseProducts = () => ({
        categories: [],
        isLoading: false,
        error: true
      })

      const { container } = render(
        <Home useProductsHook={mockUseProducts} />
      )

      expect(container.innerHTML).toMatch("Error")
    })
  })
})

Nous avons mocké le composant ProductCard pour éviter d'ajouter une dépendance au test. Nous avons écrit les tests suivants :

  • Nous utilisons des mocks concernant les produits pour afficher différents états.
  • Si nous n'avons pas de données et que isLoading est vrai, alors le loader doit s'afficher.
  • Si nous n'avons pas de données mais que nous avons une erreur, alors nous espérons une erreur.
  • Si nous avons des données, alors nous affichons les produits.

Dans ProductCard.tsx :

  • L'image doit avoir le bon alt et src.
  • Le prix.
  • Le nom du produit.

Nous devons ajouter le bouton "+ Add to cart" qui peut avoir deux états :

  • Désactivé si le produit est ajouté avec le texte "Added to cart".
  • Activé s'il n'est pas ajouté avec le texte "Add to cart".

Nous allons créer le test correspondant.

 import React from "react"
import { render, fireEvent } from "@testing-library/react"
import { ProductCard } from "./ProductCard"
import { Product } from "../shared/types"

describe("ProductCard", () => {
  const product: Product = {
    name: "Product foo",
    price: 55,
    image: "/test.jpg"
  }

  it("renders correctly", () => {
    const { container, getByRole } = render(
      <ProductCard datum={product} />
    )

    expect(container.innerHTML).toMatch("Product foo")
    expect(container.innerHTML).toMatch("55 Zm")
    expect(getByRole("img")).toHaveAttribute(
      "src",
      "/test.jpg"
    )
  })

  describe("when product is in the cart", () => {
    it("the 'Add to cart' button is disabled", () => {
      const mockUseCartHook = () => ({
        addToCart: () => {},
        products: [product]
      })

      const { getByRole } = render(
        <ProductCard
          datum={product}
          useCartHook={mockUseCartHook as any}
        />
      )
      expect(getByRole("button")).toBeDisabled()
    })
  })

  describe("when product is not in the cart", () => {
    describe("on 'Add to cart' click", () => {
      it("calls 'addToCart' function", () => {
        const addToCart = jest.fn()
        const mockUseCartHook = () => ({
          addToCart,
          products: []
        })

        const { getByText } = render(
          <ProductCard
            datum={product}
            useCartHook={mockUseCartHook}
          />
        )

        fireEvent.click(getByText("Add to cart"))
        expect(addToCart).toHaveBeenCalledWith(product)
      })
    })
  })
})
  • Nous avons mocké notre produit.

  • Nous avons testé que les informations sont correctement ajoutées.

  • Nous avons testé les états des boutons.

jest.fn() permet de mocker la fonction. Ici, nous voulons nous assurer de l'appel de la fonction.

La logique que nous avons mise en place pour cette page est très souvent la même pour les autres. Je vous laisse donc ceci en exercice et vous permets de vérifier avec le git vos résultats.

React Hooks

Pour tester les hooks react nous devons installer :

 npm install --save-dev @testing-library/react-hooks

Les hooks dans le composant Home, nous permettent de charger les données via le hook useProducts :

  • Il récupère les données au montage.
  • Pendant que les données sont chargées, on met isLoading à true.
  • Si on a une erreur, on met isLoading à false.
  • Quand les données sont retournées, on met isLoading à false et on stocke les données dans le state.

Voici la stratégie de test :

 import { renderHook } from "@testing-library/react-hooks"
import { useProducts } from "./useProducts"
describe("useProducts", () => {
it.todo("fetches products on mount")
describe("while waiting API response", () => {
it.todo("returns correct loading state data")
})
describe("with error response", () => {
it.todo("returns error state data")
})
describe("with successful response", () => {
it.todo("returns successful state data")
})
})

S'assurer que les données sont chargées au moment du mount

   it("fetches products on mount", async () => {
    const mockApiGetProducts = jest.fn()

    await act(async () => {
      renderHook(() => useProducts(mockApiGetProducts))
    })

    expect(mockApiGetProducts).toHaveBeenCalled()
  })

renderHook : c'est une méthode qui permet d'utiliser notre hook et de mocker les résultats à travers mockApiGetProducts.

Test de l'état quand les données sont retournées.

   describe("while waiting API response", () => {
    it("returns correct loading state data", () => {
      const mockApiGetProducts = jest.fn(
        () => new Promise(() => {})
      )

      const { result } = renderHook(() =>
        useProducts(mockApiGetProducts)
      )
      expect(result.current.isLoading).toEqual(true)
      expect(result.current.error).toEqual(false)
      expect(result.current.categories).toEqual([])
    })
  })

Ici, nous mockons l'appel au backend afin de nous assurer qu'en cas de succès, les données soient correctement moquées. Cela va nous permettre pour le test suivant de tester le cas d'erreur.

Test de l'état quand l'appel crée une erreur.

   describe("with error response", () => {
    it("returns error state data", async () => {
      const mockApiGetProducts = jest.fn(
        () =>
          new Promise((resolve, reject) => {
            reject("Error")
          })
      )

      const { result, waitForNextUpdate } = renderHook(() =>
        useProducts(mockApiGetProducts)
      )

      await act(() => waitForNextUpdate())

      expect(result.current.isLoading).toEqual(false)
      expect(result.current.error).toEqual("Error")
      expect(result.current.categories).toEqual([])
    })
  })

Ici en simulant l'échec d'une promesse on arrive facilement à générer une erreur.

Retourne les résultats mockées

   describe("with successful response", () => {
    it("returns successful state data", async () => {
      const mockApiGetProducts = jest.fn(
        () =>
          new Promise((resolve, reject) => {
            resolve({
              categories: [{ name: "Category", items: [] }]
            })
          })
      )

      const { result, waitForNextUpdate } = renderHook(() =>
        useProducts(mockApiGetProducts)
      )

      await act(() => waitForNextUpdate())

      expect(result.current.isLoading).toEqual(false)
      expect(result.current.error).toEqual(false)
      expect(result.current.categories).toEqual([
        {
          name: "Category",
          items: []
        }
      ])
    })
  })

L'utilisation de await act(() => waitForNextUpdate()) permet d'attendre le prochain update avant d'exécuter notre test. Cela est nécessaire car les données sont envoyées via une fonction asynchrone et il est donc important d'attendre que le processus soit terminé.

Ensuite, nous affichons les résultats à travers la fonction d'affichage.

Nous avons couvert plusieurs concepts dans ce tutoriel. Je vous invite à terminer le testing de l'application et à comparer vos résultats ensuite !

Developpeur et architecte passionné, qui souhaite partagé son univers et ses découvertes afin de rendre les choses plus simple pour chacun