Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Proves

Tenir codi llest per a producció requereix fer proves. Com que no les podem repetir totes a mà cada vegada que canviem alguna cosa, sorgeix la necessitat d’automatitzar-les. Un procés que va de la mà de les pràctiques de desenvolupament àgil i el lliurament continu.

Aquesta pàgina recull els conceptes que no depenen del llenguatge, amb exemples en Java per concretar-los. Les eines es tracten a les pàgines de cada tecnologia: JUnit per a Java, Vitest i Testing Library per a JavaScript.

Les seccions responen, per ordre, aquestes preguntes: quin abast té una prova, amb quin objectiu l’executes, com s’escriu, d’on surt el criteri que decideix què és correcte, si la suite serveix de res, i quan i qui l’executa. L’última tracta què canvia amb els assistents de codi.

La piràmide de les proves

La piràmide de les proves reparteix la suite en tres capes segons què abasta cada prova, de les més nombroses i barates a les més escasses i cares:

  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 sistema 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 desenvolupa 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

Cada nivell atrapa una classe de fallada diferent, i el criteri per repartir-hi l’esforç es desenvolupa a Nivells de test.

Tipus de prova segons l’objectiu

La piràmide classifica les proves per abast: quina part del sistema toquen. Hi ha un segon eix independent, el de per què les executes, i és el que dona nom a la major part del vocabulari que sentiràs. Una mateixa prova pot ser alhora unitària per abast i de regressió per objectiu.

Proves de regressió

Una regressió és un comportament que abans funcionava i ara no. És la fallada més cara del desenvolupament, perquè ningú la busca: el canvi que la provoca sol estar en una altra part del codi i la persona que el fa no té cap motiu per sospitar.

Una prova de regressió és qualsevol prova que es conserva i es torna a executar per detectar-ho. Això vol dir que no és un tipus de prova diferent de les altres, sinó el paper que fa tota la suite a partir del moment en què la puges al repositori: la vas escriure per verificar una funcionalitat nova i, a partir de l’endemà, la seva feina és avisar-te si algú la trenca.

La pràctica que se’n deriva és senzilla i val la pena convertir-la en costum: cada vegada que arregles un bug, escriu primer una prova que el reprodueixi. Ha de fallar abans de la correcció i passar després. Guanyes dues coses: la garantia que has arreglat el que creies, i la garantia que aquell bug concret no tornarà mai sense que la suite ho digui.

Perquè aquest paper funcioni, la suite s’ha d’executar sencera i automàticament, no quan algú se’n recorda. És la feina de la integració contínua.

Proves d’acceptació

Una prova d’acceptació respon una pregunta diferent de la resta: no si el codi fa el que el programador volia, sinó si el sistema fa el que es va acordar amb qui el va demanar. Per això el criteri no el fixa l’equip de desenvolupament tot sol, i s’escriu amb el vocabulari del domini (“una comanda ja pagada no es pot cancel·lar”), no amb el de la implementació.

Sovint coincideixen amb el nivell end-to-end de la piràmide, però no és el mateix: l’abast diu per on passa la prova, i l’acceptació diu qui n’ha decidit el resultat esperat. Quan aquests criteris s’acorden abans de programar i es fan executables, tenim BDD i ATDD.

Són també la porta d’entrada a la prova d’acceptació d’usuari (UAT), la validació manual que fa el client abans de donar per bona una entrega.

Altres objectius habituals

La regressió i l’acceptació són els dos objectius que més vocabulari generen, però l’eix continua amb uns quants noms més, que es distingeixen igualment per la pregunta que responen. Cap d’ells no és una categoria a part de la piràmide: una prova de seguretat pot ser unitària o end-to-end, i continua sent una prova de seguretat.

TipusQuè pregunta
Proves de fum (smoke)Val la pena provar res més? Un subconjunt mínim i ràpid que comprova que l’aplicació arrenca i respon. S’executa just després de desplegar
Proves de caracteritzacióQuè fa realment aquest codi heretat, abans que hi posi les mans?
Proves de rendiment i càrregaAguanta el volum esperat i respon prou ràpid? No verifiquen correcció, sinó límits
Proves de seguretatEs comporta bé davant d’entrades malicioses, no només davant de les previstes?
Proves de contracteLes dues bandes d’una frontera continuen entenent-se? La frontera pot ser una interfície i les seves implementacions, o dos serveis que es despleguen per separat, i llavors es tracten a Fronteres de servei

Com s’escriu una prova

Les proves són tan importants com el codi: es llegeixen, es mantenen i envelleixen igual, i per tant se’ls apliquen els mateixos criteris de qualitat. D’aquí surten les pistes per fer-les mantenibles:

  • Prova només una funcionalitat per prova. Que sigui curta farà que sigui més clara.
  • Escriure el codi amb funcions petites ajuda a fer proves més granulars.
  • La claredat és més important que no repetir-se.

Que una prova sigui fàcil o difícil d’escriure diu molt del codi que prova. Ho tractem a Disseny per a la testabilitat i a Llegir els senyals de les proves.

