En la década de 1960, se consideraba una buena práctica de base en la ingeniería de software para probar su código a medida que lo escribía. Los pioneros del desarrollo de software en esa época eran partidarios de varios niveles de pruebas; algunos abogaban por las pruebas «unitarias» y otros no, pero todos reconocían la importancia de probar el código.
Las pruebas ejecutables pueden haber sido introducidas por primera vez por Margaret Hamilton en el proyecto Apolo a mediados de la década de 1960, donde originó un tipo de comprobación ejecutable que ahora llamamos «análisis de código estático». Ella lo llamaba «software de orden superior», con lo que se refería a un software que opera contra otro software en lugar de hacerlo directamente contra el dominio del problema. Su software de orden superior examinaba el código fuente en busca de patrones que se sabía que conducían a problemas de integración.
En 1970, la gente se había olvidado en gran medida de las pruebas ejecutables. Por supuesto, la gente ejecutaba las aplicaciones y las revisaba aquí y allá a mano, pero mientras el edificio no se quemara a su alrededor, pensaban que el código era «suficientemente bueno». El resultado ha sido más de 35 años de código en producción en todo el mundo que se prueba de forma inadecuada, y que en muchos casos no funciona del todo como se pretendía, o de una forma que satisfaga a sus clientes.
La idea de que los programadores prueben a medida que avanzan hizo una reaparición a partir de mediados de la década de 1990, aunque hasta el momento la gran mayoría de los programadores siguen sin hacerlo. Los ingenieros de infraestructuras y los administradores de sistemas prueban sus scripts incluso con menos diligencia que los programadores prueban el código de sus aplicaciones.
A medida que nos adentramos en una era en la que el despliegue rápido de soluciones complicadas que comprenden numerosos componentes autónomos se está convirtiendo en la norma, y las infraestructuras «en la nube» nos obligan a gestionar miles de máquinas virtuales y contenedores que van y vienen a una escala que no se puede gestionar con métodos manuales, no se puede ignorar la importancia de las pruebas y comprobaciones automatizadas y ejecutables a lo largo del proceso de desarrollo y entrega; no sólo para los programadores de aplicaciones, sino para todos los que participan en el trabajo de TI.
Con la llegada de devops (la polinización cruzada de habilidades, métodos y herramientas de desarrollo y operaciones), y las tendencias como «la infraestructura como código» y «automatizar todas las cosas», las pruebas unitarias se han convertido en una habilidad básica para programadores, probadores, administradores de sistemas e ingenieros de infraestructura por igual.
En esta serie de posts, introduciremos la idea de las pruebas unitarias de los shell scripts, y luego exploraremos varios marcos de pruebas unitarias que pueden ayudar a hacer esa tarea práctica y sostenible a escala.
Otra práctica que puede ser desconocida para muchos ingenieros de infraestructura es el control de versiones. Más adelante en esta serie, tocaremos los sistemas de control de versiones y los flujos de trabajo que utilizan los desarrolladores de aplicaciones, y que pueden ser eficaces y útiles para los ingenieros de infraestructura, también.
- Un script para probar
- Comprobaciones funcionales automatizadas
- Aprobar, fallar y error
- ¿Qué debemos comprobar?
- ¿Qué no debemos comprobar?
- Improvisar el comando df
- Simular el comando mail
- Patrón para ejecutar comprobaciones automatizadas
- ¿Por qué usar un marco de pruebas/biblioteca?
- Marcos de pruebas unitarias para scripts
Un script para probar
Vivek Gite publicó un script de shell de ejemplo para supervisar el uso del disco y generar una notificación por correo electrónico cuando ciertos sistemas de archivos superan un umbral. Su artículo está aquí: https://www.cyberciti.biz/tips/shell-script-to-watch-the-disk-space.html. Vamos a utilizarlo como objeto de prueba.
La versión inicial de su script, con la adición de la opción -P en el comando df para evitar los saltos de línea en la salida, como se sugiere en un comentario de Per Lindahl, tiene el siguiente aspecto:
#!/bin/shdf -HP | grep -vE '^Filesystem|tmpfs|cdrom' | awk '{ print " " }' | while read output;do usep=$(echo $output | awk '{ print }' | cut -d'%' -f1 ) partition=$(echo $output | awk '{ print }' ) if ; then echo "Running out of space \"$partition ($usep%)\" on $(hostname) as on $(date)" | mail -s "Alert: Almost out of disk space $usep%" [email protected] fidone
Vivek continúa perfeccionando el script más allá de ese punto, pero esta versión servirá para los propósitos del presente post.
Comprobaciones funcionales automatizadas
Un par de reglas generales sobre las comprobaciones funcionales automatizadas, ya sea que estemos comprobando el código de la aplicación o un script o cualquier otro tipo de software:
- la comprobación tiene que ejecutarse de forma idéntica cada vez, sin necesidad de ajustes manuales para preparar cada ejecución; y
- el resultado no puede ser vulnerable a los cambios en el entorno de ejecución, o los datos, u otros factores externos al código bajo prueba.
Aprobar, fallar y error
Podrías señalar que es posible que el script no se ejecute en absoluto. Eso es normal para cualquier tipo de marco de pruebas unitarias para cualquier tipo de aplicación. Tres resultados, en lugar de dos, son posibles:
- El código bajo prueba muestra el comportamiento esperado
- El código bajo prueba se ejecuta, pero no muestra el comportamiento esperado
- El código bajo prueba no se ejecuta
A efectos prácticos, el tercer resultado es el mismo que el segundo; tendremos que averiguar lo que salió mal y arreglarlo. Así que, generalmente, pensamos en estas cosas como algo binario: Pasa o falla.
¿Qué debemos comprobar?
En este caso, estamos interesados en verificar que el script se comportará como se espera dados varios valores de entrada. No queremos contaminar nuestras comprobaciones unitarias con ninguna otra verificación más allá de eso.
Revisando el código bajo prueba, vemos que cuando el uso del disco alcanza un umbral del 90%, el script llama a mail para enviar una notificación al administrador del sistema.
De acuerdo con las buenas prácticas generalmente aceptadas para las comprobaciones unitarias, queremos definir casos separados para verificar cada comportamiento que esperamos para cada conjunto de condiciones iniciales.
Poniéndonos nuestro sombrero de «probador», vemos que esto es un tipo de condición límite. No necesitamos comprobar numerosos porcentajes diferentes de uso de disco individualmente. Sólo necesitamos comprobar el comportamiento en los límites. Por lo tanto, el conjunto mínimo de casos para proporcionar una cobertura significativa será:
- Envía un correo electrónico cuando el uso del disco alcanza el umbral
- No envía un correo electrónico cuando el uso del disco está por debajo del umbral
¿Qué no debemos comprobar?
En consonancia con las buenas prácticas generalmente aceptadas para el aislamiento de pruebas unitarias, queremos asegurarnos de que cada uno de nuestros casos puede fallar por exactamente una razón: El comportamiento esperado no sucede. En la medida de lo posible, queremos configurar nuestras comprobaciones para que otros factores no hagan fallar el caso.
No siempre es rentable (o incluso posible) garantizar que los factores externos no afecten a nuestras comprobaciones automatizadas. Hay ocasiones en las que no podemos controlar un elemento externo, o en las que hacerlo implicaría más tiempo, esfuerzo y coste que el valor de la comprobación, y/o implica un oscuro caso límite que tiene una probabilidad muy baja de ocurrir o muy poco impacto cuando ocurre. Es una cuestión de criterio profesional. Como regla general, haga todo lo posible para evitar la creación de dependencias en factores más allá del alcance del código bajo prueba.
No necesitamos verificar que los comandos df, grep, awk, cut y mail funcionan. Eso está fuera del alcance de nuestros propósitos. Quien mantenga las utilidades es responsable de eso.
Sí queremos saber si la salida del comando df no es procesada de la manera que esperamos por grep o awk. Por lo tanto, queremos que los comandos grep y awk reales se ejecuten en nuestras comprobaciones, basándose en la salida del comando df que coincide con la intención de cada caso de prueba. Eso está en el ámbito porque los argumentos de la línea de comandos para df son parte del script, y el script es el código bajo prueba.
Eso significa que necesitaremos una versión falsa del comando df para usar con nuestras comprobaciones unitarias. Este tipo de componente falso se llama a menudo un simulacro. Un simulacro sustituye a un componente real y proporciona una salida predefinida para dirigir el comportamiento del sistema de forma controlada, de manera que podamos comprobar el comportamiento del código bajo prueba de forma fiable.
Vemos que el script envía una notificación por correo electrónico cuando un sistema de archivos alcanza el nivel de uso del umbral. No queremos que nuestras comprobaciones unitarias arrojen un montón de correos electrónicos inútiles, así que también querremos imitar el comando mail.
Este script es un buen ejemplo para ilustrar la imitación de estos comandos, ya que lo haremos de forma diferente para mail que para df.
Improvisar el comando df
El script está construido alrededor del comando df. La línea relevante en el script es:
df -HP | grep -vE '^Filesystem|tmpfs|cdrom' | awk '{ print " " }'
Si ejecutas sólo df -HP, sin pasar por grep, verás una salida similar a esta:
Filesystem Size Used Avail Use% Mounted onudev 492M 0 492M 0% /devtmpfs 103M 6.0M 97M 6% /run/dev/sda1 20G 9.9G 9.2G 52% /tmpfs 511M 44M 468M 9% /dev/shmtmpfs 5.3M 0 5.3M 0% /run/locktmpfs 511M 0 511M 0% /sys/fs/cgrouptmpfs 103M 8.2k 103M 1% /run/user/1000
Los comandos grep y awk reducen la salida a esto:
0% udev52% /dev/sda1
Necesitamos controlar la salida de df para conducir nuestros casos de prueba. No queremos que el resultado de la comprobación varíe en función del uso real del disco en el sistema donde estamos ejecutando el conjunto de pruebas. No estamos comprobando el uso del disco; estamos comprobando la lógica del script. Cuando el script se ejecute en producción, comprobará el uso del disco. Lo que estamos haciendo aquí es para la validación, no para las operaciones de producción. Por lo tanto, necesitamos un comando df falso o «falso» con el que podamos generar los «datos de prueba» para cada caso.
En una plataforma *nix es posible anular el comando df real definiendo un alias. Queremos que el comando con alias emita valores de prueba en el mismo formato que la salida de df -HP. Esta es una forma de hacerlo (es una sola línea; está dividida para facilitar la lectura):
alias df="shift;echo -e 'Filesystem Size Used Avail Use% Mounted on'; echo -e 'tempfs 511M 31M 481M 6% /dev/shm'; echo -e '/dev/sda1 20G 9.9G 9.2G 52% /'"
El cambio omite el argumento ‘-HP’ cuando se ejecuta el script, de modo que el sistema no se queje de que -HP es un comando desconocido. El comando df alias emite la salida en la misma forma que df -HP.
Los valores de prueba se canalizan en grep y luego en awk cuando el script se ejecuta, por lo que estamos imitando sólo el mínimo necesario para controlar nuestros casos de prueba. Queremos que nuestro caso de prueba sea lo más parecido a la «cosa real» para que no obtengamos falsos positivos.
Los mocks creados a través de una biblioteca de mocking pueden devolver un valor predefinido cuando son llamados. Nuestro enfoque para simular el comando df refleja la función de un simulacro; estamos especificando la salida predefinida que se devolverá cada vez que el código bajo prueba llame a df.
Simular el comando mail
Queremos saber si el script intenta enviar un correo electrónico bajo las condiciones adecuadas, pero no queremos que envíe un correo electrónico real a ningún sitio. Por lo tanto, queremos ponerle un alias al comando mail, como hicimos antes con el comando df. Necesitamos establecer algo que podamos comprobar después de cada caso de prueba. Una posibilidad es escribir un valor en un archivo cuando se llama a mail, y luego comprobar el valor en nuestro caso de prueba. Esto se muestra en el ejemplo siguiente. Otros métodos también son posibles.
Los mocks creados a través de una biblioteca de mocking pueden contar el número de veces que son llamados por el código bajo prueba, y podemos afirmar el número esperado de invocaciones. Nuestro enfoque para simular el comando de correo refleja la función de un simulacro; si el texto «mail» está presente en el archivo mailsent después de ejecutar el script diskusage.sh, significa que el script llamó al comando de correo.
Patrón para ejecutar comprobaciones automatizadas
Las comprobaciones automatizadas o ejecutables a cualquier nivel de abstracción, para cualquier tipo de aplicación o script, en cualquier lenguaje, normalmente comprenden tres pasos. Suelen tener los siguientes nombres:
- Arreglo
- Acto
- Aserto
La razón de esto es probablemente que a todo el mundo le gusta la aliteración, especialmente en la letra A, ya que la propia palabra «aliteración» comienza con la letra A.
Sea cual sea la razón, en el paso de arreglo establecemos las precondiciones para nuestro caso de prueba. En el paso act invocamos el código bajo prueba. En el paso assert declaramos el resultado que esperamos ver.
Cuando utilizamos un marco de trabajo o una biblioteca de pruebas, la herramienta maneja el paso assert muy bien por nosotros, de modo que no tenemos que codificar un montón de engorrosas lógicas if/else en nuestros conjuntos de pruebas. Para nuestro ejemplo inicial aquí, no estamos utilizando un marco de pruebas o una biblioteca, por lo que comprobamos los resultados de cada caso con un bloque if/else. En la próxima entrega, jugaremos con marcos de pruebas unitarias para lenguajes de shell, y veremos cómo se ve.
Aquí está nuestro tosco pero efectivo script de prueba para probar el script de shell de Vivek, que hemos llamado diskusage.sh:
#!/bin/bashshopt -s expand_aliases# Before allalias mail="echo 'mail' > mailsent;false"echo 'Test results for diskusage.sh' > test_resultstcnt=0# It does nothing when disk usage is below 90%# Before (arrange)alias df="echo 'Filesystem Size Used Avail Use% Mounted on';echo '/dev/sda2 100G 89.0G 11.0G 89% /'"echo 'no mail' > mailsent# Run code under test (act). ./diskusage.sh# Check result (assert)((tcnt=tcnt+1))if ]; then echo "$tcnt. FAIL: Expected no mail to be sent for disk usage under 90%" >> test_resultselse echo "$tcnt. PASS: No action taken for disk usage under 90%" >> test_resultsfi # It sends an email notification when disk usage is at 90%alias df="echo 'Filesystem Size Used Avail Use% Mounted on';echo '/dev/sda1 100G 90.0G 10.0G 90% /'"echo 'no mail' > mailsent. ./diskusage.sh((tcnt=tcnt+1))if ]; then echo "$tcnt. PASS: Notification was sent for disk usage of 90%" >> test_resultselse echo "$tcnt. FAIL: Disk usage was 90% but no notification was sent" >> test_resultsfi # After allunalias dfunalias mail# Display test results cat test_results
Aquí hay un recorrido por el script de prueba.
Primero, ves que estamos usando bash para probar un simple y viejo archivo .sh. Eso está perfectamente bien. No es necesario, pero está bien.
A continuación, ves un comando shopt. Eso hará que el shell expanda nuestros alias de prueba cuando el subshell sea invocado para ejecutar el script diskusage.sh. En la mayoría de los casos de uso, no pasaríamos alias a los subshells, pero las pruebas unitarias son una excepción.
El comentario, «Before all», es para la gente que está familiarizada con los frameworks de pruebas unitarias que tienen comandos de configuración y desmontaje. Estos suelen llamarse algo así como «antes» y «después», y normalmente hay un par que pone entre paréntesis todo el conjunto de pruebas y otro par que se ejecuta individualmente para cada caso de prueba.
Queríamos mostrar que la definición del alias para el correo, la inicialización del archivo de resultados de la prueba y la inicialización del contador de casos de prueba se hacen exactamente una vez, al principio del conjunto de pruebas. Este tipo de cosas son normales en los conjuntos de pruebas ejecutables. El hecho de que estemos probando un script de shell en lugar de un programa de aplicación no cambia eso.
El siguiente comentario, «No hace nada…» indica el comienzo de nuestro primer caso de prueba individual. La mayoría de los frameworks de pruebas unitarias ofrecen una manera de proporcionar un nombre para cada caso, para que podamos hacer un seguimiento de lo que está sucediendo y para que otras herramientas puedan buscar, filtrar y extraer casos de prueba por diversas razones.
A continuación, hay un comentario que dice, «Before (arrange)». Este representa una configuración que se aplica sólo a un caso de prueba. Estamos configurando el alias df para que emita la salida que necesitamos para este caso particular. También estamos escribiendo el texto, «sin correo», en un archivo. Así es como podremos saber si el script diskusage.sh intentó enviar un correo de notificación.
El paso de actuación viene a continuación, donde ejercitamos el código bajo prueba. En este caso, eso significa ejecutar el propio script diskusage.sh. Lo originamos en lugar de ejecutarlo directamente.
Ahora hacemos el paso assert, que estamos haciendo de la manera difícil en este ejemplo porque todavía no hemos introducido un marco de pruebas. Incrementamos el contador de pruebas para poder numerar los casos de prueba en el archivo de resultados. De lo contrario, si tuviéramos un gran número de casos podría ser difícil averiguar cuáles han fallado. Los marcos de pruebas se encargan de esto por nosotros.
El alias que definimos para el comando mail escribe el texto ‘mail’ en el archivo mailsent. Si diskusage.sh llama a mail, entonces el archivo mailsent contendrá ‘mail’ en lugar del valor inicial, ‘no mail’. Puedes ver cuáles son las condiciones de paso y de fallo leyendo las cadenas de texto de las que se hace eco el archivo de resultados de la prueba.
Comenzando con el comentario, «Envía una notificación de correo electrónico…» repetimos los pasos de arrange, act, assert para otro caso de prueba. Haremos que nuestro falso comando df emita datos diferentes esta vez, para impulsar un comportamiento diferente del código bajo prueba.
Donde aparece el comentario «Después de todo», estamos limpiando después de nosotros mismos mediante la eliminación de las definiciones que creamos en la configuración «Antes de todo» cerca de la parte superior del script de prueba.
Por último, volcamos el contenido del archivo test_results para poder ver lo que hemos conseguido. Se ve así:
Test results for diskusage.sh1. PASS: No action taken for disk usage under 90%2. PASS: Notification was sent for disk usage of 90%
¿Por qué usar un marco de pruebas/biblioteca?
Acabamos de escribir un par de casos de prueba unitarios para un script de shell sin usar un marco de pruebas, una biblioteca de imitación o una biblioteca de aserción. Encontramos que los comandos del sistema pueden ser burlados mediante la definición de alias (al menos en los sistemas *nix), que las aserciones pueden ser implementadas como declaraciones condicionales, y la estructura básica de una prueba de unidad es fácil de configurar a mano.
No fue difícil hacer esto sin un marco o biblioteca. Entonces, ¿cuál es el beneficio?
Los frameworks y bibliotecas de pruebas simplifican y estandarizan el código de pruebas y permiten suites de pruebas mucho más legibles que los scripts hechos a mano que contienen un montón de declaraciones condicionales. Algunas bibliotecas contienen características adicionales útiles, como la capacidad de atrapar excepciones o la posibilidad de escribir casos de prueba basados en tablas y datos. Algunas están adaptadas para soportar productos específicos de interés para los ingenieros de infraestructuras, como Chef y Puppet. Y algunos incluyen la funcionalidad para realizar un seguimiento de la cobertura del código y / o para dar formato a los resultados de las pruebas en una forma consumible por las herramientas en la tubería de CI / CD, o al menos un navegador web.
Marcos de pruebas unitarias para scripts
En esta serie vamos a explorar varios marcos de pruebas unitarias para scripts de shell y lenguajes de scripting. Aquí hay una visión general:
- shunit2 es un proyecto de código abierto muy sólido con una historia de diez años. Originalmente desarrollado por Kate Ward, un Ingeniero de Fiabilidad del Sitio y Gerente de Google con sede en Zürich, es activamente desarrollado y apoyado por un equipo de seis personas. Desde sus humildes comienzos como una solución puntual para probar una biblioteca de registro para scripts de shell, se ha desarrollado intencionadamente hasta convertirse en un marco de pruebas unitarias de propósito general que soporta múltiples lenguajes de shell y sistemas operativos. Incluye un número de características útiles más allá de las simples aserciones, incluyendo soporte para pruebas basadas en datos y en tablas. Utiliza el estilo tradicional de aserciones «assertThat». El sitio del proyecto contiene una excelente documentación. Para pruebas unitarias de propósito general de scripts de shell, esta es mi principal recomendación.
- BATS (Bash Automated Testing System) es un marco de pruebas unitarias para bash. Fue creado por Sam Stephenson hace unos siete años, y ha tenido una docena de colaboradores. La última actualización fue hace cuatro años, pero esto no es nada para preocuparse, ya que este tipo de herramienta no requiere actualizaciones frecuentes o mantenimiento. BATS se basa en el Test Anything Protocol (TAP), que define una interfaz consistente basada en texto entre los módulos de cualquier tipo de arnés de pruebas. Permite una sintaxis limpia y consistente en los casos de prueba, aunque no parece añadir mucho azúcar sintáctico más allá de las declaraciones bash directas. Por ejemplo, no hay una sintaxis especial para las aserciones; se escriben comandos bash para probar los resultados. Teniendo esto en cuenta, su principal valor puede residir en la organización de conjuntos de pruebas y casos de una manera lógica. Ten en cuenta, además, que escribir scripts de prueba en bash no nos impide probar scripts que no sean en bash; ya lo hicimos anteriormente en este post. El hecho de que la sintaxis de BATS esté tan cerca de la sintaxis de bash nos da mucha flexibilidad para manejar diferentes lenguajes de shell en nuestras suites de prueba, a costa de la legibilidad (dependiendo de lo que encuentres «legible»; la audiencia a la que va dirigido este post probablemente encuentre la sintaxis del lenguaje de shell bastante legible). Una característica particularmente interesante (en mi opinión) es que puedes configurar tu editor de texto con resaltado de sintaxis para BATS, como se documenta en el wiki del proyecto. Emacs, Sublime Text 2, TextMate, Vim y Atom eran compatibles en la fecha de este post.
- zunit (no el de IBM, el otro) es un marco de pruebas unitarias para zsh desarrollado por James Dinsdale. El sitio del proyecto afirma que zunit se inspiró en BATS, e incluye las muy útiles variables $state, $output y $lines. Pero también tiene una sintaxis de aserción definitiva que sigue el patrón, «assert actual matches expected». Cada uno de estos marcos tiene algunas características únicas. Una característica interesante de ZUnit, en mi opinión, es que marcará cualquier caso de prueba que no contenga una aserción como «arriesgado». Puedes anular esto y forzar la ejecución de los casos, pero por defecto el framework te ayuda a recordar que debes incluir una aserción en cada caso de prueba.
- bash-spec es un framework de pruebas de estilo conductual que sólo soporta bash (o al menos, sólo ha sido probado contra scripts bash). Es un humilde proyecto paralelo mío que lleva más de cuatro años y tiene unos pocos usuarios «reales». No se actualiza mucho, ya que actualmente hace lo que se pretendía hacer. Uno de los objetivos del proyecto era hacer uso de las funciones de bash en un estilo «fluido». Las funciones son llamadas en secuencia, cada una pasando la lista completa de argumentos a la siguiente después de consumir tantos argumentos como necesite para realizar su tarea. El resultado es un conjunto de pruebas legible, con afirmaciones como «esperar que nombre-paquete sea instalado» y «esperar que nombre-array no contenga valor». Cuando se utiliza para guiar el desarrollo de scripts en función de las pruebas, su diseño tiende a llevar al desarrollador a escribir funciones que apoyan la idea de «modularidad» o «responsabilidad única» o «separación de preocupaciones» (llámelo como quiera), lo que resulta en una facilidad de mantenimiento y funciones fácilmente reutilizables. El «estilo de comportamiento» significa que las aserciones toman la forma, «espera que esto coincida con eso».
- korn-spec es un puerto de bash-spec para el shell korn.
- Pester es el marco de pruebas de unidad de elección para Powershell. Powershell se ve y se siente más como un lenguaje de programación de aplicaciones que puramente un lenguaje de scripting, y Pester ofrece una experiencia de desarrollador totalmente consistente. Pester viene con Windows 10 y puede instalarse en cualquier otro sistema que soporte Powershell. Tiene una robusta biblioteca de aserción, soporte incorporado para mocking, y recoge las métricas de cobertura de código.
- ChefSpec se basa en rspec para proporcionar un marco de prueba de estilo de comportamiento para las recetas de Chef. Chef es una aplicación Ruby, y ChefSpec aprovecha al máximo las capacidades de rspec más el soporte incorporado para la funcionalidad específica de Chef.
- rspec-puppet es un marco de trabajo de estilo de comportamiento para Puppet, funcionalmente similar a ChefSpec.