Proves

Tenir codi llest per a producció requereix fer proves. Com que no podem tenir milers de testers, sorgeix la necessitat d'automatitzar-les. Un procés que va de la mà de les pràctiques de desenvolupament àgil i el CD (Continuous delivery).

La piràmide de les proves

La piràmide de les proves és una metàfora visual que descriu les capes de les proves:

  1. Proves unitàries: validació dels blocs més atòmics del software, les funcions.
  2. Proves d'integració: validació que diferents peces del software funcionen juntes. Per exemple, comunicació amb la base de dades, la xarxa o el sistem d'arxius.
  3. Proves end to end (E2E): validació des del punt de vista de l'usuari, tractant el software com una caixa negra.

En el món real, aquesta categorització no és estricta, hi ha proves que poden estar al mig d'aquestes capes.

.UnitàriesIntegracióEnd-to-end
Objectiu principalfuncions individualsintegració de funcionsfuncionalitat d'usuari
QuantitatNombrosesUna mica freqüentEscassa
VelocitatMolt altaMitjanaLenta
Freqüència d'execucióAlta, quan es desenvolupen una funcióRegular, quan es desenvolupa una funcionalitatQuan s'acaba una funcionalitat
FeedbackEntrada i sortida per a la funcióComportament problemàticFuncionalitat incorrecta
CostBaix: petita, fàcil d'actualitzar, executar i entendreModeratCostós
Coneixement de l'aplicacióAcoblat al codiCodi, bases de dades, xarxa, arxiusInconscient
BeneficisFeedback ràpid durant el desenvolupament, evitar regressions, documentacióÚs de llibreries de tercers, comprova efectes secundarisFuncionament correcte de l'usuari

Tècniques

Qualitat de les proves

Aquestes són algunes pistes per a aconseguir proves que siguin mantenibles:

  1. Les proves són tan importants com el codi.
  2. Prova només una funcionalitat per prova. Que sigui curta farà que sigui més clara.
  3. Escriure el nostre codi amb funcions petites ens ajudarà a fer proves més granulars.
  4. Estructura les proves amb "arrange, act, assert" o bé "given, when, then".
  5. La claredat és més important que no repetir-se.

Arrange, act i assert

L'estructura recomanada d'una prova és una seqüència de tres passos:

  1. Arrange: prepara les entrades i els objectius de la prova.
  2. Act: invoca el comportament, cridant una funció, interactuant amb un API, una pàgina web, etc.
  3. Assert: comprova que el resultat és l'esperat.

Test Doubles

Els test doubles són una tècnica que permet introduir una versió simplificada (o falsa) de les dades o funcions reals que permeten reduir la complexitat i facilitar les proves. Aquests objectes falsos poden classificar-se, de menys a més específic, en:

  • Dummy: objectes que es passen però no s'utilitzen.
  • Fake: implementacions que funcionen, però no serveixen per a producció.
  • Stubs: proporcionen respostes enllaunades a certes preguntes, no responent a res que no hi hagi a la prova.
  • Spies: són stubs que guarden informació de com van ser cridats.
  • Mocks: estan programats amb expectatives, i per tant poden comprovar si la crida no s'espera, o falta alguna crida, llençant excepcions. Per tant, abans d'utilitzar-los cal indicar quines són les expectatives. Molts desenvolupadors utilitzen "mocks" per parlar de forma general dels dobles.