Arrange, act i assert

L’estructura recomanada per a qualsevol prova, sigui del nivell que sigui, é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.

També es coneix com a given, when, then, que és el mateix patró amb el vocabulari del BDD. Els tres blocs es distingeixen visualment amb una línia en blanc:

@Test
void withdraw_withSufficientBalance_leavesRemainingBalance() {
    Account account = new Account(100);   // arrange
    account.withdraw(30);                 // act
    assertEquals(70, account.balance());  // assert
}

Una asserció (assertion) és la comprovació que fa fallar la prova quan la condició no es compleix. Una prova pot acabar de tres maneres: passa, falla perquè una asserció no s’ha complert, o dona error perquè ha saltat una excepció que ningú esperava.

Quins casos provar

Cap suite no pot provar totes les entrades possibles, així que provar és triar. Els casos d’una funcionalitat es reparteixen en tres grups:

  • Camí feliç (happy path): l’ús previst, amb dades vàlides i sense res que vagi malament. Retirar 30 d’un compte amb 100.
  • Casos límit (edge cases): les entrades a la vora d’un canvi de comportament, que és on s’amaguen la major part dels errors. Retirar exactament 100, retirar 0, retirar el valor màxim d’un int.
  • Casos d’error: les entrades que el codi ha de rebutjar, i la manera com les rebutja. Retirar 150 d’un compte amb 100, o una quantitat negativa.

El camí feliç és el que tothom escriu i el que menys informació dona, perquè és el cas que qui programava ja tenia al cap. Els altres dos són els que troben els bugs.

Per trobar els límits sense endevinar-los, es parteix el domini de cada entrada en classes que haurien de comportar-se igual (particions d’equivalència) i es prova un valor de cada classe més els que hi ha just a banda i banda de cada frontera (anàlisi de valors límit). Per a withdraw, les classes són quantitat negativa, zero, entre zero i el saldo, i per sobre del saldo; les fronteres són el zero i el saldo exacte.

Com anomenar una prova

El nom d’una prova és el que llegeixes quan falla al pipeline, sovint sense el codi al davant. Compara aquests dos:

testWithdraw()
withdraw_withInsufficientBalance_throwsException()

El primer no diu res. El segon segueix el patró unitat, condició, resultat esperat, i llegit en veu alta ja és una frase: “withdraw, with insufficient balance, throws exception”. Quan la prova falla, el nom sol ser suficient per saber què s’ha trencat.

Quan el framework permet posar el nom com a text lliure, com JUnit amb @DisplayName, escriu directament la frase sencera:

@Test
@DisplayName("does not allow withdrawing more money than available")
void withdraw_withInsufficientBalance_throwsException() { ... }

El conjunt de noms de proves d’una classe acaba sent la llista de promeses que fa. Si la llista no s’entén sense obrir el codi, o hi ha una única prova anomenada testAll(), la documentació que hauria de donar la suite s’ha perdut.

Test doubles

Els test doubles són versions simplificades, o directament falses, de les dades o funcions reals, i serveixen per reduir la complexitat de la prova.

Per comprovar que Account rebutja una retirada que el servei antifrau marca com a sospitosa, no cal parlar amb el servei antifrau real: n’hi ha prou amb un objecte que sempre respongui “sospitosa”. Aquest objecte és un test double, i n’hi ha de diversos graus, de menys a més específic:

  • 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, és convenient seleccionar el menys específic (millor un dummy que un mock).
  • Un cop seleccionat el doble, millor implementar-lo que utilitzar un framework.

El lloc on s’injecta un doble és un seam: el punt on una implementació es pot canviar per una altra sense tocar el codi que la fa servir.

Els seams no apareixen sols. Si una classe instancia les seves dependències a dins, o crida directament un servei estàtic, no hi ha cap escletxa per posar-hi un doble i la prova esdevé impossible sense reescriure el codi. Els mateixos principis que fan bo un disseny (dependències explícites, injectades des de fora, i interfícies en lloc de classes concretes) són els que deixen lloc per als dobles. Per això la dificultat d’escriure una prova és un senyal sobre el codi, no sobre la prova: es tracta a Disseny per a la testabilitat.

Proves de contracte

Una prova de contracte (contract test) és una prova escrita contra una interfície, sense anomenar cap classe concreta, que serveix per jutjar qualsevol implementació d’aquella interfície. En comptes d’una suite per classe, tens una suite per contracte i tantes execucions com implementacions.

En Java la forma habitual és una classe de prova abstracta que deixa la construcció del subjecte com a únic mètode pendent:

public abstract class AccountRepositoryContractTest {

    protected abstract AccountRepository newRepository();

    @Test
    void save_thenFindById_returnsTheSameAccount() {
        AccountRepository repo = newRepository();
        repo.save(new Account("1", 100));
        assertEquals(100, repo.findById("1").orElseThrow().balance());
    }

    @Test
    void findById_withUnknownId_returnsEmpty() {
        assertTrue(newRepository().findById("nope").isEmpty());
    }
}

Cada implementació només ha d’aportar la seva manera de construir el subjecte:

