Anuncios

Bienvenidos sean a este post, hoy si veremos como testear una aplicacion de verdad.

Anuncios

Para este caso vamos a crear una aplicacion un poco compleja porque se tratara de una generadora de archivos csv, en este archivo vamos a almacenar informacion de usuarios con distintos datos que lo representan, ya sea el nombre, apellido, email y algunos datos mas, vamos a usar dos modulos en particular como son marshmallow y pytest, para instalarlos haremos lo siguiente:

Para instalarlo en Debian:

sudo apt-get install python3-marshmallow python3-pytest
Anuncios

Para instalarlo en Windows con pip:

pip install marshmallow
pip install pytest
Anuncios

En el caso de Debian podemos instalar los dos paquetes al mismo tiempo, con nuestro paquetes instalados podemos proceder a crear el ejemplo o proyecto que usaremos para testear, para este caso vamos a crear dos archivos, en uno almacenaremos nuestro proyecto y en el otro crearemos nuestro testeador, comencemos con la aplicacion en si, primero haremos una nueva carpeta llamada tests, y dentro crearemos un archivo llamado api.py y le agregaremos este codigo primero:

api.py

import os
import csv
from copy import deepcopy

from marshmallow import Schema, fields, pre_load
from marshmallow.validate import Length, Range

class SchemaUsuario(Schema):

        email = fields.Email(required=True)
        nombre = fields.String(required=True,validate=Length(min=1))
        edad = fields.Integer(
                required=True,validate=Range(min=18,max=65))
        rol = fields.String()

        @pre_load(pass_many=False)
        def nombre_tira(self, datos):
                copia_datos = deepcopy(datos)

                try:
                        copia_datos['nombre'] = copia_datos['nombre'].strip()

                except (AttributeError, KeyError, TypeError):
                        pass

                return copia_datos

schema = SchemaUsuario
Anuncios
Anuncios

Primero importaremos algunos modulos y librerias que necesitaremos, entre ellas algunas conocidas como os y otras no tanto, como csv y copy, de copy nos interesa la funcion deepcopy pero ya veremos para que, lo siguiente sera la importacion de algunos elementos de marshmallow, en la primera linea importaremos las funciones para el Schema (base de datos), los campos y una para pre cargar los datos, la siguiente utiliza una clase del modulo marshmallow para la longitud y el rango, si se preguntan para que nos sirve marshmallow esta es una herramienta que nos permite serializar/deserializar objetos y nos da los medios para definir un schema o base de datos, nuestro siguiente paso sera crear una clase que sera para establecer a Schema, esta la usaremos para almacenar los datos de usuarios, en este caso tendremos cuatro objetos para almacenar estos:

  • email, direccion de correo del usuario y establecemos que es obligatorio
  • nombre, nombre del usuario y establecemos que es obligatorio y su longitud minima
  • edad, la edad del usuario y establecemos que es obligatorio y el rango valido para esta
  • rol, guarda el rol del usuario
Anuncios

Primero usaremos a pre_load como decorador y el pass_many como False para establecer que vamos a serializar, luego creamos una funcion para limpiar el nombre de espacios en blanco, para ello haremos una copia de datos por medio de deepcopy le copiaremos todos los elementos informados en datos.

Anuncios
Nota: 
deepcopy construye un nuevo objeto compuesto y luego, de forma recursiva, inserta copias en él de los objetos que se encuentran en el original.
Anuncios

Una vez realizado tomaremos el valor del campo nombre en la copia de datos y le ejecutaremos un strip para eliminar espacios en blanco por delante y por detras, esto lo haremos por medio de un bloque try/except para manejar algun posible error en la realizacion, por ultimo con todo esto realizado devolveremos la copia de datos, por ultimo iniciaremos un nuevo objeto llamado schema con el valor creado en la clase anterior, ahora agregaremos unos metodos dentro de este archivo para trabajar pero lo haremos uno por uno para poder explicarlo, comencemos con el primero:

