Una introducción a la burla en Python
Publicado: 2022-03-11Cómo ejecutar pruebas unitarias en Python sin poner a prueba tu paciencia
La mayoría de las veces, el software que escribimos interactúa directamente con lo que etiquetaríamos como servicios "sucios". En términos sencillos: servicios que son cruciales para nuestra aplicación, pero cuyas interacciones tienen efectos secundarios intencionados pero no deseados, es decir, no deseados en el contexto de una ejecución de prueba autónoma.
Por ejemplo: tal vez estamos escribiendo una aplicación social y queremos probar nuestra nueva función 'Publicar en Facebook', pero no queremos publicar en Facebook cada vez que ejecutamos nuestro conjunto de pruebas.
La biblioteca de Python unittest
incluye un subpaquete llamado unittest.mock
, o si lo declara como una dependencia, simplemente mock
, que proporciona medios extremadamente poderosos y útiles para simular y eliminar estos efectos secundarios no deseados.
Nota: el mock
se incluye recientemente en la biblioteca estándar a partir de Python 3.3; las distribuciones anteriores tendrán que usar la biblioteca Mock descargable a través de PyPI.
Llamadas al sistema frente a simulación de Python
Para darle otro ejemplo, y uno con el que trabajaremos en el resto del artículo, considere las llamadas al sistema . No es difícil ver que estos son los principales candidatos para burlarse: ya sea que esté escribiendo un script para expulsar una unidad de CD, un servidor web que elimina archivos de caché anticuados de /tmp
o un servidor de socket que se vincula a un puerto TCP, estos llama a todos los efectos secundarios no deseados en el contexto de sus pruebas unitarias.
Como desarrollador, le importa más que su biblioteca llame con éxito a la función del sistema para expulsar un CD (con los argumentos correctos, etc.) en lugar de tener la bandeja de CD abierta cada vez que se ejecuta una prueba. (¡O peor aún, varias veces, ya que varias pruebas hacen referencia al código de expulsión durante una sola ejecución de prueba unitaria!)
Del mismo modo, mantener la eficiencia y el rendimiento de sus pruebas unitarias significa mantener la mayor cantidad de "código lento" fuera de las ejecuciones de prueba automatizadas, es decir, el sistema de archivos y el acceso a la red.
Para nuestro primer ejemplo, refactorizaremos un caso de prueba estándar de Python desde su forma original a una usando mock
. Demostraremos cómo escribir un caso de prueba con simulacros hará que nuestras pruebas sean más inteligentes, más rápidas y capaces de revelar más sobre cómo funciona el software.
Una función de eliminación simple
Todos necesitamos eliminar archivos de nuestro sistema de archivos de vez en cuando, así que escribamos una función en Python que hará que sea un poco más fácil para nuestros scripts hacerlo.
#!/usr/bin/env python # -*- coding: utf-8 -*- import os def rm(filename): os.remove(filename)
Obviamente, nuestro método rm
en este momento no proporciona mucho más que el método subyacente os.remove
, pero nuestra base de código mejorará, permitiéndonos agregar más funcionalidad aquí.
Escribamos un caso de prueba tradicional, es decir, sin simulacros:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import os.path import tempfile import unittest class RmTestCase(unittest.TestCase): tmpfilepath = os.path.join(tempfile.gettempdir(), "tmp-testfile") def setUp(self): with open(self.tmpfilepath, "wb") as f: f.write("Delete me!") def test_rm(self): # remove the file rm(self.tmpfilepath) # test that it was actually removed self.assertFalse(os.path.isfile(self.tmpfilepath), "Failed to remove the file.")
Nuestro caso de prueba es bastante simple, pero cada vez que se ejecuta, se crea un archivo temporal y luego se elimina. Además, no tenemos forma de probar si nuestro método rm
pasa correctamente el argumento a la llamada os.remove
. Podemos suponer que lo hace según la prueba anterior, pero queda mucho que desear.
Refactorización con simulacros de Python
Refactoricemos nuestro caso de prueba usando mock
:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import mock import unittest class RmTestCase(unittest.TestCase): @mock.patch('mymodule.os') def test_rm(self, mock_os): rm("any path") # test that rm called os.remove with the right parameters mock_os.remove.assert_called_with("any path")
Con estos refactores, hemos cambiado fundamentalmente la forma en que opera la prueba. Ahora, tenemos un insider , un objeto que podemos usar para verificar la funcionalidad de otro.
Peligros potenciales para burlarse de Python
Una de las primeras cosas que debe destacarse es que estamos usando el decorador del método mock.patch
para simular un objeto ubicado en mymodule.os
e inyectando ese simulacro en nuestro método de caso de prueba. ¿No tendría más sentido burlarse del mymodule.os
os
Bueno, Python es algo así como una serpiente astuta cuando se trata de importar y administrar módulos. En tiempo de ejecución, el módulo mymodule
tiene su propio sistema os
que se importa a su propio ámbito local en el módulo. Por lo tanto, si nos burlamos de os
, no veremos los efectos de la simulación en el módulo mymodule
.
El mantra a seguir repitiendo es este:
Burlarse de un artículo donde se usa, no de dónde vino.
Si necesita tempfile
el módulo de archivo temporal para myproject.app.MyElaborateClass
, probablemente necesite aplicar el simulacro a myproject.app.tempfile
, ya que cada módulo mantiene sus propias importaciones.
Con esa trampa fuera del camino, sigamos burlándonos.
Agregar validación a 'rm'
El método rm
definido anteriormente está bastante simplificado. Nos gustaría que valide que existe una ruta y que es un archivo antes de simplemente intentar eliminarlo a ciegas. Refactoricemos rm
para que sea un poco más inteligente:
#!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path def rm(filename): if os.path.isfile(filename): os.remove(filename)
Genial. Ahora, ajustemos nuestro caso de prueba para mantener la cobertura.
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import mock import unittest class RmTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # set up the mock mock_path.isfile.return_value = False rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True rm("any path") mock_os.remove.assert_called_with("any path")
Nuestro paradigma de pruebas ha cambiado por completo. Ahora podemos verificar y validar la funcionalidad interna de los métodos sin efectos secundarios.
Eliminación de archivos como servicio con Mock Patch
Hasta ahora, solo hemos estado trabajando con el suministro de simulacros para funciones, pero no para métodos en objetos o casos en los que el simulacro es necesario para enviar parámetros. Primero cubramos los métodos de objetos.
Comenzaremos con una refactorización del método rm
en una clase de servicio. Realmente no hay una necesidad justificable, per se, de encapsular una función tan simple en un objeto, pero al menos nos ayudará a demostrar conceptos clave en el mock
. Refactoricemos:
#!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path class RemovalService(object): """A service for removing objects from the filesystem.""" def rm(filename): if os.path.isfile(filename): os.remove(filename)
Notarás que no ha cambiado mucho en nuestro caso de prueba:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path")
Genial, ahora sabemos que RemovalService
funciona según lo planeado. Vamos a crear otro servicio que lo declare como una dependencia:
#!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path class RemovalService(object): """A service for removing objects from the filesystem.""" def rm(self, filename): if os.path.isfile(filename): os.remove(filename) class UploadService(object): def __init__(self, removal_service): self.removal_service = removal_service def upload_complete(self, filename): self.removal_service.rm(filename)
Dado que ya tenemos cobertura de prueba en RemovalService
, no vamos a validar la funcionalidad interna del método rm
en nuestras pruebas de UploadService
. Más bien, simplemente probaremos (sin efectos secundarios, por supuesto) que UploadService
llama al método RemovalService.rm
, que sabemos que "simplemente funciona" de nuestro caso de prueba anterior.