class JdbcAccountRepositoryTest extends AccountRepositoryContractTest {
    protected AccountRepository newRepository() { return new JdbcAccountRepository(testDataSource()); }
}

class InMemoryAccountRepositoryTest extends AccountRepositoryContractTest {
    protected AccountRepository newRepository() { return new InMemoryAccountRepository(); }
}

Les assercions no poden parlar de la implementació, perquè no en coneixen cap. Són proves de comportament observable per construcció i no per disciplina, i això les fa immunes als vermells injustificats.

El guany més gran, però, és el que fan pels dobles. El perill d’un fake és que es comporti diferent del real i que la prova passi igualment; si ha de passar la mateixa suite, aquell risc desapareix. L’InMemoryAccountRepository de l’exemple és exactament això.

Tres coses a tenir clares abans de fer-ne servir:

  • Quan compensen. Quan la interfície té més d’una implementació real. El cas més freqüent no sembla que en siguin dues: la de producció i el fake. Després venen les fronteres on la segona arriba tard però arriba (memòria i base de dades, disc local i núvol) i les interfícies que implementa algú altre. Amb una sola implementació i cap doble, la classe abstracta és cerimònia.
  • Fins on arriben. La suite només afirma el que el contracte garanteix. Que un client HTTP es recuperi d’un tall de xarxa, o què fa la versió amb base de dades amb una restricció d’unicitat, no hi surt: cada implementació manté a més les seves pròpies proves.
  • Quan s’escriuen. Abans de la implementació, perquè el contracte ja existeix. Per això poden jutjar codi que encara no s’ha escrit sense infringir el criteri de correcció. No és TDD: la interfície ja està decidida i la suite s’escriu sencera, sense cicle. I sovint qui l’escriu no és qui l’implementarà, cosa que hi acosta el BDD i ATDD.

Compte amb el nom, que està sobrecarregat. “Contract testing”, dit sense més, avui sol voler dir les proves entre serveis que es despleguen per separat, amb eines com Pact, que es tracten a Fronteres de servei. La idea de fons és la mateixa i la frontera no: allà separa dos equips, i aquí una interfície de les seves implementacions. Quan la distinció importa, el cas d’aquest apartat es diu suite de conformitat, i el seu exemple més conegut és el TCK (Technology Compatibility Kit) que ha de passar qualsevol implementació de Jakarta EE.

El criteri de correcció

Les pràctiques que veurem a continuació es diferencien en moltes coses, però totes descansen sobre la mateixa regla:

El criteri de correcció no pot sortir del codi que jutja.

Si escrius la prova després de llegir el que retorna el codi, la prova passa però no verifica res: hi has copiat el comportament que el codi ja té, en lloc del que hauria de tenir. És el mateix mecanisme dels tests tautològics.

Quina forma pot tenir el criteri. Una prova no és l’única manera de fixar-lo:

  • TDD: una prova unitària escrita abans d’implementar.
  • Criteris d’acceptació: la definició de fet d’una tasca o història d’usuari, fixada al backlog.
  • BDD i ATDD: un exemple d’ús acordat amb qui demana la funcionalitat, escrit com a escenari given-when-then.
  • Disseny per contracte i tipatge estàtic: precondicions, postcondicions, invariants i tipus que es comproven sols.
  • Revisió de codi: una segona persona jutja el resultat al pull request.
  • Llindars de producció: la taxa d’error, la latència o la mètrica de negoci que has decidit tolerar, vigilada amb monitoratge, feature flags i desplegaments graduals.

Els quatre últims no són proves, i tot i així fixen un criteri que no surt del codi. La regla no diu que calgui provar-ho tot, diu d’on ha de venir el criteri.

L’últim, a més, no és una alternativa a provar sinó el complement: cobreix el que cap suite no pot cobrir (la càrrega real, les dades reals, les combinacions que ningú va imaginar). Quan i on s’aplica cadascun es veu a Les proves dins del cicle de desenvolupament.

Quan s’escriu. Cap pràctica no obliga a escriure sempre la prova abans. Això varia segons el context, i cap d’aquestes opcions no incompleix la regla mentre el criteri no surti del codi:

  • Proves després del codi: el cas més habitual. Entren al mateix pull request i la integració contínua les executa abans de fusionar.
  • Spike i reescriptura: un prototip exploratori sense proves per entendre el problema, que es llença i es refà amb proves.
  • Proves de caracterització: en codi heretat, proves que documenten el comportament actual abans de tocar-lo.

Proves després del codi

És l’opció més habitual, i també la que exposa més la regla, perquè el codi ja és allà mentre escrius la prova. Dues disciplines la salven, i totes dues surten de la regla.

Fes-la fallar a propòsit. El TDD veu el vermell de franc, perquè el codi encara no existeix. Escrivint la prova després, l’has de provocar tu, i es fa trencant el codi un moment:

  1. Amb la prova ja en verd, espatlla el codi que verifica: canvia un > per un >=, esborra la validació, retorna una constant.
  2. Executa la prova. S’ha de posar vermella.
  3. Desfés el canvi i comprova que torna a ser verda.