def exportar(archivo, usuarios, overwrite=True):

        if not overwrite and os.path.isfile(archivo):
                raise IOError(f"'{archivo}' ya existe.")

        usuarios_validos = get_usuarios_validos(usuarios)
        escribir_csv(archivo, usuarios_validos)
Anuncios
Anuncios

Esta funcion sera la que usaremos para exportar los datos a un archivo csv, donde recibiremos el archivo de destino, los usuarios que trabajaremos y por ultimo con la opcion overwrite para poder sobreescribir, luego tenemos un condicional donde si overwrite es False y el archivo existe genera un error informando que el mismo ya existe, luego tenemos dos objetos donde primero guardaremos los usuarios que sean validados, esto lo obtenemos con la funcion get_usuarios_validos y una vez almacenado la informacion usaremos a escribir_csv para crear el archivo, con la informacion deseada, como se habran dado cuenta estas dos funciones no existen por lo tanto vamos a definirlas, comencemos con get_usuarios_validos:

def get_usuarios_validos(usuarios):
        yield from filter(es_valido, usuarios)
Anuncios

En este caso nos devolvera por medio de yield los datos filtrados por medio de una funcion y los datos informados, pasemos a agregar la informacion del filtro:

def es_valido(usuario):
        return not schema.validate(usuario)
Anuncios

En esta funcion usaremos a schema.validate para validar los usuarios, en este caso devolveremos un True cuando resultado obtenido sea en blanco de lo contrario devolvera un False, por ultimo tenemos que agregar la siguiente funcion:

def escribir_csv(archivo, usuarios):
        nombrecampos = ['email', 'nombre', 'edad', 'rol']

        with open(archivo, 'x', newline='') as archivocsv:
                escritor = csv.DictWriter(archivocsv,fieldnames=nombrecampos)
                escritor.writeheader()
                for usuario in usuarios:
                        escritor.writerow(usuario)
Anuncios
Anuncios

Esta sera la funcion encargada de escribir el archivo csv, para ello recibiremos un nombre del archivo y el objeto con los usuarios, despues crearemos una lista con los nombre de los campos de nuestra «tabla», lo siguiente sera abrir el archivo informado pero usaremos a la opcion X para que agregue contenido al archivo y el newline sera en blanco, lo almacenamos en un objeto llamado archivocsv, luego crearemos un objeto llamado escritor en el cual usaremos a DictWriter de csv para escribir en el archivo los campos del dict que son los nombre de campos, luego escribiremos el encabezado y por ultimo pasaremos cada usuario pasado en usuarios y escribiremos linea por linea, con esto terminamos nuestro software, veamos su codigo final:

api.py

import os
import csv
from copy import deepcopy

from marshmallow import Schema, fields, pre_load
from marshmallow.validate import Length, Range

class SchemaUsuario(Schema):

	email = fields.Email(required=True)
	nombre = fields.String(required=True,validate=Length(min=1))
	edad = fields.Integer(
		required=True,validate=Range(min=18,max=65))
	rol = fields.String()

	@pre_load(pass_many=False)
	def nombre_tira(self, datos):
		copia_datos = deepcopy(datos)

		try:
			copia_datos['nombre'] = copia_datos['nombre'].strip()

		except (AttributeError, KeyError, TypeError):
			pass

		return copia_datos

schema = SchemaUsuario()

def exportar(archivo, usuarios, overwrite=True):

	if not overwrite and os.path.isfile(archivo):
		raise IOError(f"'{archivo}' ya existe.")

	usuarios_validos = get_usuarios_validos(usuarios)
	escribir_csv(archivo, usuarios_validos)

def get_usuarios_validos(usuarios):
	yield from filter(es_valido, usuarios)

def es_valido(usuario):
	return not schema.validate(usuario)

def escribir_csv(archivo, usuarios):
	nombrecampos = ['email', 'nombre', 'edad', 'rol']

	with open(archivo, 'x', newline='') as archivocsv:
		escritor = csv.DictWriter(archivocsv,fieldnames=nombrecampos)
		escritor.writeheader()
		for usuario in usuarios:
			escritor.writerow(usuario)