Millors pràctiques:

  • Si es pot, evitar els dobles i treballar amb implementacions reals.
  • De vegades no és possible, per diferents raons: volem provar certs escenaris (d'èxit o error) però no els podem generar, o bé és molt lent, o no volem treballar amb les dades reals. En general, escenaris on el codi té side effects.
  • Llavors ens cal simular comportaments de certa dependència o servei extern.
  • Entre els possibles dobles, es convenient seleccionar el menys específic (millor un dummy que un mock).
  • Un cop seleccionat el doble, millor implementar-lo que utilitzar un framework.

Test-driven development

El TDD (desenvolupament guiat per proves) és una pràctica de desenvolupament que utilitza les proves unitàries per a escriure codi, i ho fa seguint el següent procediment:

  1. Afegir un nou test
  2. Executar tots els tests. La nova prova ha de fallar.
  3. Escriure el codi més senzill que permeti passar el test.
  4. Totes les proves han de funcionar novament.
  5. Fer refactoring quan calgui, utilitzant els tests per assegurar-se que la funcionalitat es preserva.

Eines

Jest i Vitest

El framework de proves JS més conegut és Jest. L'alternativa més interessant és Vitest, completament compatible amb Jest, més ràpid i que funciona amb mòduls.

Les proves JS s'emparellen amb el codi que es vol testar. Per exemple, feature.js tindria les proves a feature.test.js. Dins d'aquest arxiu s'han de poder trobar crides del tipus:

test('descripció de la prova', () => { // codi de la prova (arrange, act, assert) });

Alternativament, també s'utilitza la funció it (sinònim) en lloc de test. Podem utilitzar les següents funcions per a actuar abans i després de les proves:

beforeAll(() => { // s'executa abans de totes les proves }); beforeEach(() => { // s'executa abans de cada prova }); afterEach(() => { // s'executa després de cada prova }); afterAll(() => { // s'executa després de totes les proves });

Arrange

L'arrange ha de preparar les entrades i els objectius de la prova. Això pot incloure la selecció dels nodes del DOM, la creació d'objectes, la configuració de l'estat inicial, la creació de mocks, etc.

Per a fer la selecció dels nodes, podem utilitzar document.querySelector o document.querySelectorAll. Però aquesta no és la millor forma, ja que no és el que faria un usuari (veure la Testing Library).

Act

Per al pas act, podríem utilitzar JavaScript directament, però és més recomanable utilitzar una llibreria que simuli l'usuari (veure la Testing Library).

Assert

Per al pas assert, tenim l'estructura expect(receivedValue).matcher(expectedValue). Els matchers més habituals són els següents:

  • toBe: per a comprovar valors primitius.
  • toEqual: per a comprovar objectes o arrays.
  • not.matcher(expectedValue): per a negar qualsevol matcher.
  • toBeNull, toBeUndefined, toBeDefined, toBeTruthy, toBeFalsy: per a comprovar truthiness.
  • toBeGreaterThan, toBeGreaterThanOrEqual, toBeLessThan, toBeLessThanOrEqual: per a números.
  • toMatch(/.../): per a expressions regulars.
  • toContain: per a comprovar si un array o iterable conté un ítem.
  • toThrow: per a comprovar si es llença una excepció. Permet comprovar el text també.

Per exemple, expect(2 + 2).toBe(4) comprova que 2+2 són 4.

La Testing Library afegeix nous matchers a jest.

Testing Library

La Testing Library és un complement a jest o vitest que afegeix noves funcionalitats per a fer proves.

Aquesta llibreria conté:

  • Queries, uns mètodes per trobar elements a una pàgina. getBy, queryBy i findBy.
  • fireEvent i userEvent, per a simular esdeveniments que permeten interactuar amb el DOM.
  • act i waitFor, per a esperar rerenders o actualitzacions d'estat asíncrones.
  • Matchers addicionals per a jest/vitest

A més, és aplicable tan a aplicacions vanilla JS com a React o d'altres frameworks.

Queries

Les queries poden ser de tres tipus, segons els seu comportament:

  • getBy: retorna un element o llença una excepció si no el troba.
  • queryBy: retorna un element o null si no el troba.
  • findBy: retorna una promesa que es resoldrà quan trobi l'element.

També hi ha les queries per a múltiples elements:

  • getAllBy: retorna un array d'elements o llença una excepció si no en troba cap.
  • queryAllBy: retorna un array d'elements o un array buit si no en troba cap.
  • findAllBy: retorna una promesa que es resoldrà quan trobi els elements.

El format get és el més recomanat, ja que si no troba l'element, llença una excepció i atura la prova. Es recomana utilitzar les queries en aquest ordre:

  1. Les queries accessibles a tothom, amb getByRole, getByLabelText, getByPlaceholderText, getByText, getByDisplayValue.
  2. Les queries semàntiques, amb getByAltText, getByTitle.
  3. Els testid, amb getByTestId. Implica l'ús de l'atribut data-testid als elements. És el menys aconsellable, però evita l'ús d'altres atributs, com ara id o class, associats normalment a l'estil.

Exemples de queries:

const button = getByRole('button', { name: /submit/i }); const input = getByLabelText('Username'); const input = getByPlaceholderText('Username'); const text = getByText('Hello, world!'); const input = getByDisplayValue('Hello, world!'); const img = getByAltText('A close-up of a cat'); const element = getByTitle('A description of the element');

Les queries del getByRole() permeten buscar dins d'un rol. Hi ha un nombre de rols establers per l'estàndard WAI-Aria. Alguns elements tenen un valor implícit, i també es poden definir amb l'atribut role d'un element HTML.

Les opcions més comunes d'aquesta funció són:

  • name: relaciona els elements pel seu nom accessible.
  • level: s'utilitza per a les funcions d'encapçalament per especificar nivells (h1, h2, etc.).
  • expanded: per a elements ampliables (true o false).
  • checked: per a la casella de selecció i els elements de ràdio (true o false).
  • pressed: per als elements button que indiquen l'estat de commutació (true o false).
  • selected: per a elements com option que indica l'estat de selecció (true o false).
  • `: inclou elements que estan visualment ocults però encara accessibles.

Alguns examples de queries:

screen.getByRole("button", { name: "Click Me" }); screen.getByRole("heading", { level: 2 }); screen.getByRole("combobox", { expanded: true }); screen.getByRole("button", { name: "Submit" }); screen.getByRole("option", { selected: true });

El name de les opcions es pot derivar de (en ordre de preferència):

  • aria-label
  • aria-labelledby
  • Text intern (per botons, capçaleres, etc.)
  • Atribut alt (imatges)
  • Elements label associats (per a form inputs, etc.)
  • Atribut title (si no es troba cap altre)

fireEvent i userEvent

fireEvent és una funció que permet simular esdeveniments. userEvent és una llibreria que permet simular esdeveniments com ho faria un usuari.

Per exemple, fireEvent.click(button) simula un clic sobre un botó. userEvent.click(button) també simula un clic sobre un botó, però ho fa com ho faria un usuari.

Es recomana utilitzar userEvent sempre que sigui possible, ja que simula millor el comportament d'un usuari.

act i waitFor

act permet esperar fins que s'han produït tots els rerenders associats a les accions sobre el DOM. Moltes funcions de la testing library ja l'utilitzen, com per exemple fireEvent. Però en altres casos, com per exemple quan s'interactua amb un custom hook, cal utilitzar-lo perquè cal que l'estat s'actualitzi abans de tornar a generar un esdeveniment.

waitFor és una funció que permet esperar fins que una condició asíncrona es compleixi. Per exemple, waitFor(() => getByText('text')). Aquesta funció és útil quan es vol esperar fins que un element aparegui a la pàgina, o fins que desaparegui. També és útil per a esperar fins que un element canvii.

La sintaxi és:

await waitFor(() => { // codi que comprova la condició });

Per canviar el temps esperat, es pot passar un objecte amb la propietat timeout:

await waitFor(() => { // codi que comprova la condició }, { timeout: 1000 });

Matchers addicionals

Aquesta llibreria també afegeix matchers addicionals a jest, com ara toBeVisible, toBeDisabled, toBeEmpty, toBeEnabled, toBeInvalid, toBeRequired, toBeValid, toBeVisible, toHaveTextContent, toHaveValue, toHaveAttribute, toHaveClass, toHaveStyle, toHaveFormValues, toBeChecked, toBePartiallyChecked, toHaveFocus, toHaveDescription, toHaveDisplayValue, toHaveDisplayValue, toHaveErrorMessage, toHaveLabel, toHaveLabelText, toHaveRole, toHaveTitle, toHaveAltText, toHaveAriaLabel, toHaveAriaRole, toHaveAriaSelected.

JSDOM

Es tracta de la implementació JavaScript d'un navegador sense interfície i molt més ràpid que els convencionals. Permet implementar proves automatitzades amb poca infraestructura i senzillesa.

La seva configuració es mostra en l'apartat corresponent d'aquest document. Implementa l'objecte window.document i les seves API.

Renderització

Components

Per provar components ho podem fer amb render. Cal cridar-lo abans de fer qualsevol comprovació de l'estat. Per exemple:

render(<Clickdown initialValue={3} />); const titleEl = screen.getByText(/value is 3/i); expect(titleEl).toBeInTheDocument();

També podríem tornar a renderitzar el component simulant que ha canviat una prop. O bé desmuntar un component per a comprovar l'efecte:

const { rerender, unmount } = render(<Clickdown initialValue={3} />); // ... rerender(<Clickdown initialValue={4} />); // then, check the new value... unmount(); // then check the effect of unmounting

Hooks

Per provar els custom hooks podem utilitzar renderHook. Per exemple, si tenim un hook del comptador:

const useCounter = (initialState: number) => { const [counter, setCounter] = useState<number>(initialState); return { value: counter, increment() { setCounter(prev => prev + 1); } }; };

El podríem provar així:

const { result } = renderHook(() => useCounter(0)); act(() => result.current.increment()); expect(result.current.value).toBe(1);

Com es pot veure, s'utilitza act per assegurar-se que el canvi d'estat associat a increment s'ha consolidat. També podem utilitzar waitFor per a esperar un temps un esdeveniment asíncron.

L'objecte retornat per renderHook també conté rerender i unmount, tal com passa amb el render dels components.

Configuració

Vanilla JS

La llibreria més fàcil d'utilitzar és Vitest, compatible amb la sintaxi de Jest i amb ES modules. Necessitarem npm per a fer les proves, i això requereix crear un package.json:

$ npm init -y $ npm install --save-dev vitest jsdom @testing-library/dom @testing-library/jest-dom

Caldria editar l'entrada script del nostre package.json:

"test": "vitest --run --reporter verbose"

Si necessitem provar el DOM, caldria afegir l'environment jsdom a la capçalera de qualsevol arxiu de proves. La sintaxi de Vitest és la de Jest:

// @jest-environment jsdom

Podem fer npm test per a executar els tests.

Per a fer proves DOM amb un script utilitzant Vitest, l'arrange podria ser:

const initialHtml = fs.readFileSync("./index.html", "utf-8"); document.body.innerHTML = initialHtml; vi.resetModules(); // recarrega mòduls await import('./script.js'); // aplica un script que estaria a l'HTML

També és interessant importar això si volem afegir els custom matchers de jest a les nostres proves:

import '@testing-library/jest-dom/vitest';

React

Utilitzarem vitest per a les proves React.

Caldria instal.lar vitest, @testing-library/react i @testing-library/jest-dom. A més, si volem afegir matchers addicionals, podem fer-ho important-los des de vite.config.js:

import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', setupFiles: './src/setup_tests.js', }, });

Llavors, setup_tests.js podria ser:

import { afterEach } from 'vitest'; import { cleanup } from '@testing-library/react'; import '@testing-library/jest-dom/vitest'; afterEach(() => { cleanup(); // clears the jsdom after each test });

Podem canviar l'entrada test del package.json perquè mostri els detalls dels tests:

"test": "vitest --run --reporter verbose",

Podem fer npm test per a executar els tests.

Referències