Si a la passa 2 continua verda, la prova no travessa el camí que et penses, o l’asserció no discrimina (el cas típic és un assertNotNull sobre una cosa que mai no és nul·la). Són deu segons i és l’única manera de saber que la prova pot fallar; una prova que no ha estat mai vermella no s’ha verificat. És la versió manual del que automatitza el testing de mutacions.

Quan el que fas és arreglar un bug no cal sabotejar res: escriu la prova abans de la correcció i el vermell te’l dona el bug, com ja hem vist a les proves de regressió.

El resultat esperat surt del requisit, no del codi. Llegir el codi línia a línia per decidir què provar té dos efectes: copies el comportament que hi ha en lloc del que hi hauria d’haver, i només cobreixes el camí feliç. La branca que falta és invisible precisament perquè falta, i cap lectura del codi te la mostrarà. Els casos límit i d’error surten del requisit, no del if que tens al davant.

El que no pot sortir del codi és el resultat esperat. Escollir quins casos exercites és una altra decisió, i aquí mirar la implementació és legítim i de vegades imprescindible: una branca que la cobertura marca com a no executada, un mutant que ha sobreviscut, unes claus que col·lideixen a propòsit per veure si una taula de hash degenera, o l’entrellaçat de fils que només saps que existeix perquè has vist on és l’estat compartit. En tots aquests casos el codi et diu on buscar i el requisit continua dient què ha de passar.

El senyal d’alerta és fàcil de reconèixer: si escrius les proves d’un codi ja fet i totes passen a la primera, no has verificat res, has fotografiat el comportament actual.

Pràctiques guiades per proves

Aquest apartat desenvolupa la família que fixa el criteri amb una prova, que és la que ocupa aquesta pàgina. El TDD només n’és la més coneguda; es diferencien en qui escriu el criteri, a quin nivell i en quin format:

PràcticaQui escriu la provaNivellQuè fixa
TDDLa persona que programaUnitariEl disseny d’una unitat, pas a pas
BDD i ATDDL’equip amb qui demana la funcionalitatAcceptacióQuè vol dir que la funcionalitat està feta
Proves de caracteritzacióQui ha de tocar codi heretatEl que calguiEl comportament actual, abans de canviar-lo
Proves basades en propietatsLa persona que programaUnitariUna regla que s’ha de complir per a qualsevol entrada

No són excloents. Un mateix projecte pot tenir escenaris d’acceptació acordats amb el client, TDD per al codi nou i proves de caracterització al mòdul antic que ningú vol tocar.

Test-driven development

El TDD (desenvolupament guiat per proves) inverteix l’ordre habitual: primer s’escriu la prova unitària i després el codi que la fa passar. El procediment és aquest:

  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.

La passa 2 no és un tràmit. Una prova que passa abans d’escriure el codi no verifica res, i escriure-la després de veure el resultat porta a copiar-hi el comportament que ja té el codi en lloc del que hauria de tenir.

Vegem-ho amb l’Account. La primera volta del cicle comença per la prova de la retirada correcta, que és exactament la de l’apartat arrange, act i assert. Encara no existeix withdraw(), de manera que la prova ni tan sols compila: aquest és el vermell de la passa 2. El codi més senzill que la posa verda és aquest:

public void withdraw(int amount) {
    balance -= amount;
}

És clarament incomplet, però és correcte per a l’única prova que existeix, i el TDD no deixa escriure res que cap prova hagi demanat. El cas que falta entra a la volta següent:

@Test
void withdraw_withInsufficientBalance_throwsException() {
    Account account = new Account(100);
    assertThrows(InsufficientBalanceException.class, () -> account.withdraw(150));
    assertEquals(100, account.balance());
}

Falla, perquè el codi actual deixa el saldo a -50 sense queixar-se. Ara sí que la comprovació està justificada:

public void withdraw(int amount) {
    if (amount > balance) {
        throw new InsufficientBalanceException();
    }
    balance -= amount;
}

Fixa’t en el que ha passat. La validació no s’ha escrit perquè algú se n’ha recordat, sinó perquè una prova l’ha exigida, i el codi no ha crescut més enllà del que les proves demanaven.

El TDD té un efecte secundari que sovint importa més que les proves resultants: com que escrius primer com es fa servir el codi, acabes amb interfícies més fàcils d’utilitzar. Si la prova és incòmoda d’escriure, el disseny encara no és bo.

També té els seus inconvenients. Demana saber què vols abans de programar, cosa que no passa quan encara estàs explorant el problema, i llavors el camí honest és un spike sense proves que després es llença. A més, les proves queden acoblades a la granularitat de les unitats que has anat creant: un refactor que en fusioni o en parteixi obliga a reescriure-les. Per això el TDD estricte és minoritari a la indústria, tot i que la regla que el sosté no ho és.

BDD i ATDD

