Un ejecutor de pruebas para SRFI 64

Abstract

Sobre mi primera implementación de un ejecutor de pruebas personalizado para agregar un poquito más de legibilidad y color a los reportes de pruebas de código escrito en Guile Scheme.

Esta semana empiezo a estudiar algunas técnicas de Desarrollo guiado por pruebas (o TDD, Test-driven development) de un libro gratuito que ojeé el mes pasado: TDD, de Jason Gorman. Los ejemplos y ejercicios del libro están escritos en Java, pero yo voy a intentar hacerlos en Guile usando SRFI 64, una API de Scheme para colecciones de pruebas.

En proyectos anteriores ya había escrito pruebas unitarias usando SRFI 64, pero hay algunas cosas que no me gustan de la forma como el ejecutor de pruebas (test runner) predeterminado de Guile muestra los resultados, así que decidí escribir uno propio.

La biblioteca ABC

Como ejemplo, voy a usar en esta entrada una biblioteca cuyos archivos fuente están organizados así:

abc
├── abclib.scm
└── test-suite.scm

El archivo abclib.scm es la biblioteca y test-suite.scm es la colección de pruebas para la misma. La colección de pruebas es esta:

;;; ABC Library's Test Suite.
;;; Public domain 2019. All rights waived.

(use-modules (abclib)
             (srfi srfi-64))


(define SUITE-NAME "ABC Library Test Suite")

(test-begin SUITE-NAME)

(test-group
 "[procedure] area-of-square"
 (test-equal
  "The area of a square of side 5 is 25."
  (* 5 5)
  (area-of-square 5)))



(test-group
 "[procedure] palindrome?"
 (test-assert "Radar is palindrome." (palindrome? "Radar"))
 (test-assert "Giraffe is not palindrome." (not (palindrome? "Giraffe"))))



(test-group
 "[procedure] string-first"
 (test-equal "The first character in Alacrity is A."
             #\A
             (string-first "Alacrity"))
 (test-equal "The first character in empty string is empty string."
             ""
             (string-first "")))

(test-end SUITE-NAME)

Este archivo define un nombre para la colección de pruebas y define pruebas para tres procedimientos que son funciones puras: area-of-square, palindrome? y string-first. Las pruebas para cada uno de los procedimientos las organizo en grupos.

Por el otro lado, la biblioteca está definida así:

;;; ABC Library.
;;; Public domain 2019. All rights waived.

(define-module (abclib)
  #:export (area-of-square
            palindrome?
            string-first))


(define (area-of-square side)
  "Return the area of a square of the given SIDE."
  (* side side))

