Il test impossibile

  • software
  • clean-code

Prima di fare un refactor chiedo all’AI di fissare il comportamento del codice con dei test, quando mancano o non sono esaustivi. Così, se dopo il refactor qualcosa cambia, me ne accorgo. Michael Feathers li chiama characterization tests: non dicono cosa il codice dovrebbe fare, fissano quello che fa.

Su un pezzo di codice legacy, tra i test che ha scritto ce n’era uno che a prima vista non aveva senso. Per raccontare l’aneddoto uso una similitudine, un’asta: il test verificava che il prezzo corrente non si aggiorna quando l’asta non è ancora aperta.

Se n’è accorto un collega che conosce quel software a fondo: quella casistica è impossibile. Prima dell’apertura non ci sono rilanci, quindi non c’è nessun prezzo corrente da aggiornare. C’è solo la base d’asta.

Sul fatto eravamo d’accordo. L’interpretazione era però divergente: il mio collega pensava che fosse un errore dell’AI, io pensavo che fosse un problema della codebase.

L’AI crede al codice

Quello che il collega sapeva non era scritto da nessuna parte. Che un’asta programmata non riceve rilanci è una regola del mestiere: chi ci lavora la dà per scontata, il codice no.

Peter Naur lo diceva nel 1985, molto prima dell’AI: programmare è costruire una teoria. Quella teoria vive nella testa di chi scrive il programma, non nel testo. Il codice legacy è proprio quello in cui la teoria si è persa o non è mai stata scritta. Chi arriva dopo ha solo il testo.

Un collega appena arrivato, davanti allo stesso codice, avrebbe chiesto: “Questo può succedere?”. L’AI non chiede, scrive il test. La domanda è la stessa, solo che arriva scritta in codice.

Il codice non sa cos’è un’asta

Il codice era più o meno così:

class Auction {
  private String status;
  private double startingPrice;
  private Double currentPrice;

  void placeBid(double amount) {
    if (!"OPEN".equals(status)) {
      return;
    }
    currentPrice = amount;
  }
}

Tutto primitivi, anche gli stati. Lo stato è una stringa qualunque, il prezzo un numero, il rilancio un altro numero. Niente dice che un’asta programmata non accetta rilanci: c’è solo un if che, se lo stato non è quello giusto, esce in silenzio. Per chi legge, AI compresa, quel return è un comportamento previsto. Quindi va testato:

@Test
void currentPriceIsNotUpdatedWhenTheAuctionIsNotOpen() {
  Auction auction = new Auction("SCHEDULED", 100.0);

  auction.placeBid(150.0);

  assertThat(auction.currentPrice()).isNull();
}

Martin Fowler e Kent Beck hanno dato un nome a questo odore: Primitive Obsession. Molti programmatori, scrivono, sono stranamente restii a creare i tipi fondamentali utili al proprio dominio. Così usano numeri e stringhe al posto di concetti come il denaro o un intervallo. Le stringhe, aggiungono, sono il terreno più fertile per questo odore. Qui la stringa era lo stato dell’asta. Il dominio, come ho raccontato con l’officina di un meccanico, è fatto di regole che cambiano le decisioni di chi ci lavora. Questa regola il codice non la esprimeva.

Il test diventa un vincolo

Il test impossibile non lo cancello. Lo trasformo: da test su un caso che “non succede” a test che documenta la regola per cui non succede. E non sistemo tutto in un colpo: vado per passi piccoli, ognuno con il suo test verde prima di passare al successivo.

Primo passo: dire la regola

Il cambiamento più piccolo che conta. Lo stato smette di essere una stringa qualunque e il return silenzioso diventa un errore con un nome:

enum AuctionStatus { SCHEDULED, OPEN, CLOSED }

class Auction {
  private AuctionStatus status;
  private double startingPrice;
  private Double currentPrice;

  void placeBid(double amount) {
    if (status != AuctionStatus.OPEN) {
      throw new AuctionNotOpen(status);
    }
    currentPrice = amount;
  }
}