El TDD respon “aquest mètode fa el que jo esperava”. No respon si el que jo esperava era el que calia. Aquest segon problema és el que ataquen el BDD (behaviour-driven development) i l’ATDD (acceptance test-driven development): l’exemple del comportament esperat s’acorda abans de programar amb qui demana la funcionalitat, i s’escriu amb el seu vocabulari, no amb el del codi.

El format habitual és l’escenari given-when-then, escrit en Gherkin.

El format Gherkin

Gherkin és un llenguatge de text pla amb molt poques paraules clau, dissenyat perquè el pugui llegir algú que no programa. Els escenaris es guarden en fitxers .feature:

  • Feature (Funcionalitat): agrupa els escenaris relacionats i n’explica el context.
  • Scenario (Escenari): un cas concret. És la unitat que s’executa.
  • Given, When, Then (Donat, Quan, Aleshores): les tres passes de l’escenari, que són l’arrange, act i assert amb un altre vocabulari.
  • And, But (I, Però): afegeixen una línia continuant la paraula clau anterior.
  • Background (Rerefons): passes comunes que s’executen abans de cada escenari del fitxer.
  • Scenario Outline (Esquema de l’escenari) amb Examples (Exemples): el mateix escenari repetit amb una taula de dades.
Feature: Cash withdrawal

  Background:
    Given an account with 100 euros

  Scenario: withdrawal above the balance
    When 150 euros are withdrawn
    Then the operation is rejected
    And the balance is still 100 euros

  Scenario Outline: any amount above the balance is rejected
    When <amount> euros are withdrawn
    Then the operation is rejected

    Examples:
      | amount |
      | 101    |
      | 500    |
      | 1000   |

Per defecte les paraules clau són en anglès. Una capçalera # language: ca a la primera línia les canvia al català, i cada llengua té les seves: el then català és Aleshores o Cal, no Llavors.

Els step definitions

L’escenari no és documentació, és codi executable. Cada línia s’enganxa a un step definition, un mètode amb una anotació que conté el mateix text i amb els valors que varien marcats com a paràmetres. El conjunt d’aquests mètodes, la capa que uneix el text de l’escenari amb el codi real, es coneix com a glue:

public class WithdrawalSteps {

    private Account account;
    private Exception error;

    @Given("an account with {int} euros")
    public void anAccountWith(int balance) {
        account = new Account(balance);
    }

    @When("{int} euros are withdrawn")
    public void eurosAreWithdrawn(int amount) {
        try {
            account.withdraw(amount);
        } catch (InsufficientBalanceException e) {
            error = e;
        }
    }

    @Then("the operation is rejected")
    public void operationRejected() {
        assertNotNull(error);
    }

    @Then("the balance is still {int} euros")
    public void balanceIsStill(int balance) {
        assertEquals(balance, account.balance());
    }
}

Quatre anotacions cobreixen les sis línies de passos dels dos escenaris, perquè l’esquema reutilitza passos que ja existien. Aquest és el guany de la capa: escrius el step definition un cop i després compons escenaris amb text.

Tres detalls del codi anterior:

  • Cada pas és un mètode independent, així que es comuniquen pels camps de la classe.
  • L’anotació no ha de coincidir amb la paraula clau de l’escenari. Cucumber ignora el Given o el When i només busca el text que ve després, de manera que un escenari en català pot enganxar-se a anotacions en anglès.
  • La línia que comença amb And continua la paraula clau anterior. Per això aquí és un @Then.

Aquesta capa de traducció és tota la diferència entre Gherkin i una prova JUnit normal. A favor:

  • Un sol artefacte fa de requisit, prova i documentació. És el que s’anomena especificació amb exemples (specification by example), i no es pot desactualitzar sense que la suite es posi vermella.
  • La conversa amb qui demana la funcionalitat passa abans del codi, i sovint val més que el fitxer resultant.
  • Els escenaris, escrits en vocabulari de domini, sobreviuen a un refactor intern.

En contra:

  • Cal una indirecció més per saber què comprova un escenari.
  • La glue és codi que s’ha de mantenir, i tendeix a duplicar-se perquè els passos són text lliure.
  • L’estat viu en camps compartits.
  • Les proves solen ser lentes, perquè travessen el sistema sencer.

El problema més habitual, però, no és cap d’aquests: és que el client no llegeixi mai els escenaris. Llavors l’equip carrega amb tota la sintaxi i n’és l’única audiència, i Gherkin no aporta res que no aporti JUnit amb bons noms de prova. Per això el BDD té sentit per a les regles de negoci que algú de fora de l’equip ha de validar, no per a cada mètode.

Proves de caracterització

En codi heretat sense proves no pots començar per escriure el criteri correcte, perquè encara no saps què fa el codi ni saps si el que fa és el que hauria de fer. Una prova de caracterització (characterization test), terme de Michael Feathers, dona la volta al plantejament: escriu proves que capturin el comportament actual, sigui correcte o no.

El procediment és escriure una prova amb una asserció qualsevol, executar-la, veure què retorna de debò el codi i posar aquest valor a l’asserció. La prova no diu que el codi sigui correcte, diu que fa el que feia ahir. Amb aquesta xarxa ja pots refactoritzar sabent que, si trenques alguna cosa, te n’assabentaràs, i llavors sí, corregir el que estigui malament.