(define (palindrome? word)
  "Return true if the given WORD is a palindrome."
  #false)

(define (string-first string)
  "Return the first caracter of the given STRING."
  #\Z)

Como se ve, solamente el procedimiento area-of-square está implementado para satisfacer las pruebas. Los demás solamente devuelven valores literales temporales que hacen fallar algunas de las pruebas.

Ejecutor de Guile

Ejecutemos la suite de pruebas para la biblioteca ABC en un terminal:

$ cd /ruta/a/abc
$ guile test-suite.scm

La colección de pruebas de ABC no indica que debe usarse un ejecutor específico, así que Guile usa su ejecutor predeterminado para reportar los resultados, que se ven así:

%%%% Starting test ABC Library Test Suite  (Writing full log to "ABC Library Test Suite.log")
/home/sirgazil/abc/test-suite.scm:23: FAIL Radar is palindrome.
/home/sirgazil/abc/test-suite.scm:30: FAIL The first character in Alacrity is A.
/home/sirgazil/abc/test-suite.scm:33: FAIL The first character in empty string is empty string.
# of expected passes      2
# of unexpected failures  3

Este es un resumen de los resultados. Como dice el reporte, los detalles están en el archivo ABC Library Test Suite.log. El contenido de este archivo se ve así:

%%%% Starting test ABC Library Test Suite
Group begin: ABC Library Test Suite
Group begin: [procedure] area-of-square
Test begin:
  test-name: "The area of a square of side 5 is 25."
  source-file: "/home/sirgazil/Escritorio/abc/test-suite.scm"
  source-line: 14
  source-form: (test-equal "The area of a square of side 5 is 25." (* 5 5) (area-of-square 5))
Test end:
  result-kind: pass
  actual-value: 25
  expected-value: 25
Group end: [procedure] area-of-square
Group begin: [procedure] palindrome?
Test begin:
  test-name: "Radar is palindrome."
  source-file: "/home/sirgazil/Escritorio/abc/test-suite.scm"
  source-line: 23
  source-form: (test-assert "Radar is palindrome." (palindrome? "Radar"))
Test end:
  result-kind: fail
  actual-value: #f
Test begin:
  test-name: "Giraffe is not palindrome."
  source-file: "/home/sirgazil/Escritorio/abc/test-suite.scm"
  source-line: 24
  source-form: (test-assert "Giraffe is not palindrome." (not (palindrome? "Giraffe")))
Test end:
  result-kind: pass
  actual-value: #t
Group end: [procedure] palindrome?
Group begin: [procedure] string-first
Test begin:
  test-name: "The first character in Alacrity is A."
  source-file: "/home/sirgazil/Escritorio/abc/test-suite.scm"
  source-line: 30
  source-form: (test-equal "The first character in Alacrity is A." #\A (string-first "Alacrity"))
Test end:
  result-kind: fail
  actual-value: #\Z
  expected-value: #\A
Test begin:
  test-name: "The first character in empty string is empty string."
  source-file: "/home/sirgazil/Escritorio/abc/test-suite.scm"
  source-line: 33
  source-form: (test-equal "The first character in empty string is empty string." "" (string-first ""))
Test end:
  result-kind: fail
  actual-value: #\Z
  expected-value: ""
Group end: [procedure] string-first
Group end: ABC Library Test Suite
# of expected passes      2
# of unexpected failures  3

Del reporte general no me gusta lo siguiente:

  • La ubicación del mensaje sobre el log. Yo lo pondría al final de los resultados.
  • La información está apeñuscada verticalmente. Yo usaría líneas en blanco para separar el nombre de la colección de los resultados de pruebas individuales fallidas y del conteo de pruebas.
  • No dice el motivo de la falla de cada prueba listada.
  • No dice el total de pruebas ejecutadas.
  • No hay color. Por ejemplo, el color rojo se usa convencionalmente para resaltar pruebas fallidas.

El reporte detallado también tiene el problema del apeñuscamiento y la ausencia del total de pruebas.

Ejecutor personalizado

Ahora ejecutemos las pruebas con mi ejecutor personalizado, cuya intención en general es mejorar la legibilidad de los resultados.

Usando el mismo ejemplo de la biblioteca ABC, se puede «instalar» el ejecutor propio antes de que se evalue el código que define las pruebas. En este caso, lo hago en el archivo test-suite.scm, antes de test-begin. Entonces:

;;; ABC Library's Test Suite.
;;; Public domain 2019. All rights waived.

(use-modules (abclib)
         (glab testing)
         (srfi srfi-64))


;;; INSTALL CUSTOM TEST RUNNER
;;; ==========================

(test-runner-factory
 (lambda () (glab-runner "/tmp/abc-test-suite.log")))



;;; TEST SUITE
;;; ==========

(define SUITE-NAME "ABC Library Test Suite")

(test-begin SUITE-NAME)

(test-group
 "[procedure] area-of-square"
 (test-equal
  "The area of a square of side 5 is 25."
  (* 5 5)
  (area-of-square 5)))



(test-group
 "[procedure] palindrome?"
 (test-assert "Radar is palindrome." (palindrome? "Radar"))
 (test-assert "Giraffe is not palindrome." (not (palindrome? "Giraffe"))))



(test-group
 "[procedure] string-first"
 (test-equal "The first character in Alacrity is A."
         #\A
         (string-first "Alacrity"))
 (test-equal "The first character in empty string is empty string."
         ""
         (string-first "")))

(test-end SUITE-NAME)

Aquí, glab-runner es un procedimiento que definí en el módulo (glab testing) y se ve así:

(define (glab-runner filename)
  "Return a custom test runner.

   FILENAME (string)
     Absolute path to a file to log detailed results of tests. For
     example: '/tmp/mylib-test-suite.log'.

   RETURN VALUE (<test-runner>)
     A test runner as defined in the SRFI-64 specification."
  (let ((runner (test-runner-null))
    (port (open-output-file filename)))
    ;; Report individual test results.
    (test-runner-on-test-end!
     runner
     (lambda (runner)
       (format port "~a~%" (format-test-result runner))
       (case (test-result-kind runner)
     ((fail xfail) (format #t "~a~%" (format-test-result runner #:colored #true)))
     (else #true)))) ; <- Why not omit the else? An event-function can't return unspecified?

    ;; Report test suite general results.
    (test-runner-on-final!
     runner
     (lambda (runner)
       (display-general-results runner filename)
       (log-general-results runner port)
       (close-output-port port)))
    runner))

Este procedimiento está basado en el ejemplo que se da en la especificación de la SRFI 64 para escribir un ejecutor personalizado.

Ahora ejecutemos las pruebas con el ejecutor nuevo:

$ cd /ruta/a/abc
$ guile test-suite.scm

Así se ven los resultados en el terminal:

Resultados de las pruebas en un terminal.

Los detalles están organizados de manera similar en el archivo abc-test-suite.log:

✔ PASS The area of a square of side 5 is 25.
File: /home/sirgazil/Escritorio/abc/test-suite.scm:26
Group: [procedure] area-of-square
Expected value: 25
Actual value: 25

✖ FAIL Radar is palindrome.
File: /home/sirgazil/Escritorio/abc/test-suite.scm:35
Group: [procedure] palindrome?
Expected value: #t
Actual value: #f

✔ PASS Giraffe is not palindrome.
File: /home/sirgazil/Escritorio/abc/test-suite.scm:36
Group: [procedure] palindrome?
Expected value: #t
Actual value: #t

✖ FAIL The first character in Alacrity is A.
File: /home/sirgazil/Escritorio/abc/test-suite.scm:42
Group: [procedure] string-first
Expected value: A
Actual value: Z

✖ FAIL The first character in empty string is empty string.
File: /home/sirgazil/Escritorio/abc/test-suite.scm:45
Group: [procedure] string-first
Expected value: ""
Actual value: Z

------------------------------------------------------------
TOTAL TESTS: 5
PASSED:      2
FAILED:      3

Por ahora me gusta y me parece suficiente para empezar a estudiar el libro de TDD con una visualización más cómoda de los resultados de las pruebas. Lo que falte lo voy a ir puliendo poco a poco mientras avanzo en el libro.