ShellCheck dejó el toolkit limpio de errores de forma, pero no sabe nada de lo que hace. No puede decirte si veloz_porcentaje 0 0 devuelve algo sensato en lugar de reventar, si respaldo.sh respeta de verdad los treinta días de retención, ni si el awk con el que aceleramos el informe en 08-02 produce las mismas cifras que el bucle que sustituyó. Eso solo lo responde ejecutar el código y comparar el resultado con lo esperado. Esta lección responde a la pregunta con la que abría el módulo —¿cómo sé que un cambio no rompe nada?— con Bats, el marco de pruebas de Bash.
Contenido
- Por qué los scripts de operaciones se prueban poco, y por qué es un error
- Qué merece la pena probar y qué no
- Probar a mano primero
- Instalar
bats-corey sus librerías - Anatomía de un fichero
.bats - Ejecutar las pruebas
- Aislar la prueba del entorno
- Dobles de prueba: sustituir un comando externo
- Estructura de
tests/en el toolkit - Hook, CI y criterio de cobertura
- Aplicación:
tests/comun.bats
- Por qué los scripts de operaciones se prueban poco, y por qué es un error
Nadie discute que una aplicación web se prueba. Con los scripts de sistema la costumbre es otra: se ejecutan a mano una vez, «funciona», y a producción. Las excusas habituales tienen su lógica aparente: «es solo un script de 80 líneas» —que ejecuta rm -rf como root cada noche—, «lo pruebo ejecutándolo» —una vez, con los datos de hoy, en tu máquina, con tu PATH— y «no hay forma de probar algo que toca el sistema», que es falso y se resuelve en el apartado 8.
El argumento a favor es más fuerte que todos esos: los scripts de operaciones fallan de madrugada y sin nadie delante. Una aplicación web caída la ve un usuario en diez segundos; respaldo.sh puede llevar tres semanas guardando archivos vacíos y nadie se entera hasta que hay que restaurar. Y hay un segundo argumento más práctico: las pruebas hacen posible refactorizar. Todo el módulo 8 consiste en cambiar código que funciona; sin pruebas, cada cambio es un acto de fe, y con ellas el refactor de rendimiento de 08-02 se valida en dos segundos.
- Qué merece la pena probar y qué no
| Qué | Rentabilidad | Por qué |
|---|---|---|
Funciones puras de lib/comun.sh (veloz_porcentaje, validadores) |
Muy alta | Entrada → salida, sin efectos; se prueban en milisegundos |
Funciones con efectos acotados (veloz_log, escritura de ficheros) |
Alta | Se comprueba el fichero resultante en un temporal |
| Script completo: código de salida, salida, ficheros que genera | Media-alta | Es lo que de verdad usa el usuario, y donde más bugs silenciosos hay |
systemctl, ssh a la flota, envío de correo real |
Baja | Se prueban con dobles, no de verdad |
| Cabeceras, colores del terminal | Nula | Se ve a simple vista y cambia constantemente |
La regla: prueba la lógica, no la fontanería. Si una función decide algo —un umbral, un formato, una validación, una suma—, pruébala. Si solo encadena dos órdenes del sistema, comprueba el conjunto y no las piezas.
- Probar a mano primero
Antes de instalar nada conviene ver qué automatiza Bats. Una función se prueba en una sesión interactiva:
$ source ~/veloz-ops/lib/comun.sh
$ veloz_porcentaje 412 1284
32.1
$ veloz_porcentaje 0 0
veloz_porcentaje: total no puede ser cero # y $? vale 1
# El mismo dialogo, escrito como comprobacion automatica
[[ $(veloz_porcentaje 412 1284) == "32.1" ]] || echo "FALLO: porcentaje basico"
veloz_porcentaje 0 0 2>/dev/null && echo "FALLO: deberia fallar con total 0"Esto ya es una prueba: ejecutar con una entrada conocida y comparar con la salida esperada. Para tres comprobaciones puede bastar. Lo que Bats aporta cuando pasas de tres es todo lo que aquí falta: nombres legibles por caso, que un fallo no detenga los demás, capturar a la vez salida y código de salida, preparar y limpiar el entorno de cada prueba y un resumen final con la cuenta de aciertos y fallos.
- Instalar
bats-core y sus librerías
bats-core y sus libreríasbats-core es el proyecto mantenido hoy (el original sstephenson/bats está abandonado).
$ sudo apt install bats # rapido, version algo antigua
# O como submodulos del repositorio, que es lo recomendable en equipo (08-04)
$ git submodule add https://github.com/bats-core/bats-core.git tests/bats
$ git submodule add https://github.com/bats-core/bats-support.git tests/lib/bats-support
$ git submodule add https://github.com/bats-core/bats-assert.git tests/lib/bats-assertLas dos librerías auxiliares no son imprescindibles, pero mejoran mucho los mensajes: bats-support aporta el formato de errores y bats-assert las comprobaciones assert_success, assert_failure, assert_output, assert_line y refute_output, que al fallar muestran qué esperaban y qué obtuvieron en vez de un escueto «no pasó».
- Anatomía de un fichero
.bats
.batsUn .bats es un script de Bash con una sintaxis añadida: el bloque @test.
#!/usr/bin/env bats
# tests/comun.bats - pruebas de la libreria del toolkit
setup() {
load 'lib/bats-support/load'
load 'lib/bats-assert/load'
source "${BATS_TEST_DIRNAME}/../lib/comun.sh"
}
@test "veloz_porcentaje calcula con un decimal" {
run veloz_porcentaje 412 1284
assert_success
assert_output "32.1"
}Las piezas, una a una:
@test "descripción" { ... }: cada prueba con un nombre que se lee en la salida y que debe decir qué comportamiento se espera, no «prueba 1».run orden ...: ejecuta la orden capturando su salida y su código de salida sin que el fallo aborte la prueba. Es la pieza central de Bats.$status: código de salida de lo que ejecutórun.$output: salida combinada (stdout + stderr).${lines[@]}: la misma salida como array, una línea por elemento.setup()se ejecuta antes de cada prueba yteardown()después, incluso si falla;setup_file()/teardown_file()una sola vez por fichero, para preparativos caros.load ficherocarga un.bashrelativo al directorio de la prueba,skip "motivo"omite una prueba yBATS_TEST_DIRNAMEes el directorio del.bats, para construir rutas independientes de dónde se lance.
Sin bats-assert las mismas comprobaciones se escriben con [ "$status" -eq 0 ] y [ "$output" = "32.1" ], que también es válido.
- Ejecutar las pruebas
$ bats tests/
comun.bats
✓ veloz_porcentaje calcula con un decimal
✓ veloz_porcentaje falla si el total es cero
✗ veloz_requiere detecta una orden ausente
(in test file tests/comun.bats, line 41)
-- command succeeded, but it was expected to fail --
3 tests, 1 failureOpciones útiles: --filter 'porcentaje' ejecuta solo las pruebas cuyo nombre case con el patrón, -t produce salida TAP pura —el formato estándar que consumen los sistemas de CI—, -x traza cada orden para depurar y -j 4 ejecuta en paralelo. El código de salida es 0 si todo pasa y 1 si algo falla, que es justo lo que necesitan el hook y CI.
- Aislar la prueba del entorno
El error más común al empezar es escribir pruebas que dependen de la máquina: del /srv/veloz/datos real, del huso horario, de que exista jq. Esas pruebas pasan en tu portátil, fallan en CI y acaban desactivadas. La solución es que cada prueba construya su propio mundo en un directorio temporal:
setup() {
load 'lib/bats-support/load'
load 'lib/bats-assert/load'
export TZ=UTC LC_ALL=C # nada de formatos locales
DIR_PRUEBA="$(mktemp -d "${BATS_TMPDIR}/veloz.XXXXXX")"
export VELOZ_DIR_DATOS="$DIR_PRUEBA/datos" VELOZ_DIR_LOGS="$DIR_PRUEBA/logs"
mkdir -p "$VELOZ_DIR_DATOS" "$VELOZ_DIR_LOGS"
cat > "$VELOZ_DIR_DATOS/envios.csv" <<'EOF'
id_envio,fecha,ciudad,repartidor,estado,importe
E000001,2026-08-03,Valencia,alopez,entregado,24.50
E000002,2026-08-03,Sevilla,mgarcia,incidencia,31.00
E000003,2026-08-03,Valencia,jruiz,en_reparto,18.75
EOF
source "${BATS_TEST_DIRNAME}/../lib/comun.sh"
}
teardown() { rm -rf "$DIR_PRUEBA"; }Tres envíos bastan: hay una ciudad repetida, dos estados y tres repartidores, que es todo lo que necesita cualquier agregación. BATS_TMPDIR es el temporal que gestiona Bats; en versiones recientes existe además BATS_TEST_TMPDIR, creado y borrado automáticamente por prueba.
- Dobles de prueba: sustituir un comando externo
Esta es la técnica que hace probables los scripts de sistema, y es más simple de lo que parece. Bash busca las órdenes recorriendo $PATH de izquierda a derecha; si pones al principio un directorio con un script llamado curl, ese es el curl que se ejecuta.
setup() {
DIR_PRUEBA="$(mktemp -d "${BATS_TMPDIR}/veloz.XXXXXX")"
mkdir -p "$DIR_PRUEBA/bin"
cat > "$DIR_PRUEBA/bin/curl" <<'EOF'
#!/usr/bin/env bash
printf '%s\n' "$*" >> "$REGISTRO_LLAMADAS" # anota como se le llamo
printf '{"estado":"ok","envios":128}\n'
EOF
chmod +x "$DIR_PRUEBA/bin/curl"
export REGISTRO_LLAMADAS="$DIR_PRUEBA/llamadas.txt"
export PATH="$DIR_PRUEBA/bin:$PATH" # el doble tiene prioridad
source "${BATS_TEST_DIRNAME}/../lib/comun.sh"
}
@test "veloz_api_get devuelve el JSON de la API" {
run veloz_api_get /metricas
assert_success
assert_output --partial '"envios":128'
}
@test "veloz_api_get usa --netrc y no credenciales en la linea de ordenes" {
veloz_api_get /salud >/dev/null
run grep -c -- '--netrc' "$REGISTRO_LLAMADAS"
assert_output "1"
}El doble consigue tres cosas, y solo la primera es «no usar la red». Determinismo: la respuesta es siempre la misma, así que la prueba no falla porque la API esté lenta. Casos imposibles de provocar: cambiando el doble para que devuelva exit 7 o un JSON truncado se prueba el manejo de errores, que es justo el código que nunca se ejercita a mano. Y verificación de la llamada: el registro permite comprobar cómo se invocó, que es como se ha probado arriba el uso de --netrc de 08-03.
La misma técnica cubre el resto: un doble de systemctl que devuelva inactive prueba la rama de alerta de estado-servicio.sh; uno de ssh prueba flota.sh sin salir de la máquina; uno de mail verifica que vigilante.sh avisa sin enviar nada a nadie. Y para respaldo.sh no hace falta doble: basta apuntar VELOZ_DIR_DATOS y VELOZ_DIR_RESPALDO al temporal para que trabaje sobre cuatro ficheros ficticios en lugar de sobre el disco real.
- Estructura de
tests/ en el toolkit
tests/ en el toolkitEl directorio tests/ contiene los submódulos (bats/, lib/) y un .bats por unidad probada: comun.bats para las funciones de la librería, informe.bats para informe-diario.sh de extremo a extremo y respaldo.bats para la retención y la restauración.
| Fichero | Casos normales | Casos límite | Casos de error |
|---|---|---|---|
comun.bats |
veloz_porcentaje 412 1284 → 32.1 |
numerador 0, total 0, valor mayor que el total | argumentos no numéricos → código 1 |
informe.bats |
CSV de 4 filas → totales por ciudad | CSV solo con cabecera, campo vacío | CSV inexistente → código 2 y mensaje |
respaldo.bats |
crea el .tar.gz y su .sha256 |
0 ficheros; nombre con espacios | destino sin permisos → código ≠ 0 |
Los casos límite son los que más bugs encuentran: un CSV con solo la cabecera hace que muchos informes dividan entre cero, un importe 1.234,50 rompe la suma y un fichero con espacios desmonta el find mal escrito. Y probar el código de salida no es un detalle: es lo único que ve el timer de systemd de 07-05 y lo que decide si se envía una alerta.
- Hook, CI y criterio de cobertura
El hook pre-commit de 08-04 ya invocaba bats tests/. En CI se añade junto al análisis estático, con bats --formatter tap tests/. Un detalle importante para que esto sea sostenible: las pruebas deben ser rápidas. Si tardan cuarenta segundos, el hook se vuelve molesto y alguien empezará a usar --no-verify; con dobles y datos ficticios, la suite completa del toolkit tarda menos de dos segundos, y eso es lo que garantiza que se ejecute siempre.
No hay que probarlo todo. El criterio, por orden de prioridad: lo que ya se ha roto alguna vez —cada incidente resuelto se convierte en una prueba, y esta es la regla más valiosa, porque garantiza que el mismo fallo no vuelve y hace crecer la suite justo por donde el sistema es frágil—; lo que decide algo (umbrales, validaciones, cálculos); lo que es destructivo (retención, borrado, sobrescritura), porque un fallo ahí no se deshace; y los códigos de salida de los scripts que gobiernan alertas y timers. Perseguir el 100 % de cobertura en Bash produce pruebas frágiles que se rompen con cada cambio de formato y acaban desactivadas, que es peor que no tenerlas.
- Aplicación:
tests/comun.bats
tests/comun.bats#!/usr/bin/env bats
# tests/comun.bats - pruebas de lib/comun.sh del toolkit de Veloz Envios
# setup() y teardown() como en el apartado 7, con VELOZ_DIR_LOGS en el temporal
@test "porcentaje: caso normal con un decimal" {
run veloz_porcentaje 412 1284
assert_success
assert_output "32.1"
}
@test "porcentaje: numerador cero devuelve 0.0" {
run veloz_porcentaje 0 1284
assert_success
assert_output "0.0"
}
@test "porcentaje: total cero falla y no divide" {
run veloz_porcentaje 5 0
assert_failure
assert_output --partial "total"
}
@test "requiere: falla y nombra la orden ausente" {
run veloz_requiere orden_que_no_existe_12345
assert_failure
assert_output --partial "orden_que_no_existe_12345"
}
@test "log: escribe nivel, mensaje y marca de tiempo ISO-8601" {
veloz_log INFO "respaldo completado"
run cat "$VELOZ_DIR_LOGS/veloz-ops.log"
assert_output --partial "[INFO] respaldo completado"
assert_line --regexp '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}'
}
@test "log: no interpreta el contenido del mensaje" {
veloz_log INFO 'peligro $(id) y *'
run cat "$VELOZ_DIR_LOGS/veloz-ops.log"
assert_output --partial 'peligro $(id) y *'
}Cinco pruebas, menos de dos segundos, y cubren los tres tipos de caso (a las que conviene añadir el caso positivo veloz_requiere bash). La última merece atención: comprueba que veloz_log trata su argumento como texto y no lo expande, que es exactamente la vulnerabilidad de inyección de 08-03. Una prueba puede vigilar una propiedad de seguridad igual que vigila un cálculo.
Errores Comunes y Consejos
- Olvidar
run. Sin él, una orden que falla aborta la prueba y no puedes comprobar$status. - Pruebas que dependen de la máquina. Rutas reales, huso horario,
jqinstalado. Aísla todo ensetupcon temporal,TZyLC_ALL. - No limpiar en
teardown. Las pruebas se contaminan entre sí y fallan según el orden de ejecución. - Probar solo el camino feliz. Los bugs viven en el CSV vacío, el campo ausente y el disco lleno.
- Consejo: convierte cada incidente en una prueba. Es la forma más barata de que la suite cubra justo lo que importa.
- Consejo:
bats -x --filter 'nombre'ejecuta una sola prueba con traza completa; es elset -xde 05-03 aplicado a las pruebas.
Ejercicios
Ejercicio 1. Escribe una prueba Bats que verifique que informe-diario.sh falla con código 2 y un mensaje en stderr cuando el CSV de entrada no existe.
Ejercicio 2. Explica por qué esta prueba está mal y reescríbela.
Soluciones
Solución 1.
@test "informe: CSV inexistente falla con codigo 2 y mensaje" {
run "${BATS_TEST_DIRNAME}/../bin/informe-diario.sh" -f "$DIR_PRUEBA/no-existe.csv"
assert_failure 2
assert_output --partial "no-existe.csv"
}assert_failure 2 exige el código exacto, no un fallo cualquiera: es lo que distingue «error de uso» de «error interno» según los códigos convencionales de 05-03. Como run captura stdout y stderr juntos, assert_output ve el mensaje aunque se escriba en stderr.
Solución 2. Tiene cuatro problemas: usa una ruta absoluta del usuario, así que solo funciona en esa máquina; ejecuta el respaldo sobre los datos reales, con riesgo de sobrescribir el respaldo de producción desde una prueba; solo comprueba el código de salida, no que el respaldo contenga algo; y su nombre no dice qué comportamiento se espera.
@test "respaldo: crea el tar.gz y su suma de comprobacion" {
export VELOZ_DIR_DATOS="$DIR_PRUEBA/datos" VELOZ_DIR_RESPALDO="$DIR_PRUEBA/resp"
mkdir -p "$VELOZ_DIR_DATOS" "$VELOZ_DIR_RESPALDO"
printf 'contenido\n' > "$VELOZ_DIR_DATOS/envios.csv"
run "${BATS_TEST_DIRNAME}/../bin/respaldo.sh"
assert_success
run bash -c 'ls "$VELOZ_DIR_RESPALDO"/*.tar.gz "$VELOZ_DIR_RESPALDO"/*.sha256'
assert_success
run tar -tzf "$(ls "$VELOZ_DIR_RESPALDO"/*.tar.gz)"
assert_output --partial "envios.csv"
}Ahora la prueba es independiente de la máquina, trabaja sobre datos ficticios y verifica el efecto: que el archivo existe, que existe su suma de comprobación (07-03) y que dentro está el fichero esperado.
Conclusión
Probar scripts de operaciones no es un lujo: es la única detección temprana disponible en un código que se ejecuta de madrugada y sin nadie delante, donde un respaldo vacío puede pasar semanas inadvertido. Lo más rentable son las funciones puras de lib/comun.sh, seguidas del comportamiento de extremo a extremo de cada script —código de salida, salida y ficheros que crea—; la fontanería sin lógica no se prueba. La idea es la misma que la comprobación casera [[ $(f x) == esperado ]] || echo FALLO, y Bats añade lo que a esa le falta: nombres legibles, aislamiento entre casos, resumen final y, sobre todo, run, que captura $status, $output y ${lines[@]} sin que un fallo aborte la prueba. Con setup/teardown cada caso construye su mundo en un directorio temporal con datos ficticios de Veloz Envíos y fija TZ y LC_ALL, de modo que la prueba no dependa de la máquina —el motivo principal por el que las suites acaban desactivadas—. Los dobles de prueba son la técnica que lo hace todo posible: un script falso llamado curl, systemctl, ssh o mail en un directorio al principio del PATH permite probar veloz_api_get sin red, flota.sh sin servidores y respaldo.sh sin tocar el disco, además de provocar los errores que jamás verías a mano y de verificar cómo se invocó la orden. La suite se organiza en tests/comun.bats, tests/informe.bats y tests/respaldo.bats, cubriendo en cada una casos normales, límite y de error con sus códigos de salida, y se ejecuta en el hook pre-commit y en CI (08-04, 08-05), lo que exige que sea rápida: con dobles, menos de dos segundos. Y el criterio de cobertura no es un porcentaje, sino una prioridad: lo que ya se rompió una vez, lo que decide algo y lo que es destructivo.
Quedan un legible, un rápido, un auditado, un versionado, un analizado y un probado. Falta una decisión que hemos dado por supuesta todo el curso: que el intérprete es Bash 5 sobre Ubuntu. La lección 08-07 la pone a prueba: qué ocurre cuando un script tiene que ejecutarse donde /bin/sh es dash, ash de Alpine o ksh, qué construcciones de las que llevamos usando son bashismos y cuál es su equivalente POSIX, por qué las diferencias entre las herramientas GNU y BSD duelen más que las del propio shell, cómo comprobar la portabilidad con checkbashisms, shellcheck -s sh y dash -n, y cuándo POSIX puro merece la pena y cuándo es un lastre. Con esa lección se cierra el módulo entero.
Curso de Programación en Bash
Módulo 1: Introducción a Bash
- ¿Qué es Bash?
- Configurando tu Entorno
- Navegación Básica en la Línea de Comandos
- Entendiendo el Shell
- Encontrar Ayuda: man, help y --help
Módulo 2: Comandos Básicos de Bash
- Operaciones con Archivos y Directorios
- Comandos de Procesamiento de Texto
- Permisos y Propiedad de Archivos
- Redirección y Tuberías
- Comodines y Expansión de Rutas
- Historial y Atajos de Teclado
Módulo 3: Fundamentos de Scripting
- Creando y Ejecutando un Script
- Variables y Constantes
- Operadores Básicos
- Sentencias Condicionales
- Argumentos y Entrada del Usuario
- Comillas, Expansión y Sustitución
Módulo 4: Scripting Intermedio
- Bucles en Bash
- Funciones en Bash
- Arrays y Arrays Asociativos
- Manipulación de Cadenas
- La Sentencia case y los Menús Interactivos
- Aritmética y Cálculos Numéricos
Módulo 5: Técnicas Avanzadas de Scripting
- Operaciones Avanzadas con Archivos
- Gestión de Procesos
- Manejo de Errores y Depuración
- Expresiones Regulares
- Entrada/Salida Avanzada: Descriptores y Here-Documents
- Scripts Modulares y Librerías Reutilizables
Módulo 6: Trabajando con Herramientas Externas
Módulo 7: Automatización y Programación
- Trabajos Cron
- Automatizando Tareas
- Scripts de Respaldo y Restauración
- Monitoreo y Registro
- Servicios y Temporizadores con systemd
- Automatización Remota con SSH
Módulo 8: Mejores Prácticas y Optimización
- Escribiendo Código Legible
- Optimizando Scripts en Bash
- Consideraciones de Seguridad
- Control de Versiones con Git
- Análisis Estático con ShellCheck y shfmt
- Pruebas Automatizadas con Bats
- Portabilidad: POSIX sh frente a Bashismos