Anuncios

Podemos pasar a crear el archivo que se encargara de hacer el test, para ello dentro de la carpeta tests crearemos un archivo llamado test_api.py y le agregaremos todo el codigo encargado de hacer el test pero para entenderlo mejor lo agregaremos por partes y explicaremos cada una de ellas, agreguemos la primera parte parte del codigo:

test_api.py

import os
from unittest.mock import patch, mock_open, call
import pytest
from api import es_valido, exportar, escribir_csv
Anuncios

Este va a ser un poco mas complejo pero no mucho, como siempre importaremos los elementos que necesitemos, el primero sera os, luego lo hablado en el post anterior como son mock, patch y para llamar, despues de esto importamos a pytest y por ultimo del archivo anterior importaremos a las funciones es_valido, exportar y escribir_csv, pasemos a agregar el siguiente bloque de codigo:

@pytest.fixture
def usuario_min():
        return {
                'email' : 'minimo@ejemplo.com',
                'nombre' : 'Sarasa sarasa',
                'edad' : 18,
        }

@pytest.fixture
def usuario_completo():
        return {
                'email' : 'completo@ejemplo.com',
                'nombre': 'Martin Miranda',
                'edad' : 44,
                'rol' : 'CEO',
        }

@pytest.fixture
def usuarios(usuario_min, usuario_completo):
        mal_usuario = {
                'email' : 'invalido@ejemplo.com',
                'nombre' : 'no valido',
        }
        return [usuario_min, mal_usuario, usuario_completo]
Anuncios
Anuncios

Aqui agregaremos tres fixtures de los que hablamos en el post anterior, en este caso crearemos tres para dos distintos tipos de usuarios y uno que devuelve a todos, observen que lo pasamos como decoradores y aclarando que son fixtures de pytest, en el primer caso devolvemos un usuario minimo con tres campos de los cuatro que posee, en la siguiente devolvemos un usuario completo con todos los datos, en la ultima devolveremos todos los usuarios pero previamente generamos un diccionario que sera un usuario mal cargado por ultimo devolvemos a los tres usuarios generados, para nuestro siguiente paso agregaremos una clase que sera la encargada verificar si el test es valido, comencemos con esta parte:

class TestEsValido:

        def test_minimo(self, usuario_min):
                assert es_valido(usuario_min)

        def test_completo(self, usuario_completo):
                assert es_valido(usuario_completo)
Anuncios

Estas dos funciones solo serviran para verificar que ambos tipos de usuarios informados son validos, nuestro siguiente paso sera agregar los parametros tal como mencionamos en este post, para ello vamos a agregar el siguiente bloque:

        @pytest.mark.parametrize('edad', range(18))
        def test_edad_invalida_muy_joven(self, edad, usuario_min):
                usuario_min['edad'] = edad
                assert not es_valido(usuario_min)

        @pytest.mark.parametrize('edad', range(66, 100))
        def test_edad_invalida_muy_viejo(self, edad, usuario_min):
                usuario_min['edad'] = edad
                assert not es_valido(usuario_min)

        @pytest.mark.parametrize('edad', ['NaN', 3.1415, None])
        def test_edad_invalida_tipo_mal(self, edad, usuario_min):
                usuario_min['edad'] = edad
                assert not es_valido(usuario_min)

        @pytest.mark.parametrize('edad', range(18, 66))
        def test_valido_edad(self, edad, usuario_min):
                usuario_min['edad'] = edad
                assert es_valido(usuario_min)
Anuncios
Anuncios

