Pruebas unitarias y Python



Mi nombre es Vadim, soy un desarrollador líder en Mail.Ru Search. Compartiré nuestra experiencia en pruebas unitarias. El artículo consta de tres partes: en la primera le diré lo que generalmente logramos con la ayuda de las pruebas unitarias; la segunda parte describe los principios que seguimos; y de la tercera parte aprenderá cómo se implementan los principios mencionados en Python.

Objetivos


Es muy importante entender por qué está aplicando pruebas unitarias. Las acciones concretas dependerán de esto. Si usa las pruebas unitarias incorrectamente, o con su ayuda no hace lo que quería, entonces nada bueno saldrá de eso. Por lo tanto, es muy importante comprender de antemano qué objetivos persigue.

En nuestros proyectos, perseguimos varios objetivos.

El primero es la regresión banal: arreglar algo en el código, ejecutar las pruebas y descubrir que nada se rompió. Aunque, de hecho, no es tan simple como parece.

El segundo objetivo es evaluar el impacto de la arquitectura . Si introduce pruebas unitarias obligatorias en el proyecto, o simplemente está de acuerdo con los desarrolladores en el uso de pruebas unitarias, esto afectará inmediatamente el estilo de escritura del código. Es imposible escribir funciones en 300 líneas con 50 variables locales y 15 parámetros si estas funciones están sujetas a pruebas unitarias. Además, gracias a estas pruebas, las interfaces serán más comprensibles y aparecerán algunas áreas problemáticas. Después de todo, si el código no está tan caliente, entonces la prueba será una curva e inmediatamente llamará tu atención.

El tercer objetivo es aclarar el código . Supongamos que llega a un nuevo proyecto y recibe 50 MB de código fuente. Es posible que no pueda resolverlos. Si no hay pruebas unitarias, entonces la única forma de familiarizarse con el trabajo del código, además de leer la fuente, es el "método de empuje". Pero si el sistema es bastante complicado, puede llevar mucho tiempo obtener los códigos necesarios a través de la interfaz. Y gracias a las pruebas unitarias, puede ver cómo se ejecuta el código desde cualquier lugar.

El cuarto objetivo es simplificar la depuración . Por ejemplo, ha encontrado alguna clase y desea depurarla. Si en lugar de las pruebas unitarias solo hay pruebas del sistema, o no hay ninguna prueba, entonces solo queda llegar al lugar correcto a través de la interfaz. Sucedí que participé en un proyecto donde, para probar algunas características, me llevó media hora crear un usuario, cobrarle dinero, cambiar su estado, lanzar algún tipo de cron, para que este estado se transfiriera a otro lugar, luego haga clic en algo en la interfaz, inicie algo algún otro cron ... Después de media hora, finalmente apareció un programa de bonificación para este usuario. Y si me hicieran pruebas unitarias, podría llegar inmediatamente al lugar correcto.

Finalmente, el objetivo más importante y muy abstracto, que une a todos los anteriores, es la comodidad . Cuando tengo pruebas unitarias, experimento menos estrés cuando trabajo con código, porque entiendo lo que está sucediendo. Puedo tomar una fuente desconocida, corregir tres líneas, ejecutar pruebas y asegurarme de que el código funcione según lo previsto. Y ni siquiera es que las pruebas sean verdes: pueden ser rojas, sino exactamente donde espero. Es decir, entiendo cómo funciona el código.

Principios


Si comprende sus objetivos, puede comprender lo que debe hacerse para alcanzarlos. Y aquí comienzan los problemas. El hecho es que se han escrito muchos libros y artículos sobre pruebas unitarias, pero la teoría aún es muy inmadura.

Si alguna vez leyó artículos sobre pruebas unitarias, trató de aplicar lo descrito y no tuvo éxito, entonces es muy probable que la razón sea la imperfección de la teoría. Esto pasa todo el tiempo. Yo, como todos los desarrolladores, una vez pensé que el problema estaba en mí. Y luego se dio cuenta: no puede ser que me haya equivocado tantas veces. Y decidió que en las pruebas unitarias era necesario proceder de sus propias consideraciones, para actuar con más sensatez.

El consejo estándar que puede encontrar en todos los libros y artículos: "no debe probar la implementación, sino la interfaz". Después de todo, la implementación puede cambiar, pero la interfaz no. Probémoslo para que las pruebas no caigan todo el tiempo en cada ocasión. El consejo, al parecer, no es malo, y todo parece lógico. Pero lo sabemos muy bien: para probar algo, debe seleccionar algunos valores de prueba. Por lo general, cuando se prueban funciones, se distinguen las llamadas clases de equivalencia: el conjunto de valores en los que la función se comporta de manera uniforme. En términos generales, la prueba para cada si. Pero para saber qué clases de equivalencia tenemos, se necesita una implementación. No lo prueba, pero lo necesita, debe buscarlo para saber qué valores de prueba elegir.

