Análisis de aplicaciones mediante pruebas de software
Contenidos
Aserciones: Expresiones de prueba semánticas:
Queremos construir una función llamada expect
que realice una aserción. Una aserción es una expresión que define una aseveración que puede ser validada o rechazada.
Ejemplos:
10 > 5
ó10 es mayor que 5
- Si convierto
5000
a palabras entonces debería sercinco mil
- Espero que 10 sea mayor que 20
Para implementar una aserción en Javascript la dividiremos en 2 partes. Primero creamos una función llamada expect()
const testValue = numberToWords(200) /* 👈 Valor entregado por la función a probar */
const expectedValue = 'doscientos'
expect(testValue) /* 👈 retorna un objeto con
funciones útiles para validar
el valor de entrada VS el valor esperado */
Haremos que el objecto retornado por la función expect()
tenga nuestro primero método de aserciones (matcher) llamado .toEqual()
. Este método recibirá el valor esperado a comparar.
En caso de fallar la comparación obtendremos un error en la terminal. Luego de este segundo paso la función quedará así
// Espero que el valor de prueba sea igual al valor esperado
expect(testValue).toEqual(expectedValue) // 👈 arroja error si no se cumple la comparación
Totalmente semántico!! Si bien estamos programando, esta forma de realizar Aserciones podría permitir inclusive a personas que no sepan mucho de programación escribirlas y entenderlas.
Corroboraciones de algoritmos con un mini Framework de pruebas
function expect(testValue) {
// OJO en algo muy interesante en este objeto literal. Utilizan el valor testValue. ¿Por qué nos debería llamar la atención esto?
const comparatorFunctions = {
toEqual(expectedValue) {
if (testValue !== expectedValue) {
throw new Error()
}
},
toHaveLength(expectedValue) {
if (testValue.length !== expectedValue) {
throw new Error()
}
},
}
// Esto permita que quien use expect pueda decidir el comparador que usa según sea el caso
return comparatorFunctions
}
El Patrón AAA / Given-When-Then
El patrón AAA o Given-When-Then intenta dar un orden y categorizaciones de las acciones necesarias para ejecutar una aserción efectiva. En el siguiente trozo de código puedes ver en detalle a que se refiere cada una de estas secciones
// Preparación - Arrange o Given
// Todo el código necesario de configurar ANTES de la prueba
// Ej: Parámetros, Dobles de prueba, Definición valores esperados, etc
const numberToWord = new NumberToWord()
const testValue = 2100
const expectedValue = 'veintiún mil'
// Ejecución de la acción - Act o When
// La ejecución de la prueba unitaria en la función o método objetivo
const result = numberToWord.convert(testValue)
// Corroboración de los resultados - Assert o Then
// ¿Qué es una Aserción?
expect(result).toEqual(expectedValue)
Juntando todas las piezas y sacando conclusiones
Luego de escribir los códigos anteriores podemos tener un mejor panorama del porque son necesarios ciertos procesos y lo importante de cada uno de ellos. Hasta el momento podemos identificar 3:
- Source Under Test: Inclusión del código objetivo para hacer pruebas sobre él.
- Assertion Library: La Librería de Aserciones es una o varias funciones utilizarias que permita generar comparadores que sean transversales y que den una API más semántica para quién escriba las aserciones de los archivos de pruebas unitarias y así, centrarse mucho más en la descripción de los casos de uso en vez de escribir algoritmos
- Test Runner: Función que se encarga de correr las pruebas utilizando la librería de aserciones y define los valores de entrada y salida. De esta forma esta función puede reportar hacia la salida estándar de la terminal los resultados de las pruebas. Incluso podriamos escribir un archivo con los resultados y procesar este.
// SUT (Source Under Test): Código que contiene las funciones que queremos probar unitariamente
const NumberToWord = require('../SUT/utils/numberToWord')
// Assertion Library: Código utilitario con el objetivo de ser reutilizado en las distintas pruebas
function expect(testValue) {
// OJO en algo muy interesante en este objeto literal. Utilizan el valor testValue. ¿Por qué nos debería llamar la atención esto?
const comparatorFunctions = {
toEqual(expectedValue) {
if (testValue !== expectedValue) {
throw new Error()
}
},
toHaveLength(expectedValue) {
if (testValue.length !== expectedValue) {
throw new Error()
}
},
}
// Esto permita que quien use expect pueda decidir el comparador que usa según sea el caso
return comparatorFunctions
}
// Test Runner: Función que se encarga de crear los casos de pruebas, y administrar como se van a correr y los mensajes que va a reportar respecto del resultado de las pruebas
function runTests() {
const numberToWord = new NumberToWord()
const tests = [
{ value: 5000, expected: 'cinco mil' },
{ value: 999, expected: 'novecientos noventa y nueve' },
{ value: 25990, expected: 'veinticinco mil novecientos noventa' },
{ value: 21000, expected: 'veintiún mil' },
{ value: 31121051, expected: 'treinta y un millones ciento veintiún mil cincuenta y uno' },
]
tests.forEach(({ value, expected }) => {
const testValue = numberToWord.convert(value)
try {
expect(testValue).toEqual(expected)
console.log(`✅ La prueba de comparación entre "${testValue}" y "${expected}" fue exitosa`)
} catch(error) {
console.error(`❌ La prueba para el valor "${testValue}" falló`)
console.error(`El valor esperado era "${expected}"`)
}
})
}
En el siguiente capítulo veremos como ya conociendo estos 3 primeros conceptos, podemos enfrentarnos al último y más complejo desafío para escribir pruebas unitarias de software de forma profesional: Lidiar con el código asíncrono y no-determinista. En general, este último está dado por eventos ajenos a nuestro control como la creación de un número aleatorio, el calculo de la fecha actual o una llamada al servidor.
Ya veremos que significa el término no-determinista y como podemos enfrentar las problemáticas que trae consigo este concepto y las técnica que provee el lenguaje para poder abordarlas.
Funciones globales para realizar pruebas de software
Utilización de la opción --require
en la línea de comandos de NodeJS
Si ejecutamos directamente este script con el siguiente comando:
node unit-test-framework/02_handle_async_in_tests.js
veremos el error
ERROR expect is not defined
falla porque la función .expect()
no existe. Esto es porque en está declarada en otro archivo. Vamos a revisar en la raíz del proyecto un archivo llamado test-setup.js
. Veremos que tiene una función .toEqual()
un poco más sofisticada. Fijarse en la línea final que en vez de utilizar module.exports
dice:
global.expect = expect
Esto permitirá definir expect como una función global. Para que utilicemos expect sin problemas debemos ejecutar
node --require ./test-setup.js unit-test-framework/02_handle_async_in_tests.js
Acá la descripción más detallada de que significa la opción –require https://nodejs.org/api/cli.html#cli_r_require_module
Una vez ejecutada la prueba mostrará un error relacionado a la comparación de una lista de países con otra.
Pruebas de software para algoritmos no-deterministas
Muchas veces nuestra unidades de códigos están agrupadas en distintos métodos y funciones que en general reciben ciertos parámetros de entrada para generar una salida relacionada a esos datos de entrada. Hacer cosas como formatear o validar datos, ordenar o formatear listas, encontrar datos y muchas otras cosas que nos permite el desarrollo de algoritmos.
Dentro de este grupo de algoritmos existen los Algoritmos No Deterministas.
En ciencias de la computación, un algoritmo no determinista es un algoritmo que con la misma entrada ofrece muchos posibles resultados, y por tanto no ofrece una solución única. No se puede saber de antemano cuál será el resultado de la ejecución de un algoritmo no determinista referencia: https://xlinux.nist.gov/dads/HTML/nondetermAlgo.html
Podemos identificar varias situaciones en las cuales algún algoritmo no siempre dará los resultados esperados, por ejemplo cuando creamos un número aleatorio, calculamos un fecha, hacemos una operación AJAX con diversos resultados y muchos otros.
En contraposición, las pruebas de Software Unitarias deben cumplir, entre otros, con dos principios: Deben ser rápidas de ejecutar y deben ser reproducibles y esto es: dadas ciertas entradas y datos de prueba, no importa la cantidad de veces que se vuelvan a ejecutar las pruebas, sus resultados deben ser los mismos.
Vamos a echar un vistazo al código y así poder sacar conclusiones del por qué afirmamos que se están rompiendo los principios. Y como solucionarlo.
Dependencias de una unidad de código
Al abrir el archivo unit-test-framework/02_handle_async_in_tests.js
vamos a ver que hay un bloque try/catch
que debería ser de nuestro interés.
try {
testValue = await countriesService.getSouthAmericanCountries()
expect(testValue).toEqual(expectedCountries)
console.log(`✅ La prueba de comparación entre "${testValue}" y "${expectedCountries}" fue exitosa`)
} catch(error) {
console.error('ERROR', error.message)
console.error(`❌ La prueba para el valor "${testValue}" falló. El valor esperado era "${expectedCountries}"`)
}
Podemos identificar código relacionado a nuestro mini framework de pruebas, pero también una que finalmente ejecuta la acción a validar:
testValue = await countriesService.getSouthAmericanCountries()
Si vamos a mirar de donde proviene el método .getSouthAmericanCountries()
llegaremos al archivo SUT/services/countries.js
.
Acá veremos que el código está dividido en 3 etapas:
...
getCountries() {
// ETAPA 2: Se hace una petición HTTPS a través de una promesa de manera que si la petición sale OK la promesa ejecutará `resolve()` y si sale ERROR ejecutará `reject()`
const promise = new Promise((resolve, reject) => {
// Code based on: https://nodejs.org/dist/latest-v16.x/docs/api/https.html#httpsgeturl-options-callback
get('https://restcountries.com/v3.1/all', (res) => {
let data = ''
res.on('data', (chunk) => data += chunk)
res.on('end', () => resolve(JSON.parse(data)))
res.on('error', (error) => reject(error))
})
})
return promise
}
async getSouthAmericanCountries() {
try {
// ETAPA 1: llamamos a otro método que se encarga de obtener los datos sin filtrar
const result = await this.getCountries()
// ETAPA 3 OK: Una vez obtenida la respuesta filtramos y formateamos los datos de salida
return result
.filter(country => country.subregion === 'South America')
.map(country => country.name.common)
} catch(error) {
// ETAPA 3 ERROR: Si la respuesta de la petición HTTPS salío errónea, imprimimos el error y luego devolvemos un arreglo **vacío**
console.log('Error', error.message)
return []
}
}
...
Una primera etapa se encarga de llamar a otra función. Haremos un alto en esta etapa para hacer una definición: Todo código ajeno a la función que estamos tratando de probar se denominará “dependencia”. Entonces en este caso podríamos decir que:
Dada la unidad de código que estamos tratando probar
getSouthAmericanCountries
, existe una dependencia llamadagetCountries
que se encarga de obtener los resultados sobre los cuales la función operará
Es muy importante para poder escribir pruebas unitarias efectivas, que seamos capaces de poder identificar las dependencias de una unidad de código y analizar sus implicaciones respecto de el o los resultados que se obtendrán. Inclusive también poder notar cuando una unidad de código tiene muchas responsabilidades y dividir en más de una unidad un problema.
Una de las máximas al momento de escribir pruebas unitarias es que estas denotan claramente cuando un código está demasiado acoplado:
Una prueba unitaria demasiado difícil de escribir denota código poco mantenible y por consiguiente difícil de probar.
La segunda etapa de esta función se ejecuta bajo la función .getCountries()
y utiliza la función .get()
del módulo https
de NodeJs. Si experimentamos un poco con ella y analizamos sus valores de retorno observamos que se devuelve una promesa y podemos ver que en sus funciones de resolución ( resolve()
y reject()
) se devuelven valores en el caso positivo de un objeto, y en el caso negativo de un error. Puedes cambiar un poco los callbacks de los eventos y agregar console.log()
para analizar el tipo de dato devuelto. Es super importante hacer este análisis porque debemos conocer los tipos de datos devueltos por esta función.
💡 La función utilizada en el método “getCountries” es
https.get
. ¿Cómo podríamos simular un caso de error para conocer el tipo del argumentoerror
?
Vemos que la tercera etapa de ejecución ya diverge en 2 partes:
- Cuando la función resuelve un resultado positivo se asume que es un arreglo al cual se le aplican las operaciones
.filter()
y.map()
- Cuando la función resuelve con un resultado negativo se devuelve un arreglo vacío.
Aislar una unidad de código para realizar pruebas unitarias
Ahora que ya hicimos un análisis del código, vamos a resumir lo encontrado y vamos a realizar el proceso de aislamiento de la unidad de código que necesitamos probar:
-
.getSouthAmericanCountries()
: Unidad de código objetivo que debemos probar. Actualmente en función del resultado entregado devuelve un arreglo filtrado y transformado para el caso positivo (bloquetry
), y un arreglo vacío para el caso negativo (bloquecatch
) -
.getCountries()
: Bajo el contexto de la prueba hacia el métodogetSouthAmericanCountries
, este método es una dependencia que entrega un arreglo como valor de resolución de una promesa para el caso positivo, y un valor de resolución de la promesa del tipoError
para el caso negativo.