Aca vamos a agregar los cuatro parametros que tenemos para la edad, estos cubren desde muy joven hasta el valido, el primer parametro lo utilizaremos para generar numeros que estan fuera del rango minimo de edad por lo tanto hara las pruebas o test para cuando la edad es menor al minimo permitido, esto hara que la funcion encargada de devolver el objeto entregara uno que no es valido, el siguiente es para cuando es mas viejo es decir que la edad va de 66 en adelante hasta 100 y hace exactamente lo mismo, el siguiente es para cuando pasamos un valor que no es acorde al que espera o lo mismo que decir que el usuario carga mal la fecha, por ultimo tenemos un parametro que esta dentro del rango valido y la funcion sera la que genera la edad correcta para probarla y en este caso si devolvemos un objeto valido, con esto cubrimos los parametros relacionados a la edad pasemos a agregar el siguiente bloque:

        @pytest.mark.parametrize('campo',['email', 'nombre', 'edad'])
        def test_campos_mandatorios(self, campo, usuario_min):
                usuario_min.pop(campo)
                assert not es_valido(usuario_min)

        @pytest.mark.parametrize('campo',['email', 'nombre', 'edad'])
        def test_campos_mandatorios_vacio(self, campo, usuario_min):
                usuario_min[campo] = ''
                assert not es_valido(usuario_min)

        def test_nombre_solo_espacioblanco(self, usuario_min):
                usuario_min['nombre'] = '\n\t'
                assert not es_valido(usuario_min)
Anuncios

En este bloque validaremos a los usuarios para los distintas formas de llenar los campos, en el caso del primero verificamos si llenaron correctamente los campos mandatorios (email, nombre y edad) para ello usamos un pop para eliminar el campo y en caso de fallar devolveremos que dicho usuario no es valido, en el segundo parametro/funcion chequearemos si alguno de los mandatorios no ha sido completado y el ultimo para el caso de pasar espacios en blanco, pasemos a agregar el siguiente parametro:

        @pytest.mark.parametrize(
                'email, salida',
                [
                        ('missing_at,com', False),
                        ('@missing_start.com', False),
                        ('perdido@', False),
                        ('invalido@example', False),

                        ('buenejemplo@ejemplo.com', True),
                        ('δοκιμή@παράδειγμα.δοκιμή', True),
                        ('аджай@экзампл.рус', True),
                ]
        )
        def test_email(self, email, salida, usuario_min):
                usuario_min['email'] = email
                assert es_valido(usuario_min) == salida
Anuncios

En este caso vamos a parametrizar una serie de emails para ver si el testeo funciona correctamente, para ello pasaremos dos campos, email y salida, en el primero pasaremos la direccion de correo y en el segundo un estado booleano para indicar si son validos o no, para ello pasamos los nombres de los elementos y despues una lista con tuples para completarlos respectivamente, la funcion recibira estos dos datos y el usuario minimo, en este chequearemos si el estado del email informado corresponde al valor informado en salida, por ultimo agregaremos el siguiente parametro:

        @pytest.mark.parametrize(
                'campo, valor',
                [
                        ('email', None),
                        ('email', 3.1415),
                        ('email', {}),

                        ('nombre', None),
                        ('nombre', 3.1415),
                        ('nombre', {}),

                        ('rol', None),
                        ('rol', 3.1415),
                        ('rol', {}),
                ]
        )
        def test_tipos_invalidos(self, campo, valor, usuario_min):
                usuario_min[campo] = valor
                assert not es_valido(usuario_min)
Anuncios

Este es muy similar al anterior pero para esta ocasion chequearemos tipos invalidos para tres de los campos de nuestra aplicacion, pasamos tres tipos de datos para cada uno de los campos y luego en la funcion devolveremos si el valor en cual usuario fallo, con esto tenemos completa la clase para testear la validez, para la siguiente parte vamos a agregar una clase donde testearemos la exportacion, agreguemos la primera parte:

class TestExportar:

        @pytest.fixture
        def archivo_csv(self, tmpdir):
                yield tmpdir.join("salida.csv")

        @pytest.fixture
        def archivo_existente(self, tmpdir):
                existente = tmpdir.join('existente.csv')
                existente.write('Dejenme solo')
                yield existente
Anuncios