Hable con cualquier probador: él le dirá que con las pruebas manuales siempre imagina una implementación. Según su experiencia, comprende perfectamente dónde los programadores suelen cometer errores. El probador no verifica todo, primero ingresa 5, luego 6, luego 7. Comprueba 5, abc, –7, y el número es de 100 caracteres, porque sabe que la implementación de estos valores puede diferir, pero para 6 y 7 es poco probable .

Por lo tanto, no está claro cómo seguir el principio de "probar la interfaz, no la implementación". No puedes simplemente tomar, cerrar los ojos y escribir un examen. TDD está tratando de resolver este problema en parte. La teoría sugiere introducir clases de equivalencia de una en una y escribir pruebas para ellas. He leído muchos libros y artículos sobre este tema, pero de alguna manera no se pega. Sin embargo, estoy de acuerdo con la tesis de que las pruebas deben escribirse primero. Llamamos a este principio prueba primero. No tenemos TDD, y en relación con lo anterior, las pruebas no se escriben antes de crear el código, sino en paralelo.

Definitivamente no recomiendo escribir pruebas retroactivamente. Después de todo, influyen en la arquitectura, y si ya se ha establecido, entonces es demasiado tarde para influir en ella, todo tendrá que ser reescrito. En otras palabras, la capacidad de prueba del código es una propiedad separada que el código tendrá que dotar , no se convertirá en tal. Por lo tanto, tratamos de escribir pruebas junto con el código. No creas en historias como "escribamos un proyecto en tres meses y luego cubramos todo con pruebas en una semana", esto nunca sucederá.

Lo más importante que debe comprender: las pruebas unitarias no son una forma de verificar el código, no una forma de verificar su corrección. Esto es parte de su arquitectura, el diseño de su aplicación. Cuando trabajas con pruebas unitarias, cambias tus hábitos. Las pruebas que solo verifican la corrección son, más bien, pruebas de aceptación. Será un error pensar que puede cubrir algo con pruebas unitarias, o que no será necesario verificar el código.

Implementación de Python


Usamos la biblioteca estándar unittest de la familia xUnit. La historia es esta: estaba el lenguaje SmallTalk, y en él la biblioteca SUnit. A todos les gustó, comenzaron a copiarlo. La biblioteca se importó a Java con el nombre de Junit, desde allí en C ++ con el nombre de CppUnit y a Ruby con el nombre de RUnit (luego se renombró a RSpec). Finalmente, desde Java, la biblioteca "se movió" a Python bajo el nombre unittest. Y lo importaron tan literalmente que incluso CamelCase se mantuvo, aunque esto no corresponde a PEP 8.

Acerca de xUnit hay un libro maravilloso, "Patrones de prueba de xUnit". Describe cómo trabajar con los marcos de esta familia. El único inconveniente del libro es su tamaño: es enorme, pero aproximadamente 2/3 de los contenidos son un catálogo de patrones. Y el primer tercio del libro es maravilloso, este es uno de los mejores libros sobre TI que he conocido.

Una prueba unitaria es un código regular que tiene una cierta arquitectura estándar. Todas las pruebas unitarias constan de tres etapas: configuración, ejercicio y verificación. Prepara los datos, ejecuta las pruebas y ve si todo ha llegado al estado correcto.



Configuración


La etapa más difícil e interesante. Llevar el sistema a su estado original desde el que desea probarlo puede ser muy difícil. Y el estado del sistema puede ser arbitrariamente complejo.

Para cuando se llama a su función, podrían haber ocurrido muchos eventos, se podrían haber creado un millón de objetos en la memoria. En todos los componentes asociados con su software, en el sistema de archivos, base de datos, cachés, algo ya está ubicado y la función solo puede funcionar en este entorno. Y si el entorno no está preparado, entonces las acciones de la función no tendrán sentido.

Por lo general, todos afirman que en ningún caso puede usar sistemas de archivos, bases de datos o cualquier otro componente separado, porque esto hace que su prueba no sea modular, sino de integración. En mi opinión, esto no es cierto, porque la prueba de integración se realiza mediante la prueba de integración. Si usa algunos componentes no para verificación, sino solo para hacer que el sistema funcione, no hay nada de malo en eso. Su código interactúa con muchos componentes de la computadora y el sistema operativo. El único problema con el uso de un sistema de archivos o base de datos es la velocidad.

