Lezione 5 di 6 · 18 min di lettura

Scrivere test

La cartella test, gleam test e le funzioni che finiscono in _test. Un test che scova un bug nascosto dal modulo 1, e come si legge un test che fallisce.

Ma funziona davvero?

format_cents ci accompagna dal modulo 1. L’abbiamo usata per scontrini, carrelli, pagamenti, e ha sempre stampato quello che ci aspettavamo. Ma l’abbiamo provata solo con gli importi che ci servivano in quel momento. Funziona con 5 centesimi? E con un importo negativo, come un rimborso?

Provarla a mano ogni volta, con un echo qui e uno là, è noioso e si dimentica. Un test è un piccolo programma che controlla automaticamente che una funzione dia il risultato atteso, e che si può rilanciare in un secondo ogni volta che cambi qualcosa.

La cartella test

Ricordi la cartella test/ creata da gleam new nella lezione 1.3? Nel progetto exercises contiene un file, test/exercises_test.gleam:

gleam
import gleeunit

pub fn main() -> Nil {
  gleeunit.main()
}

// gleeunit test functions end in `_test`
pub fn hello_world_test() {
  let name = "Joe"
  let greeting = "Hello, " <> name <> "!"

  assert greeting == "Hello, Joe!"
}

gleeunit è la libreria che esegue i test (è la dev_dependency che trovi in gleam.toml). Le regole sono poche:

  • i test stanno in moduli dentro test/, e il nome del modulo finisce in _test;
  • ogni test è una funzione pubblica il cui nome finisce in _test, senza argomenti;
  • dentro, assert controlla le condizioni: se tutte sono vere, il test passa; se una è falsa, il test fallisce.

Il comando per eseguirli è:

terminale
gleam test
output
  Compiling exercises
   Compiled in 0.22s
    Running exercises_test.main
.
1 passed, no failures

Ogni puntino è un test passato.

Il primo test vero

Per essere provata da un test, una funzione deve stare in un modulo di src/ ed essere pubblica, perché il test è un altro modulo che la importa. Creiamo src/money.gleam:

src/money.gleam
import gleam/int
import gleam/string

/// Formats an amount in cents as euros with two decimals: 690 -> "6.90".
pub fn format_cents(cents: Int) -> String {
  int.to_string(cents / 100)
  <> "."
  <> { cents % 100 |> int.to_string |> string.pad_start(2, "0") }
}

e i suoi test in test/money_test.gleam:

test/money_test.gleam
import money

pub fn format_cents_test() {
  assert money.format_cents(690) == "6.90"
}

pub fn format_small_amount_test() {
  assert money.format_cents(5) == "0.05"
}

pub fn format_negative_test() {
  assert money.format_cents(-150) == "-1.50"
}

Tre test: un caso normale, un importo piccolo (servirà lo zero davanti?), un importo negativo. Nomi descrittivi, perché quando un test fallisce il nome è la prima cosa che leggi. E via:

output
  Compiling exercises
   Compiled in 0.22s
    Running exercises_test.main
...
assert test/money_test.gleam:12
 test: money_test.format_negative_test
 code: assert money.format_cents(-150) == "-1.50"
 left: "-1.-50"
right: "-1.50"
 info: Assertion failed.

3 passed, 1 failures

Un bug! Nascosto dal modulo 1, e trovato al primo tentativo.

Leggere un test che fallisce

Il rapporto dice tutto quello che serve:

  • assert test/money_test.gleam:12: il file e la riga dell’assert fallito;
  • test: quale test (modulo e funzione);
  • code: la condizione che doveva essere vera;
  • left e right: i valori dei due lati del ==. A sinistra quello che la funzione ha davvero restituito, a destra quello che ti aspettavi.

"-1.-50": ecco il problema. Con un importo negativo, -150 / 100 fa -1 e -150 % 100 fa -50 (ricordi la lezione 1.5? la divisione tronca verso lo zero, e il resto ha il segno del dividendo). Il meno compare due volte. La correzione più semplice: se l’importo è negativo, formatta il suo opposto e aggiungi il meno davanti.

src/money.gleam
import gleam/int
import gleam/string

/// Formats an amount in cents as euros with two decimals: 690 -> "6.90".
pub fn format_cents(cents: Int) -> String {
  case cents < 0 {
    True -> "-" <> format_cents(-cents)
    False ->
      int.to_string(cents / 100)
      <> "."
      <> { cents % 100 |> int.to_string |> string.pad_start(2, "0") }
  }
}
terminale
gleam test
output
  Compiling exercises
   Compiled in 0.22s
    Running exercises_test.main
....
4 passed, no failures

Quattro test (i tre nuovi più quello di esempio), tutti verdi. E da ora in poi, se una modifica a format_cents dovesse rompere uno di questi casi, gleam test te lo direbbe subito.

Quiz

Quale di queste funzioni viene eseguita da gleam test, se sta in test/money_test.gleam?

Test e sviluppo, insieme