Creamos la clase y agregaremos dos fixtures, el primero sera para crear un archivo csv en un directorio temporal, el siguiente creara otro archivo csv con un texto para trabajar como si fuera uno existente, pasemos a agregar la primera funcion para testear:

        def test_exportar(self, usuarios, archivo_csv):
                exportar(archivo_csv, usuarios)

                lineas = archivo_csv.readlines()

                assert [
                        'email,nombre,edad,rol\n',
                        'minimo@ejemplo.com,Sarasa sarasa,18,\n',
                        'completo@ejemplo.com,Martin Miranda,44,CEO\n',
                ] == lineas
Anuncios

Esta la usaremos para probar la funcion exportar, recibe al archivo y a los usuarios, en lineas almacena el contenido del archivo y luego por medio de assert verificamos que estos tres elementos en la lista sean iguales al contenido de lineas y devolvemos el resultado, nuestro siguiente paso sera agregar el siguiente bloque:

        def test_exportar_citando(self, usuario_min, archivo_csv):
                usuario_min['nombre'] = 'Un nombre, con una coma'

                exportar(archivo_csv, [usuario_min])

                lineas = archivo_csv.readlines()
                assert [
                        'email,nombre,edad,rol\n',
                        'minimo@ejemplo.com,"Un nombre, con una coma",18,\n',
                ] == lineas
Anuncios

En esta funcion testearemos la exportacion pero con una coma en el nombre para ello no usaremos toda la lista sino que solamente una por eso creamos uno minimo, luego haremos lo mismo pero esta vez en su minima expresion, por ultimo agregaremos la siguiente funcion:

        def test_no_sobreescribe(self, usuarios, archivo_existente):
                with pytest.raises(IOError) as err:
                        exportar(archivo_existente, usuarios,
                                overwrite=False)

                assert err.match(
                        r"'{}' ya existe\.".format(archivo_existente)
                )

                assert archivo_existente.read() == 'Dejenme solo'
Anuncios

Esta ultima chequea que no se puede sobreescribir el archivo existente, para este caso vamos a generar un error por medio de pytest y llamara a la funcion pertinente si el error ocurre, en el asset usaremos err.match para ver que coinicida pero si observan veremos que empieza con r, esto significa que el mensaje que pasamos para comparar debe ser un mensaje regular y no una simple cadena (string), por ultimo para el assert final debemos comparar la lectura del archivo existente con el mensaje que le ingresamos en el otro archivo, con todo esto ya tenemos concluido el archivo de test, veamos como quedo su codigo final:

test_api.py

import os
from unittest.mock import patch, mock_open, call
import pytest
from api import es_valido, exportar, escribir_csv

@pytest.fixture
def usuario_min():
	return {
		'email' : 'minimo@ejemplo.com',
		'nombre' : 'Sarasa sarasa',
		'edad' : 18,
	}

@pytest.fixture
def usuario_completo():
	return {
		'email' : 'completo@ejemplo.com',
		'nombre': 'Martin Miranda',
		'edad' : 44,
		'rol' : 'CEO',
	}

@pytest.fixture
def usuarios(usuario_min, usuario_completo):
	mal_usuario = {
		'email' : 'invalido@ejemplo.com',
		'nombre' : 'no valido',
	}
	return [usuario_min, mal_usuario, usuario_completo]