Directamente en el código, usamos la inyección de dependencia . Puede lanzar parámetros en la función en lugar de los predeterminados. Incluso puede reenviar enlaces a bibliotecas. O puede deslizar un código auxiliar en lugar de una solicitud para que el código de las pruebas no acceda a la red. Puede almacenar registradores personalizados en los atributos de clase para no escribir en el disco y ahorrar tiempo.

Para los talones, usamos el simulacro habitual de unittest. También hay una función de parche que, en lugar de implementar honestamente dependencias, simplemente dice: "en este paquete, esa importación es un sustituto de otro". Es conveniente porque no tienes que tirar nada a ningún lado. Es cierto, entonces no está claro quién reemplazó qué, así que úselo con cuidado.

En cuanto al sistema de archivos, fingirlo es bastante simple. Hay un módulo io con io.StringIO y io.BytesIO . Puede crear objetos similares a archivos que en realidad no acceden al disco. Pero si de repente esto no es suficiente para usted, entonces hay un maravilloso módulo temporal con administradores de contexto para archivos temporales, directorios, archivos con nombre, cualquier cosa. Tempfile es un supermódulo si por alguna razón IO no te queda bien.

Con una base de datos, todo es más complicado. Hay una recomendación estándar: "No use una base real, sino falsa". No sé sobre ti, pero en mi vida no he visto una sola base falsa y suficientemente funcional. Cada vez que pedía consejo sobre qué tomar específicamente bajo Python o Perl, respondían que nadie sabía nada listo y se ofrecían a escribir algo propio. No puedo imaginar cómo puedes escribir un emulador, por ejemplo, PostgreSQL. Otro consejo: "luego obtén SQLite". Pero esto romperá el aislamiento, porque SQLite funciona con el sistema de archivos. Además, si usa algo como MySQL o PostgreSQL, entonces SQLite probablemente no funcionará. Si le parece que no está utilizando las capacidades específicas de productos específicos, lo más probable es que esté equivocado. Seguramente, incluso para cosas comunes, como trabajar con fechas, utiliza funciones específicas que solo admite su DBMS.

Como resultado, generalmente usan una base real. La solución no es mala, solo necesitamos mostrar una cierta cantidad de precisión. No use una base de datos centralizada, porque las pruebas pueden romperse entre sí. Idealmente, la base misma debería elevarse durante las pruebas y detenerse después de la prueba.

Una situación ligeramente peor es cuando se requiere que ejecute una base de datos local, que se utilizará. Pero la pregunta es, ¿cómo llegarán allí los datos? Ya hemos dicho que debe haber algún estado inicial del sistema, debe haber algunos datos en la base de datos. De dónde vienen no es una pregunta fácil.

El enfoque más ingenuo que he encontrado es utilizar una copia de una base de datos real. Se le tomó una copia regularmente, de la cual se eliminaron datos confidenciales. Los autores razonaron que los datos reales son los más adecuados para las pruebas. Además, escribir pruebas para una copia de una base de datos real es un tormento. No sabes qué datos hay. Primero debes encontrar lo que vas a probar. Si esta información no está allí, entonces qué hacer no está claro. Terminó que en ese proyecto decidieron escribir pruebas para la cuenta del departamento de operaciones, que "nunca cambiará". Por supuesto, después de un tiempo ella cambió.

Esto generalmente es seguido por la decisión: “hagamos un reparto de la base real, copiemos y ya no sincronicemos. Entonces será posible estar atado a un objeto específico, observar lo que sucede allí y escribir pruebas ". La pregunta surge de inmediato: ¿qué sucederá cuando se agreguen nuevas tablas a la base de datos? Aparentemente, tendrá que ingresar manualmente datos falsos.

Pero como lo haremos de todos modos, preparemos inmediatamente el molde base manualmente. Esta opción es muy similar a lo que generalmente se llaman accesorios en Django: hacen JSON enormes, cargan casos de prueba para todas las ocasiones, los envían a la base de datos al comienzo de las pruebas, y todo estará bien con nosotros. Este enfoque también tiene muchas desventajas. Los datos se apilan en un montón, no está claro a qué prueba se refiere. Nadie puede entender si los datos se eliminaron o no. Y hay estados incompatibles de la base de datos: por ejemplo, una prueba no necesita tener usuarios en la base de datos, y la otra para tenerlos. Estas dos condiciones no pueden almacenarse simultáneamente en el mismo molde. En este caso, una de las pruebas tendrá que modificar la base de datos. Y dado que todavía tiene que lidiar con esto de todos modos, es más fácil comenzar desde una base de datos vacía, de modo que cada prueba coloque los datos necesarios allí, y al final de la prueba borra la base de datos. El único inconveniente de este enfoque es la dificultad de crear datos en cada prueba. En uno de los proyectos en los que trabajé, para crear un servicio, era necesario generar 8 entidades en diferentes tablas: un servicio en una cuenta personal, una cuenta personal en un cliente, un cliente en una entidad legal, una entidad legal en una ciudad, un cliente en una ciudad, etc. Hasta que cree todo esto en una cadena, no satisfará la clave externa, nada funciona.