Hay dos maneras de hacer esto:
- Simule el propio método
RemovalService.rm
. - Proporcione una instancia simulada en el constructor de
UploadService
.
Como ambos métodos suelen ser importantes en las pruebas unitarias, revisaremos ambos.
Opción 1: Métodos de instancias simuladas
La biblioteca mock
tiene un decorador de métodos especial para simular métodos y propiedades de instancias de objetos, el decorador @mock.patch.object
:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService, UploadService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path") class UploadServiceTestCase(unittest.TestCase): @mock.patch.object(RemovalService, 'rm') def test_upload_complete(self, mock_rm): # build our dependencies removal_service = RemovalService() reference = UploadService(removal_service) # call upload_complete, which should, in turn, call `rm`: reference.upload_complete("my uploaded file") # check that it called the rm method of any RemovalService mock_rm.assert_called_with("my uploaded file") # check that it called the rm method of _our_ removal_service removal_service.rm.assert_called_with("my uploaded file")
¡Genial! Hemos validado que UploadService
llama con éxito al método rm
de nuestra instancia. ¿Notas algo interesante ahí? El mecanismo de aplicación de parches en realidad reemplazó el método rm
de todas las instancias de RemovalService
en nuestro método de prueba. Eso significa que podemos inspeccionar las instancias mismas. Si desea ver más, intente colocar un punto de interrupción en su código de simulación para tener una buena idea de cómo funciona el mecanismo de parcheo.
Trampa de parche simulado: orden de decoradores
Cuando usa varios decoradores en sus métodos de prueba, el orden es importante y es un poco confuso. Básicamente, al asignar decoradores a parámetros de método, trabaje al revés. Considere este ejemplo:
@mock.patch('mymodule.sys') @mock.patch('mymodule.os') @mock.patch('mymodule.os.path') def test_something(self, mock_os_path, mock_os, mock_sys): pass
¿Observa cómo nuestros parámetros coinciden con el orden inverso de los decoradores? Eso se debe en parte a la forma en que funciona Python. Con decoradores de múltiples métodos, este es el orden de ejecución en pseudocódigo:
patch_sys(patch_os(patch_os_path(test_something)))
Dado que el parche para sys
es el parche más externo, se ejecutará en último lugar, lo que lo convierte en el último parámetro en los argumentos reales del método de prueba. Tome nota de esto y use un depurador cuando ejecute sus pruebas para asegurarse de que los parámetros correctos se inyectan en el orden correcto.
Opción 2: crear instancias simuladas
En lugar de simular el método de instancia específico, podríamos proporcionar una instancia simulada a UploadService
con su constructor. Prefiero la opción 1 anterior, ya que es mucho más precisa, pero hay muchos casos en los que la opción 2 podría ser eficiente o necesaria. Refactoricemos nuestra prueba nuevamente:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService, UploadService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path") class UploadServiceTestCase(unittest.TestCase): def test_upload_complete(self, mock_rm): # build our dependencies mock_removal_service = mock.create_autospec(RemovalService) reference = UploadService(mock_removal_service) # call upload_complete, which should, in turn, call `rm`: reference.upload_complete("my uploaded file") # test that it called the rm method mock_removal_service.rm.assert_called_with("my uploaded file")
En este ejemplo, ni siquiera tuvimos que parchear ninguna funcionalidad, simplemente creamos una especificación automática para la clase RemovalService
y luego inyectamos esta instancia en nuestro UploadService
para validar la funcionalidad.
El método mock.create_autospec
crea una instancia funcionalmente equivalente a la clase proporcionada. Lo que esto significa, en términos prácticos, es que cuando se interactúa con la instancia devuelta, generará excepciones si se usa de manera ilegal. Más específicamente, si se llama a un método con un número incorrecto de argumentos, se generará una excepción. Esto es extremadamente importante a medida que ocurren los refactores. A medida que cambia una biblioteca, las pruebas se rompen y eso es de esperar. Sin usar una especificación automática, nuestras pruebas aún pasarán aunque la implementación subyacente esté rota.
Trampa: Las clases mock.Mock
y mock.MagicMock
La biblioteca mock
también incluye dos clases importantes sobre las que se basa la mayor parte de la funcionalidad interna: mock.Mock
y mock.MagicMock
. Cuando se le dé la opción de usar una instancia de mock.Mock
, una instancia de mock.MagicMock
o una especificación automática, siempre prefiera usar una especificación automática, ya que ayuda a mantener sus pruebas sanas para cambios futuros. Esto se debe a que mock.Mock
y mock.MagicMock
aceptan todas las llamadas a métodos y asignaciones de propiedades independientemente de la API subyacente. Considere el siguiente caso de uso:
class Target(object): def apply(value): return value def method(target, value): return target.apply(value)
Podemos probar esto con una instancia de mock.Mock
como esta:
class MethodTestCase(unittest.TestCase): def test_method(self): target = mock.Mock() method(target, "value") target.apply.assert_called_with("value")
Esta lógica parece sensata, pero modifiquemos el método Target.apply
para tomar más parámetros:
class Target(object): def apply(value, are_you_sure): if are_you_sure: return value else: return None
Vuelva a ejecutar su prueba y encontrará que todavía pasa. Eso es porque no está construido contra su API real. Esta es la razón por la que siempre debe usar el método create_autospec
y el parámetro autospec
con los @patch
y @patch.object
.
Ejemplo simulado de Python: simulacro de una llamada a la API de Facebook
Para terminar, escribamos un ejemplo simulado de python del mundo real más aplicable, uno que mencionamos en la introducción: publicar un mensaje en Facebook. Escribiremos una buena clase contenedora y un caso de prueba correspondiente.
import facebook class SimpleFacebook(object): def __init__(self, oauth_token): self.graph = facebook.GraphAPI(oauth_token) def post_message(self, message): """Posts a message to the Facebook wall.""" self.graph.put_object("me", "feed", message=message)
Aquí está nuestro caso de prueba, que comprueba que publicamos el mensaje sin publicarlo realmente :
import facebook import simple_facebook import mock import unittest class SimpleFacebookTestCase(unittest.TestCase): @mock.patch.object(facebook.GraphAPI, 'put_object', autospec=True) def test_post_message(self, mock_put_object): sf = simple_facebook.SimpleFacebook("fake oauth token") sf.post_message("Hello World!") # verify mock_put_object.assert_called_with(message="Hello World!")
Como hemos visto hasta ahora, es realmente simple comenzar a escribir pruebas más inteligentes con mock
en Python.
Conclusión
La biblioteca mock
de Python, aunque un poco confusa para trabajar, es un cambio de juego para las pruebas unitarias. Hemos demostrado casos de uso comunes para comenzar a usar mock
en pruebas unitarias y, con suerte, este artículo ayudará a los desarrolladores de Python a superar los obstáculos iniciales y escribir un código excelente y probado.