class TestEsValido:

	def test_minimo(self, usuario_min):
		assert es_valido(usuario_min)

	def test_completo(self, usuario_completo):
		assert es_valido(usuario_completo)

	@pytest.mark.parametrize('edad', range(18))
	def test_edad_invalida_muy_joven(self, edad, usuario_min):
		usuario_min['edad'] = edad
		assert not es_valido(usuario_min)

	@pytest.mark.parametrize('edad', range(66, 100))
	def test_edad_invalida_muy_viejo(self, edad, usuario_min):
		usuario_min['edad'] = edad
		assert not es_valido(usuario_min)

	@pytest.mark.parametrize('edad', ['NaN', 3.1415, None])
	def test_edad_invalida_tipo_mal(self, edad, usuario_min):
		usuario_min['edad'] = edad
		assert not es_valido(usuario_min)

	@pytest.mark.parametrize('edad', range(18, 66))
	def test_valido_edad(self, edad, usuario_min):
		usuario_min['edad'] = edad
		assert es_valido(usuario_min)

	@pytest.mark.parametrize('campo',['email', 'nombre', 'edad'])
	def test_campos_mandatorios(self, campo, usuario_min):
		usuario_min.pop(campo)
		assert not es_valido(usuario_min)

	@pytest.mark.parametrize('campo',['email', 'nombre', 'edad'])
	def test_campos_mandatorios_vacio(self, campo, usuario_min):
		usuario_min[campo] = ''
		assert not es_valido(usuario_min)

	def test_nombre_solo_espacioblanco(self, usuario_min):
		usuario_min['nombre'] = '\n\t'
		assert not es_valido(usuario_min)

	@pytest.mark.parametrize(
		'email, salida',
		[
			('missing_at,com', False),
			('@missing_start.com', False),
			('perdido@', False),
			('invalido@example', False),

			('buenejemplo@ejemplo.com', True),
			('δοκιμή@παράδειγμα.δοκιμή', True),
			('аджай@экзампл.рус', True),
		]
	)
	def test_email(self, email, salida, usuario_min):
		usuario_min['email'] = email
		assert es_valido(usuario_min) == salida

	@pytest.mark.parametrize(
		'campo, valor',
		[
			('email', None),
			('email', 3.1415),
			('email', {}),

			('nombre', None),
			('nombre', 3.1415),
			('nombre', {}),

			('rol', None),
			('rol', 3.1415),
			('rol', {}),
		]
	)
	def test_tipos_invalidos(self, campo, valor, usuario_min):
		usuario_min[campo] = valor
		assert not es_valido(usuario_min)

class TestExportar:

	@pytest.fixture
	def archivo_csv(self, tmpdir):
		yield tmpdir.join("salida.csv")

	@pytest.fixture
	def archivo_existente(self, tmpdir):
		existente = tmpdir.join('existente.csv')
		existente.write('Dejenme solo')
		yield existente

	def test_exportar(self, usuarios, archivo_csv):
		exportar(archivo_csv, usuarios)

		lineas = archivo_csv.readlines()

		assert [
			'email,nombre,edad,rol\n',
			'minimo@ejemplo.com,Sarasa sarasa,18,\n',
			'completo@ejemplo.com,Martin Miranda,44,CEO\n',
		] == lineas

	def test_exportar_citando(self, usuario_min, archivo_csv):
		usuario_min['nombre'] = 'Un nombre, con una coma'

		exportar(archivo_csv, [usuario_min])

		lineas = archivo_csv.readlines()
		assert [
			'email,nombre,edad,rol\n',
			'minimo@ejemplo.com,"Un nombre, con una coma",18,\n',
		] == lineas

	def test_no_sobreescribe(self, usuarios, archivo_existente):
		with pytest.raises(IOError) as err:
			exportar(archivo_existente, usuarios,
				overwrite=False)

		assert err.match(
			r"'{}' ya existe\.".format(archivo_existente)
		)

		assert archivo_existente.read() == 'Dejenme solo'
Anuncios

Con todo esto podemos pasar a realizar el test de nuestro archivo, como les recomende al principio deben crear una carpeta nueva y guardar estos dos archivos en la misma, ejecutaremos el test y veremos como trabaja mediante el siguiente video

Anuncios

En el video podemos ver como hace el testeo rapido, como nos muestra el resultado y nos advierte que algunas de las tareas que hicimos pronto se dejaran de usar, con esto hemos completado como hacer el test a nuestro software.

Anuncios

En resumen, hoy hemos visto como hacer un test en un codigo real, hemos instalado las herramientas necesarias, luego hemos creado el codigo al cual le haremos el test, hemos comentado todas sus actividades, luego hemos creado el codigo que hara el test, para finalmente ver como se ejecuta y trabaja, espero les haya sido util sigueme en tumblr, Twitter o Facebook para recibir una notificacion cada vez que subo un nuevo post en este blog, nos vemos en el proximo post.

Anuncios

Donación

Es para mantenimento del sitio, gracias!

$1.50