Para tales situaciones, hay bibliotecas especiales que facilitan enormemente la vida. Puede escribir herramientas auxiliares, generalmente llamadas fábricas (no confunda con el patrón de diseño). Por ejemplo, utilizamos la biblioteca factory_boy, que es adecuada para Django. Este es un clon de la biblioteca factory_girl, que pasó a llamarse factory_bot el año pasado por razones de corrección política. Escribir una biblioteca de este tipo para su propio marco no cuesta nada. Se basa en una idea muy importante: una vez que crea una fábrica para los objetos que desea generar, establece conexiones para él y luego le dice al usuario: "cuando sea creado, tome su próximo nombre y genere el grupo usted mismo usando la fábrica del grupo". Y en la fábrica, todo es exactamente lo mismo: generar el nombre de tal manera, entidades relacionadas tal y tal.

Como resultado, solo queda una última línea en el código: user = UserFactory() . El usuario ha sido creado y puedes trabajar con él, porque debajo del capó generó todo lo que se necesita. Si lo desea, puede configurar algo manualmente.

Para limpiar los datos después de la prueba, utilizamos transacciones triviales. Al comienzo de cada prueba, se COMIENZA, la prueba hace algo con la base, y después de la prueba, se realiza ROLLBACK. Si se necesitan transacciones en la prueba en sí, por ejemplo, porque compromete algo extra en la base de datos, llama al método que llamamos break_db , le dice al marco que rompió la base de datos y el marco lo vuelve a lanzar. Resulta lento, pero como generalmente hay muy pocas pruebas que necesitan transacciones, todo está en orden.

Ejercicio


No hay nada especial que contar sobre esta etapa. Lo único que puede salir mal aquí es recurrir, por ejemplo, a Internet. Durante algún tiempo, tuvimos problemas con esto administrativamente: les dijimos a los programadores que debemos sumergir las funciones que van a algún lado o lanzar banderas especiales para que las funciones no lo hagan. Si la prueba accede a etcd corporativo, esto no es bueno. Como resultado, llegamos a la conclusión de que todo se desperdició: nosotros mismos olvidamos constantemente que alguna función llama a una función que llama a una función que va a etcd. Por lo tanto, en la configuración de la clase base, agregamos el moki de todas las llamadas, es decir, bloqueado con la ayuda de stubs todas las llamadas donde no se ponen.

Los apéndices se pueden hacer fácilmente con parches, poner los parches en un diccionario separado y dar acceso a todas las pruebas. Por defecto, las pruebas no pueden ir a ninguna parte, y si para algunos aún necesita abrir el acceso, puede redirigirlo. Muy comodo Jenkins ya no enviará SMS a sus clientes por la noche :)

Verificar


En esta etapa, utilizamos activamente certificaciones autoescritas, incluso de una sola línea. Si prueba la existencia de un archivo en la prueba, en lugar de afirmar self.assertTrue(file_exists(f)) recomiendo escribir afirmar que not file exists . Holivar está conectado con esto: ¿debo seguir usando CamelCase en los nombres, como en unittest, o debo seguir PEP 8? No tengo respuesta Si sigue PEP 8, entonces en el código de prueba habrá un desastre de CamelCase y snake_case. Y si usa CamelCase, entonces esto no corresponde a PEP 8.

Y el último. Suponga que tiene un código que está probando algo, y hay muchas opciones de datos en las que este código debe ejecutarse. Si usa py.test, allí puede ejecutar la misma prueba con diferentes datos de entrada. Si no tiene py.test, puede usar dicho decorador . Se pasa una tabla al decorador, y una prueba se convierte en varias otras, cada una de las cuales prueba uno de los casos.

Conclusión


No confíes en artículos y libros incondicionalmente. Si crees que están equivocados, es posible que así sea.

Siéntase libre de usar pruebas de dependencia. No hay nada de malo en eso. Si levantó Memcached, porque sin él su código no funciona normalmente, está bien. Pero es mejor prescindir de él, si es posible.

Presta atención a las fábricas. Este es un patrón muy interesante.

PD: los invito al canal Telegram de mi autor para programar en Python: @pythonetc.

Source: https://habr.com/ru/post/es418929/


All Articles