I test non servono solo a controllare il codice già scritto. Molti programmatori li scrivono prima: decidono cosa deve fare una funzione scrivendo i test, li guardano fallire (magari su un todo), e poi scrivono il codice finché diventano tutti verdi. È un modo di lavorare che obbliga a pensare ai casi ai bordi prima di esserne sorpresi.

E ricordi il codice di uscita della lezione precedente? gleam test esce con 0 se tutti i test passano e con 1 se qualcuno fallisce. È così che le GitHub Actions create da gleam new (in .github/workflows/) sanno se segnare un progetto in verde o in rosso a ogni modifica.

Esercizio · sul tuo computer

Il test che non passa

Aggiungi a src/money.gleam questa funzione, che legge un importo scritto come "6.90" e restituisce i centesimi:

gleam
/// Parses an amount like "6.90" into cents: Ok(690).
pub fn parse_cents(text: String) -> Result(Int, Nil) {
  use #(euros_text, cents_text) <- result.try(string.split_once(text, on: "."))
  use <- bool.guard(when: string.length(cents_text) != 2, return: Error(Nil))
  use euros <- result.try(int.parse(euros_text))
  use cents <- result.try(int.parse(cents_text))
  Ok(euros * 100 + cents)
}

(ti servono anche import gleam/bool e import gleam/result). Poi, in test/money_test.gleam, scrivi un test per ciascuno di questi casi:

  1. "6.90" diventa Ok(690);
  2. "0.05" diventa Ok(5);
  3. "six" è un Error(Nil);
  4. "6.9" è un Error(Nil) (i centesimi devono avere due cifre);
  5. "-1.50" diventa Ok(-150).

Lancia gleam test. Uno dei cinque fallisce: leggi left e right, capisci perché, e correggi parse_cents finché tutti i test passano.

Mostra una soluzione (prima prova da solo!)

Il test che fallisce è l’ultimo: left: Ok(-50), right: Ok(-150). Il meno finisce solo sugli euro: -1 * 100 + 50 fa -50. Una correzione: se il testo comincia con -, leggere il resto e cambiare segno al risultato.

src/money.gleam
import gleam/bool
import gleam/int
import gleam/result
import gleam/string

/// Formats an amount in cents as euros with two decimals: 690 -> "6.90".
pub fn format_cents(cents: Int) -> String {
  case cents < 0 {
    True -> "-" <> format_cents(-cents)
    False ->
      int.to_string(cents / 100)
      <> "."
      <> { cents % 100 |> int.to_string |> string.pad_start(2, "0") }
  }
}

/// Parses an amount like "6.90" into cents: Ok(690).
pub fn parse_cents(text: String) -> Result(Int, Nil) {
  case text {
    "-" <> rest -> parse_cents(rest) |> result.map(fn(cents) { -cents })
    _ -> parse_positive(text)
  }
}

fn parse_positive(text: String) -> Result(Int, Nil) {
  use #(euros_text, cents_text) <- result.try(string.split_once(text, on: "."))
  use <- bool.guard(when: string.length(cents_text) != 2, return: Error(Nil))
  use euros <- result.try(int.parse(euros_text))
  use cents <- result.try(int.parse(cents_text))
  Ok(euros * 100 + cents)
}
test/money_test.gleam
import money

pub fn format_cents_test() {
  assert money.format_cents(690) == "6.90"
}

pub fn format_small_amount_test() {
  assert money.format_cents(5) == "0.05"
}

pub fn format_negative_test() {
  assert money.format_cents(-150) == "-1.50"
}

pub fn parse_cents_test() {
  assert money.parse_cents("6.90") == Ok(690)
}

pub fn parse_small_amount_test() {
  assert money.parse_cents("0.05") == Ok(5)
}

pub fn parse_not_a_number_test() {
  assert money.parse_cents("six") == Error(Nil)
}

pub fn parse_one_cent_digit_test() {
  assert money.parse_cents("6.9") == Error(Nil)
}

pub fn parse_negative_test() {
  assert money.parse_cents("-1.50") == Ok(-150)
}

gleam test ora risponde 9 passed, no failures (gli otto test di money_test più quello di esempio). Il pattern "-" <> rest della lezione 2.5 separa il segno, e parse_positive, privata, fa il lavoro sui soli numeri positivi.

Ricapitolando

  • I test stanno in test/, in moduli il cui nome finisce in _test, e si lanciano con gleam test.
  • Un test è una funzione pub, senza argomenti, con il nome che finisce in _test; dentro, assert condizione.
  • Le funzioni da provare devono essere pub in un modulo di src/, importato dal test.
  • Un test fallito mostra file, riga, codice, e i valori left (ottenuto) e right (atteso).
  • Conviene testare un caso normale, i casi ai bordi e i casi d’errore: i bug vivono ai bordi.
  • Un errore di compilazione in test/ ferma anche gleam run.
  • gleam test esce con 1 se un test fallisce: è così che i controlli automatici sanno se il progetto è sano.

Nella prossima lezione la sfida del modulo: il robot del modulo 4 impara a leggere i comandi da un testo, e a dire con gentilezza quando non li capisce.