És l’excepció deliberada a la regla del criteri de correcció: aquí el criteri sí que surt del codi que jutja, i s’accepta perquè l’objectiu no és validar el comportament sinó congelar-lo mentre el toques.

El guany és que et deixen tocar codi que no goses tocar, que sovint és l’únic camí per començar. El preu és que congelen els bugs igual que la resta, i que les assercions queden plenes de valors màgics que no expliquen cap intenció. Són una bastida, no la suite definitiva: a mesura que entens el mòdul, s’han d’anar substituint per proves que diguin què ha de passar.

Proves basades en propietats

Una prova d’exemple comprova un cas concret: retirar 30 de 100 deixa 70. Una prova basada en propietats (property-based testing) comprova una regla que s’ha de complir per a qualsevol entrada, i deixa que la llibreria en generi centenars, incloent-hi els casos límit que tu no hauries pensat (el zero, el negatiu, el desbordament, la cadena buida).

@Property
void balanceIsNeverNegative(@ForAll @Positive int balance, @ForAll @Positive int amount) {
    Account account = new Account(balance);
    try { account.withdraw(amount); } catch (InsufficientBalanceException e) { }
    assertTrue(account.balance() >= 0);
}

Quan una propietat falla, l’eina redueix automàticament l’entrada al cas més petit que encara falla, i sovint et dona directament el bug. A Java es fa amb jqwik.

A favor, troben casos límit que ningú havia previst, que és exactament el que una prova d’exemple no pot fer. En contra, són més lentes, un error no sempre és fàcil d’interpretar, i la dificultat real no és l’eina sinó trobar quines propietats ha de complir el teu domini: invariants (el saldo mai no és negatiu), operacions inverses (desserialitzar el que has serialitzat torna l’original) o resultats independents de l’ordre. Per això complementen les proves d’exemple, no les substitueixen.

Saber si les proves serveixen

Tenir moltes proves verdes no vol dir tenir el codi verificat. Una suite pot fallar en dues direccions oposades: no posar-se vermella quan el codi és incorrecte, i posar-se vermella quan el codi és correcte. La primera direcció té eines que la mesuren, i mesuren coses molt diferents. La segona no en té cap, i només es pot jutjar.

Cobertura

La cobertura de codi (code coverage) mesura quin percentatge del codi s’ha executat mentre corrien les proves. Es presenta per línies, per branques o per condicions, i les eines et marquen les línies que cap prova no ha arribat a tocar.

Serveix per detectar forats: un mòdul amb 0% de cobertura segur que no està provat. Però la mètrica és només un senyal negatiu. Aquesta prova dona 100% de cobertura sobre withdraw i no comprova absolutament res:

@Test
void withdraw() {
    new Account(100).withdraw(30);
}

La línia s’ha executat, i per a l’eina això és cobertura. Ningú ha comprovat el resultat. Per això perseguir un percentatge com a objectiu (el clàssic “cal arribar al 80%”) produeix proves escrites per pujar el número, no per verificar comportament.

Testing de mutacions

El testing de mutacions (mutation testing) respon la pregunta que la cobertura no respon: si trenquem el codi, la suite se n’adona?

L’eina agafa el codi, hi introdueix un canvi petit i deliberat (un mutant), i executa les proves:

  • Si alguna prova falla, el mutant està mort. La suite ha detectat el canvi.
  • Si totes les proves passen, el mutant ha sobreviscut. Aquell comportament no està verificat per ningú.
  • Un mutant equivalent és un canvi que no altera el comportament observable, i per tant és impossible de matar. Cal descartar-los a mà.

El mutation score és la proporció de mutants morts sobre els generats, un cop descomptats els equivalents. És una mètrica molt més exigent que la cobertura: la prova withdraw() de l’apartat anterior té 100% de cobertura i mata zero mutants.

Els canvis que introdueix l’eina són sistemàtics, i cadascun correspon a un error típic de programació:

Operador de mutacióExemple
Frontera d’una condicióa < b passa a a <= b
Negació d’una condicióa == b passa a a != b
Operació aritmèticaa + b passa a a - b
Valor de retornreturn x passa a return null o return 0
Eliminació d’una cridas’esborra la invocació d’un mètode void
Incrementi++ passa a i--

El concepte no depèn del llenguatge, però sí el cost. El que canvia entre ecosistemes és on s’injecta la mutació: PIT muta el bytecode de la JVM ja compilat, que és ràpid; les eines de llenguatges interpretats muten l’arbre sintàctic o el codi font, i les de llenguatges compilats sense capa intermèdia necessiten recompilar cada mutant.

LlenguatgeEina
Java i JVMPIT (pitest)
C#, JavaScript, TypeScript, ScalaStryker
Pythonmutmut, cosmic-ray
PHPInfection
Gogo-mutesting, gremlins
Rustcargo-mutants

En qualsevol cas és molt més lent que executar la suite un cop, perquè la suite s’executa una vegada per mutant. El costum és aplicar-lo als mòduls crítics i deixar-lo per a la integració nocturna, no per a cada commit.