Il test dice la regola con le parole del dominio:

@Test
void aScheduledAuctionDoesNotAcceptBids() {
  Auction auction = new Auction(AuctionStatus.SCHEDULED, 100.0);

  assertThrows(AuctionNotOpen.class, () -> auction.placeBid(150.0));
}

Il test di prima chiedeva cosa succede se arriva un rilancio prima dell’apertura. Quello di adesso risponde: non può arrivare. Se arriva, è un errore. Chi legge il codice domani, persona o AI, trova scritta la regola che prima stava solo nella testa del collega.

Secondo passo: un tipo per ogni stato

Si può andare oltre. Yaron Minsky lo chiama “make illegal states unrepresentable”: fare in modo che gli stati illegali non si possano nemmeno rappresentare. Qui vuol dire un tipo diverso per ogni stato dell’asta, con placeBid solo su quella aperta:

sealed interface Auction permits ScheduledAuction, OpenAuction, ClosedAuction {}

record ScheduledAuction(double startingPrice) implements Auction {

  OpenAuction open() {
    return new OpenAuction(startingPrice);
  }
}

record OpenAuction(double currentPrice) implements Auction {

  OpenAuction placeBid(double amount) {
    return new OpenAuction(amount);
  }

  ClosedAuction close() {
    return new ClosedAuction(currentPrice);
  }
}

record ClosedAuction(double finalPrice) implements Auction {}

Un’asta programmata non ha un metodo per ricevere rilanci e il prezzo corrente esiste solo sull’asta aperta, dove c’è sempre. Sparisce anche il null che il test impossibile controllava. Il test impossibile non compila più. Anche il test del vincolo perde senso, perché ora la regola la garantisce il compilatore. Al suo posto si testano le transizioni:

@Test
void anOpenedAuctionAcceptsBids() {
  OpenAuction auction = new ScheduledAuction(100.0).open();

  OpenAuction afterBid = auction.placeBid(150.0);

  assertThat(afterBid.currentPrice()).isEqualTo(150.0);
}

Il database è un dettaglio

Spesso il database e il codice di dominio vengono pensati insieme. Nel database l’asta è una riga con una colonna di stato, quindi anche nel codice diventa una classe con un campo di stato. Le due cose invece vanno tenute scollegate.

Che nel database l’asta sia una riga con uno stato è un dettaglio dell’implementazione. Chi usa l’asta vede un’interfaccia che la restituisce:

interface Auctions {
  Auction byId(long id);
}

Come l’implementazione costruisce il tipo giusto, con uno switch sullo stato o in un altro modo, a chi usa l’asta non interessa. La forma delle tabelle non decide la forma del dominio.

È la stessa idea della presa di casa: l’interfaccia la scrive chi la usa. Il database sta dall’altra parte dell’adattatore, come un fornitore esterno.

Il test è una domanda

Quando l’AI scrive un test su qualcosa che “non può succedere”, prima di cancellarlo vale la pena leggerlo come una domanda. Ti sta indicando il punto preciso in cui il codice non esprime quello che sai tu.

Se ti è capitato un test così, o la vedi in un altro modo, scrivimi.

Riferimenti

  • Peter Naur, “Programming as Theory Building”, Microprocessing and Microprogramming 15 (5), 1985: la teoria di un programma sta nella testa di chi lo scrive.
  • Michael Feathers, Working Effectively with Legacy Code, Pearson, 2004 (scheda dell’editore): i characterization tests, che fissano il comportamento attuale del codice legacy prima di cambiarlo.
  • Martin Fowler, Refactoring: Improving the Design of Existing Code, seconda edizione, Addison-Wesley, 2018 (scheda dell’editore): il catalogo degli smell, Primitive Obsession compresa.
  • Kent Beck: coautore con Fowler del capitolo “Bad Smells in Code” di Refactoring.
  • Yaron Minsky, Effective ML Revisited, blog di Jane Street, 2011: “make illegal states unrepresentable”.