Vermells injustificats

La cobertura i les mutacions pregunten totes dues el mateix: la suite es posa vermella quan el codi és incorrecte? La direcció contrària fa tant de mal i no té cap mètrica que la detecti. Una suite que es posa vermella quan el codi és correcte cobra un peatge a cada canvi, i la reacció de l’equip és previsible: refactoritzar menys.

El cas més clar és una prova acoblada a la implementació. Si canviar el nom d’un mètode privat o repartir una classe en dues posa trenta proves en vermell, aquelles proves no verifiquen res més que les altres, només encareixen el refactor. Les proves haurien de descriure comportament observable, i és el mateix criteri que fa que els escenaris de Gherkin sobrevisquin a un canvi intern.

Dos senyals més, tots dos qüestió de criteri i no de número:

  • Un vermell ha de dir on és el problema. Si un sol bug posa quaranta proves en vermell, cap no t’ha dit on és: comparteixen l’arrange i en realitat són una sola prova repetida. Ho pots comprovar espatllant una cosa a propòsit, com a proves després del codi, i mirant quantes s’encenen.
  • Has de poder esborrar una prova. Una suite et serveix si l’equip es veu amb cor de treure’n una que ja no aporta res. Quan ningú no gosa esborrar res, deixa de ser una eina i passa a ser un arxiu que creix, s’alenteix i amaga les proves que sí que importen.

El cas extrem d’aquesta direcció és el de l’apartat següent: vermelles sense que el codi hagi canviat gens.

Proves intermitents

Una prova intermitent (flaky) és la que passa o falla amb el mateix codi, i una suite que en té és pitjor que no tenir-ne cap: ensenya l’equip a tornar a llançar el pipeline en lloc d’investigar el vermell. Quan això passa, la suite ja no protegeix res, perquè el dia que falli de veritat ningú s’ho creurà.

Les causes habituals:

  • Dependència del temps: esperes fixes (sleep(500)) que són suficients a la teva màquina i no al servidor de CI, o proves que comparen contra la data actual.
  • Dependència de l’ordre: la prova A deixa estat que la prova B necessita. Es detecta executant la suite en ordre aleatori.
  • Estat compartit: una base de dades, un fitxer o una variable estàtica que no es neteja entre proves.
  • Concurrència: la prova assumeix un ordre d’execució entre fils que no està garantit.
  • Dependències externes: una API real, la xarxa o un servei de tercers que un dia respon lent.

Davant d’una prova intermitent, arregla-la o esborra-la. Marcar-la perquè s’ignori és acceptable només com a mesura temporal i amb data de caducitat, perquè una prova ignorada envelleix fins que ja no té sentit recuperar-la.

Les proves dins del cicle de desenvolupament

Les proves no s’executen totes al mateix moment ni al mateix lloc. Cada etapa admet un cost diferent i, per tant, un conjunt de proves diferent. La regla que ordena la taula és que com més tard es detecta un error, més car és d’arreglar.

MomentQuè s’hi executaTemps acceptableQui veu la fallada
Mentre programes, a l’IDELes proves unitàries del que estàs tocantSegonsTu
Abans del commit, amb un git hookLinters i les proves unitàries ràpidesMenys d’un minutTu
A cada push, a la integració contínuaLa suite sencera, incloent-hi integracióMinutsL’equip
Abans de desplegar, a stagingEnd-to-end i acceptacióDesenes de minutsL’equip
Just després de desplegarProves de fum i health checksSegonsQui està de guàrdia
En produccióMonitoratge, desplegaments graduals, feature flagsContinuEls usuaris, si arriba tard

Les dues primeres files són el teu bucle personal i han de ser ràpides, perquè les executes desenes de vegades al dia. La tercera és la que protegeix la branca principal, i és l’única que no es pot saltar ningú.

Res d’això funciona si les proves s’executen a mà. Una suite que cal recordar de llançar acaba no llançant-se, i el pipeline que la converteix en obligatòria es descriu a Automatització del lliurament.

Qui fa les proves

La qualitat no és responsabilitat d’un departament, és de l’equip que construeix. Aquesta frase sona a eslògan, però té una conseqüència molt concreta: qui escriu una funcionalitat escriu també les proves que la verifiquen, i no la dona per acabada fins que passen.

El model contrari va ser l’habitual durant dècades. El desenvolupament acabava, i llavors el codi passava a un equip de proves separat que buscava errors i els retornava. Falla per dues raons. La primera és de calendari: els errors es troben setmanes després d’introduir-los, quan són molt més cars d’arreglar. La segona és de responsabilitat: si algú altre comprova la teva feina, deixes de comprovar-la tu. D’aquí ve l’expressió llançar el codi per sobre del mur, i el moviment que la corregeix, el shift left: moure la verificació tan a prop de l’origen com es pugui.

Que la responsabilitat sigui compartida no vol dir que tothom faci el mateix:

RolQuè hi aporta
Qui programaLes proves unitàries i d’integració del que escriu, i deixar la branca principal verda
QA o testerDissenyar els casos que ningú ha pensat, provar de manera exploratòria i defensar la perspectiva de l’usuari
SDET (enginyeria de proves)Construir i mantenir la infraestructura: entorns, dades de prova, framework end-to-end, pipeline
Producte, o qui demana la funcionalitatFixar els criteris d’acceptació i validar que els escenaris diuen el que han de dir
El clientLa prova d’acceptació d’usuari abans de donar per bona una entrega
Qui està de guàrdiaEl monitoratge i els health checks, que són la verificació que continua després del desplegament

En equips petits, una mateixa persona fa diversos d’aquests papers. El que no funciona és que ningú els faci.

Els tres amics

La pràctica que materialitza tot això al BDD es diu three amigos: abans de programar una funcionalitat, es reuneixen tres perspectives durant una estona curta.

  • Negoci: què ha de passar i per què.
  • Desenvolupament: què implica i quins casos límit apareixen.
  • Proves: com podria fallar i com sabrem que funciona.

El resultat de la reunió són els escenaris acordats. El guany real no és el document, són les preguntes que surten allà i que, si no, s’haurien descobert a mitja implementació.

Proves exploratòries

Una suite automàtica només troba el que algú va anticipar quan la va escriure. Per definició, no pot trobar el cas que ningú va imaginar.

Aquesta feina és humana i té nom, proves exploratòries: algú fa servir el sistema amb intenció de trencar-lo, sense guió previ, seguint el que va descobrint. Quan hi troba alguna cosa, aquell cas es converteix en una prova de regressió i ja no cal tornar-lo a buscar mai més a mà. És la divisió de feina raonable entre persones i màquines: les persones descobreixen, la suite recorda.

El paper de les proves amb assistents i agents

Els assistents de codi no han canviat què és una bona prova. Han canviat què costa cada cosa, i això reordena les prioritats d’aquesta pàgina.

La verificació passa a ser el coll d’ampolla

Escriure codi era la part cara i verificar-lo la barata. Amb un agent que genera dues-centes línies en trenta segons, la relació s’inverteix: el que limita la velocitat ja no és produir el canvi, sinó saber si el canvi és correcte.

La conseqüència pràctica és que una suite lenta o poc fiable deixa de ser una molèstia i passa a ser el fre principal. Un agent treballa en bucle (genera, executa la suite, llegeix l’error, corregeix), i cada volta del bucle costa el que costa la suite. Amb proves ràpides el bucle s’aguanta sol; amb proves intermitents el bucle es trenca, perquè l’agent no distingeix un vermell real d’un vermell aleatori i “corregeix” codi que ja era correcte.

La prova és l’especificació que dones a l’agent

A un humà li pots demanar “que gestioni bé els descomptes” i entendrà el context. Un model no en té, de context, més enllà del que li poses al davant, i omplirà els buits amb el que li sembli plausible.

Una prova escrita abans no pateix aquest problema: fixa el comportament esperat en un format que no admet interpretacions i que l’agent pot executar per saber si ha acabat. Això no és exactament TDD, és el mateix mecanisme utilitzat com a contracte de la delegació: tu escrius el criteri, l’agent busca el codi que el compleix.

Tests tautològics

Quan el mateix agent escriu la implementació i les proves, totes dues neixen de la mateixa lectura del problema i comparteixen els mateixos punts cecs. La prova acaba afirmant el que el codi fa, no el que hauria de fer, i passa sempre. Són els tests tautològics, i com evitar-los es tracta a Testing amb IA.

És el mateix problema de les proves després del codi, amb el mateix senyal d’alerta i el mateix remei, però amplificat: l’agent en genera vint de cop, totes verdes i totes plausibles, i cap no ha estat mai vermella.

Què guanya i què perd valor

PràcticaPer què canvia
Escriure la prova abansGuanya: és la manera de dir a l’agent què vols, i garanteix que la prova no s’ha copiat del codi
Cobertura com a objectiuPerd: un agent arriba al 90% sense esforç i sense verificar res. Mai no ha estat bona mètrica, però ara és trivial de falsejar
MutacionsGuanya: pregunta si la suite detecta bugs, que és exactament el que la cobertura no respon
Proves que travessen fronteresGuanyen: escrites amb vocabulari de domini, no poden heretar els punts cecs de la implementació
Llegir el diff de les provesGuanya: revisar les proves passa a ser més informatiu que revisar la implementació, perquè hi has de comprovar que afirmen el que toca
Suite ràpida i deterministaGuanya: és la condició perquè el bucle de l’agent funcioni

El que no canvia

Una prova segueix sent codi que algú haurà de mantenir, i que un agent en generi cent en un minut no vol dir que en calguin cent. Una suite inflada de proves redundants és més lenta, més fràgil i amaga les que sí que importen.

La responsabilitat tampoc es delega: el criteri de què s’ha de complir continua sent teu. L’agent proposa el codi, i de vegades la prova, però qui decideix que la prova és la correcta és qui signa el commit.

Referències

Last change: , commit: eac46c7