00:00:00

pytest

recomendaciones, paquetes básicos para testing en Python y Django

@avallbona

Notes

pytest: recomendaciones, paquetes básicos para testing en Python y Django · Septiembre 2017 · apsl.net · @avallbona

whoami/whoarewe

  • Andreu Vallbona
  • Ingeniero técnico en administración de sistemas (UOC)
  • Programando profesionalmente desde 1995
  • Desarrollador en APSL, Mallorca
  • https://www.linkedin.com/in/andreu-vallbona-plazas-b0b58720/
  • Qué hacemos en APSL:
    • desarrollo web
    • ingeniería de sistemas - devops
    • aplicaciones móviles
    • data science
    • consultoría y formación

Notes

pytest: recomendaciones, paquetes básicos para testing en Python y Django · Septiembre 2017 · apsl.net · @avallbona

ventajas del testing

asegurar la calidad del código

tranquilidad a la hora de realizar cambios

facilitar subidas de versión de Python y/o Django

facilidad en cambios de sistemas de bbdd

Notes

- con los test conseguimos que muchos tickets abiertos por el cliente (que hace uso de una api) no sean en realidad bugs sino errores por su parte

- comentar el caso de logiback_server (python 3.4)

- comentar cas logicms (mysql->python)
pytest: recomendaciones, paquetes básicos para testing en Python y Django · Septiembre 2017 · apsl.net · @avallbona

recomendaciones básicas 1

no sofisticar demasiado los tests

tests autocontenidos e independientes

usar fixtures

usar parametrize

sobreescribir settings por defecto

Notes

- No sofisticar demasiado la lógica de los tests, mantenerlos tan simples como se pueda. Si se sofistican demasiado son dificiles de retormar tiempo después por uno mismo o por otro desarrollador. Son difíciles de mantener. Intentar testear una sola funcionalidad a la vez

- Los tests tienen que ser autocontenidos e independientes de cualquier otro test, cuando se ejecutan de manera concurrente no podemos saber en que orden se ejecutaran los tests en cada thread

- Evitar el copy/paste de valores para los tests haciendo uso de **fixtures**, propios or custom

- Evitar también copy/paste de código haciendo uso del **parametrize**, permite mútiples llamadas con diferentes valores a una misma función de test

- Sobreescribir settings por defecto. E.g.: sobreescribir el backend de envío de emails en el entorno de test
pytest: recomendaciones, paquetes básicos para testing en Python y Django · Septiembre 2017 · apsl.net · @avallbona

recomendaciones básicas 2

bbdd test en local y/o memoria

cuidado con los signals de Django

mocking de servicios externos

ejecución concurrente

revisar PEP8

Notes

- Importancia de bbdd en local y/o en un tablespace en memoria para acelerar la ejecución de los tests

- Evitar ejecución de *signals* de **Django** en el entorno de test

- Mocking, nos sirve para definir los diferentes tipos de respuesta que nos puede devolver un servicio externo. E.g.: stripe, sin necesidad de llegar a conectarnos a los mismos

- Ejecución de los tests de manera concurrente es conveniente cuando tenemos muchos tests

- Cuidado con el **django-constance**, en entorno de test cambiar el backend
pytest: recomendaciones, paquetes básicos para testing en Python y Django · Septiembre 2017 · apsl.net · @avallbona

fixture

fixtures son objetos que predefinimos y después pueden ser usados por nuestras funciones de test

se pueden definir diferentes scopes:

  • nivel de función: se ejecuta una vez por test
  • nivel de clase: se ejecuta una vez por clase de tests
  • nivel de módulo: se ejecuta una ver por módulo
  • nivel de sesión: se ejecuta una vez por sesión

ejemplo de fixture:

1 import smtplib
2 import pytest
3 
4 @pytest.fixture(scope="module")
5 def smtp():
6     smtp = smtplib.SMTP("smtp.gmail.com", 587, timeout=5)
7     yield smtp  # provide the fixture value
8     print("teardown smtp")
9     smtp.close()

Notes

- concepto fundamental en pytest

- muy útil para evitar repetición de código

- nueva manera de ejecutar código de **tearUp** i **tearDown**
pytest: recomendaciones, paquetes básicos para testing en Python y Django · Septiembre 2017 · apsl.net · @avallbona

pytest-django

plugin de pytest que nos proporciona toda una serie de helpers y fixtures muy útiles para testear proyectos implementados en Django

entre otros podemos destacar:

  • django_db - marker da acceso a la bbdd
  • rf - request factory
  • client - cliente de test
  • admin_client - cliente de test autenticado
  • admin_user - superusuario autenticado
  • settings - acceso a los settings
  • mailoutbox - buzón de correo donde testear el envío de emails

Notes

- django_db - marker que sirve para dar acceso a la bbdd a una función o clase, se ejecuta en una transacción y restaura el estado de la bbdd al final del test

- rf - útil para tests de vistas (views)

- client i admin_client - útiles para tests de API's

- tema scope de los fixtures, fija cada cuando se ejecuta el fixture

- autoinyección de un fixture en cada test con **autouse**
pytest: recomendaciones, paquetes básicos para testing en Python y Django · Septiembre 2017 · apsl.net · @avallbona

model-mommy

plugin que nos sirve para crear fácilmente fixtures basados en modelos de django de manera muy sencilla

  • se generan valores de los campos de manera automática
  • contenido random de los campos pero se pueden especificar individualmente
  • se pueden crear objetos:
    • solo en memoria (mommy.prepare) útil para test unitario métodos modelo
    • persistentes (mommy.make) útil para tests de integración
  • se pueden definir relaciones entre objetos (fk, m2m)
  • se pueden definir recipes, que son como plantillas
  • se pueden definir campos secuencia

Notes

* evita tener que mantener fixtures estàticos

* rellena automáticamente los campos obligatorios con el correspondiente tipo de dato se pueden especificar los valores que se quieren especificar o dejarlos que se generen random los valores generados automáticamente son random

* en las claves foraneas se pueden generar objectos padre de manera automática. ejemplo Perro (hijo) <- Dueño (padre), al generar un perro(hijo) se genera de manera automática un dueño(padre)

* comentar **_quantity** per generar **N** elements d'un model

* recipes son como templates a partir de las cuales podemos generar instancias

* se pueden generar secuencias en las recipes para evitar problemas con los campos que su valor tiene que ser único

* es parecido a factory_boy pero más simple y fàcil de usar, al final hay un enlace comparando los dos, model-mommy está pensado para Django, Factory_boy es más genérico
pytest: recomendaciones, paquetes básicos para testing en Python y Django · Septiembre 2017 · apsl.net · @avallbona

model-mommy (2)

dado el siguiente modelo

 1 class Suscripcion(models.Model):
 2     uuid = models.UUIDField(unique=True, default=uuid4, editable=False, verbose_name=_('Uuid'))
 3     administrador = models.ForeignKey(
 4         Administrador, verbose_name=_('Administrador'), related_name='suscripciones_administrador'
 5     )
 6     status = models.CharField(_('Estado suscripción'), max_length=9, default=TRIAL_STATUS, choices=STATUS_CHOICES)
 7     trial_end = models.DateTimeField(verbose_name=_('Fin periodo prueba'), blank=True, null=True)
 8     comunidades_contratadas = models.IntegerField(
 9         verbose_name=_('Comunidades contratadas'), default=1,
10     )
11     viviendas_contratadas = models.IntegerField(
12         verbose_name=_('Viviendas contratadas'), default=0, null=True
13     )
14     garajes_contratados = models.IntegerField(
15         verbose_name=_('Garajes contratados'), default=0, null=True
16     )
17     locales_contratados = models.IntegerField(
18         verbose_name=_('Locales contratados'), default=0, null=True
19     )
20     trasteros_contratados = models.IntegerField(
21         verbose_name=_('Trasteros contratados'), default=0, null=True
22     )
23 
24     class Meta:
25         verbose_name = _('Suscripción')
26         verbose_name_plural = _('Suscripciones')

Notes

- ejemplo de modelo con varios campos
pytest: recomendaciones, paquetes básicos para testing en Python y Django · Septiembre 2017 · apsl.net · @avallbona

model-mommy (3)

podríamos generar una instancia del mismo con:

1 @pytest.fixture()
2 def trial_subscription_obj(self, admin_obj, community_obj, precios):
3     return mommy.make(
4         Suscripcion,
5         administrador=admin_obj,
6         status=Suscripcion.TRIAL_STATUS,
7         trial_end=SubscriptionBO()._set_trial_end()
8     )

y usarla después en un test:

1 def test_expire_trial_subscription(self, trial_subscription_obj):
2     sus = SubscriptionBO()
3     sus.expire_subscription(trial_subscription_obj)
4 
5     # check for subscription status
6     subs = Suscripcion.objects.get(pk=trial_subscription_obj.pk)
7     assert subs
8     assert subs.status == subs.EXPIRED_STATUS

Notes

- a partir de la clase del objeto hemos generado una instancia sólo con los campos específicos deseados

- los campos no especificados se generan con contenido random

- para la construcción de la instancia podemos basarnos en otros fixtures
pytest: recomendaciones, paquetes básicos para testing en Python y Django · Septiembre 2017 · apsl.net · @avallbona

model-mommy (4)

ejemplo de campos secuencia

1 from model_mommy.recipe import seq
2 
3 admins = mommy.make(Administrador, nombre=seq('Test User '), _quantity=3)

que generaria 3 instancias de la classe Administrador y el valor del campo nombre seria respectivamente

1 In [5]: for it in admins:
2    ...:     print(it.nombre)
3    ...:
4 Test User 1
5 Test User 2
6 Test User 3

Notes

- nos puede servir para evitar problemas de unicidad del tipo **unique=True**
pytest: recomendaciones, paquetes básicos para testing en Python y Django · Septiembre 2017 · apsl.net · @avallbona

model-mommy (5)

custom fields

 1 import pytest
 2 from django.db import models
 3 from model_mommy import mommy
 4 from backoffice.models import Grupo
 5 
 6 def gen_func():
 7     return 'readability-counts'
 8 
 9 mommy.generators.add(models.SlugField, gen_func)
10 
11 @pytest.mark.django_db
12 def test_prova():
13     item = mommy.prepare(Grupo)
14     assert item.slug == 'readability-counts'

Notes

- podemos definir generadores de valores para un determinado tipo de campo

- comentar el caso de GeoPositionField
pytest: recomendaciones, paquetes básicos para testing en Python y Django · Septiembre 2017 · apsl.net · @avallbona

model-mommy (6)

recipes

 1 from model_mommy.recipe import Recipe, related
 2 from api.models import Sale, SaleItem
 3 
 4 
 5 sale_item1 = Recipe(SaleItem)
 6 sale_item2 = Recipe(SaleItem)
 7 sale_item3 = Recipe(SaleItem)
 8 
 9 sale = Recipe(
10     Sale,
11     items=related('sale_item1', 'sale_item2', 'sale_item3'),
12 )
13 
14 @pytest.mark.django_db
15 def test_sale_detail(client):
16 
17     sale = mommy.make_recipe('api.sale')
18     build = mommy.make(BuildRequest, sale=sale)
19 
20     url = reverse('transaction-detail', kwargs={'order': sale.order})
21     response = client.get(url)
22 
23     assert response.status_code == 200
24     assert response.data.get('order')
25 
26     items = response.data.get('items', [])
27     assert items
28     assert len(items) == sale.items.count()

Notes

- los **recipes** los podemos definir como plantillas que nos sirver para generar un conjunto de datos configurados en función de las necesidades que tengamos sin tener que especificar cada atributo en cada caso que queramos usarlo

- destacar que en la receta **sale** no es necesario definir un objeto de customer ya que lo hace por defecto **model-mommy**
pytest: recomendaciones, paquetes básicos para testing en Python y Django · Septiembre 2017 · apsl.net · @avallbona

model-mommy (7)

model-mommy vs factory boy

 1 class OwnerFactory(factory.django.DjangoModelFactory):
 2     class Meta:
 3         model = models.Owner
 4 
 5     first_name = 'bobby'
 6     last_name = 'D'
 7     # age will be somewhere between 25 and 42
 8     age = FuzzyInteger(25, 42)
 9     # email will use first_name and last_name (the default or the one you provide)
10     email = factory.LazyAttribute(lambda a: '{0}.{1}@example.com'.format(a.first_name, a.last_name).lower())
11 
12 class TeashopFactory(factory.django.DjangoModelFactory):
13     class Meta:
14         model = models.Teashop
15 
16     name = 'Tea-Bone'
17     # the first teashop will be 0 Downing street, the second 1 Downing Street etc
18     address = factory.Sequence(lambda n: '{0} Downing Street'.format(n))
19     owner = factory.SubFactory(OwnerFactory)
20 
21 OwnerFactory()                          # will save into database and return an instance of the model
22 OwnerFactory.create()                   # same as above
23 OwnerFactory.build()                    # will create the object but not save it in database, very cool for unit tests
24 
25 OwnerFactory(first_name='Malcom')       # override the default first name we defined in the factory
26 TeashopFactory()                        # will create both a teashop and an associated owner model and return the teashop
27 TeashopFactory(owner=my_owner_object)   # will use the Owner object provided instead of creating one

Notes

- manera de definir las factorias de objetos con Factory Boy

- más pensado para proyectos genéricos en python
pytest: recomendaciones, paquetes básicos para testing en Python y Django · Septiembre 2017 · apsl.net · @avallbona

model-mommy (8)

model-mommy vs factory boy

1 mommy.make(Owner) # identical to .create() in factory boy
2 mommy.make(Teashop) # will create and save the teashop and the owner
3 mommy.prepare(Owner) # create but not save in the database
4 
5 mommy.make(Owner, first_name='Malcolm')
6 mommy.make(Teashop, owner__first_name='Malcom') # AWESOME
7 mommy.make(Teashop, _quantity=7) # creates and return 7 teashop objects, everyone gets a teashop!

Notes

- manera de definir las factorias de objetos con mommy

- package específicamente para django
pytest: recomendaciones, paquetes básicos para testing en Python y Django · Septiembre 2017 · apsl.net · @avallbona

pytest-lazy-fixture

plugin que nos sirve para poder usar los fixtures en modo lazy, cosa que nos permite, por ejemplo, usar los fixtures como parámetros con el parametrize.

 1 @pytest.fixture()
 2 def owners_participations(self, owners, estates):
 3     """
 4     creates owners for each "inmueble"
 5     :param owners:
 6     :param estates:
 7     :return:
 8     """
 9     participaciones = [
10         {'inmueble': estates[0], 'propietario': owners[0], 'participacion': 100},
11         {'inmueble': estates[1], 'propietario': owners[1], 'participacion': 100},
12         {'inmueble': estates[2], 'propietario': owners[2], 'participacion': 100},
13         {'inmueble': estates[3], 'propietario': owners[3], 'participacion': 40},
14         {'inmueble': estates[3], 'propietario': owners[4], 'participacion': 60},
15         {'inmueble': estates[4], 'propietario': owners[5], 'participacion': 100},
16         {'inmueble': estates[5], 'propietario': owners[5], 'participacion': 100},
17         {'inmueble': estates[6], 'propietario': owners[6], 'participacion': 100},
18     ]
19     result = [mommy.make(ParticipacionPropietario, **participacion) for participacion in participaciones]
20     return result
21 
22 @pytest.fixture
23 def no_owners(self):
24     return []

Notes

- aquí tenemos dos fixtures, uno que representa la participación de los propietarios en una comunidad

- y otro que representa el caso "sin datos", sin propietarios (caso absurdo)
pytest: recomendaciones, paquetes básicos para testing en Python y Django · Septiembre 2017 · apsl.net · @avallbona

pytest-lazy-fixture (2)

 1 @pytest.fixture
 2 def remesa(self, community):
 3     remesa = mommy.make(
 4         Remesa, comunidad=community, fecha_cargo=datetime.now(),
 5         tipo='D', importe=10000, descripcion='remesa test',
 6         grupo_inmueble=community.get_default_group()
 7     )
 8     return remesa
 9 
10 @pytest.fixture(params=[
11     pytest.lazy_fixture('owners_participations'),
12     pytest.lazy_fixture('no_owners')
13 ])
14 def ownerset(self, request):
15     return request.param
16 
17 def test_generate_recibos(self, remesa, ownerset):
18     total_initial = remesa.importe
19     num_recibos = Recibo.generar_recibos(remesa)
20     assert num_recibos == len(ownerset)
21     calculated_total = Recibo.objects.aggregate(total=Sum('importe')).get('total') or 0.0
22     if len(ownerset) > 0:
23         assert float(total_initial) == float(calculated_total)
24     else:
25         assert float(calculated_total) == 0
26         assert float(total_initial) > 0

Notes

- aquí se crea otro fixture **ownerset** con los dos fixtures anteriores como parámetros para cada valor que nos devuelve **ownerset** se ejectuta un test gracias al **parametrize**

- a primera vista puede parecer poco útil, pero a medida que vayamos avanzando con el **testing** nos puede evitar bastante repetición de código
pytest: recomendaciones, paquetes básicos para testing en Python y Django · Septiembre 2017 · apsl.net · @avallbona

pytest-lazy-fixture (3)

 1 @pytest.mark.django_db
 2 class TestApi(object):
 3 
 4     @pytest.fixture()
 5     def mocked_build_response(self):
 6         mocked_response = {
 7             'build_operation_ref': '1234',
 8             'license_key': '5678',
 9             'package_file_url': 'https://www.google.com'
10         }
11         return mock.patch('api.models.Sale._request_build', return_value=mocked_response)
12 
13     @pytest.mark.parametrize('data,num_items,status', [
14         (pytest.lazy_fixture('one_product_order'), 1, 201),
15         (pytest.lazy_fixture('multi_product_order'), 3, 201),
16     ])
17     def test_new_order(self, client, mocked_build_response, data, num_items, status):
18         with mocked_build_response:
19             response = client.post(
20                 reverse('transaction-add'), data=json.dumps(data), content_type='application/json'
21             )
22             assert response.status_code == status
23 
24             assert Sale.objects.count() > 0
25             sale = Sale.objects.get(order=response.data.get('order'))
26 
27             assert sale
28             assert sale.items.count() == num_items
29 
30             assert sale.build.license_key
31             assert sale.build.package_file_url
32             assert sale.build.build_operation_ref

Notes

- ejemplo de parametrize de un test directamente con dos fixtures

- se usa en combinación con otros parámetros
pytest: recomendaciones, paquetes básicos para testing en Python y Django · Septiembre 2017 · apsl.net · @avallbona

hypothesis

plugin que nos sirve para generar datos random para nuestra especificación o modelo de datos

nos ayuda a testear que nuestro código funcione para cualquier valor dentro de un rango y no solo para casos concretos

 1 from hypothesis import given, settings, Verbosity
 2 from hypothesis.strategies import decimals, floats
 3 from mock import MagicMock
 4 ...
 5 @given(
 6     price=floats(min_value=1, max_value=50, allow_infinity=False, allow_nan=False),
 7     discount=floats(min_value=0.0, max_value=0.9, allow_infinity=False, allow_nan=False)
 8 )
 9 @settings(perform_health_check=False, max_examples=25, verbosity=Verbosity.verbose)
10 def test_cdai(self, price, discount):
11     order_item = self.order_item(price=price, discount=Decimal(discount))
12     order = order_item.order
13     order.coupon.is_instructor_coupon = MagicMock(return_value=True)
14 
15     CouponUsage.objects.create = MagicMock(return_value=None)
16     discount_amount = order.calculate_discount_amount()
17     assert discount_amount == order.discount_amount

Notes

- aquí tenemos un ejemplo de test que queremos que los parámetros **price** y **discount** tomen valores en diferentes rangos de valores

- price: entre 5 y 50

- discount: entre 0.1 y 0.9

- si no se acotan los valores puede probar valores tales como Infiniy, NaN

- fijamos el máximo de ejemplos a 25, si no se fija el maximo, por defecto ejecuta unos 200 ejemplos. En fase desarrollo mejor reducir el numero de ejemplos y en fase despliegue dejarlo por defecto.

- aunque se prueben muchos ejemplos se condisera un solo test

- este package puede hacer muchas cosas más, pero la filosofía que sigue es: describe los parámetros, describe el resultado, que el ordenador pruebe que mi código falla, filosofía **Property-based testing**
pytest: recomendaciones, paquetes básicos para testing en Python y Django · Septiembre 2017 · apsl.net · @avallbona

hypothesis (2)

resultado

pytest-hypothesis

Notes

- en la imagen vemos los valores que val tomando los parámetros

- también vemos las 25 veces que han tomado valor
pytest: recomendaciones, paquetes básicos para testing en Python y Django · Septiembre 2017 · apsl.net · @avallbona

pytest-mock

plugin que nos sirve para hacer patching de métodos o funciones que usamos para testear nuestra lógica. Nos proporciona un fixture mocker, que nos permite hacer patch de una determinada función. E.g. testear llamadas a servicios externos, como por ejemplo a una API externa.

 1 # -*- encoding: utf-8 -*-
 2 
 3 import pytest
 4 from io import StringIO
 5 from django.core.management import call_command
 6 
 7 @pytest.mark.django_db()
 8 class TestCustomCommands(object):
 9 
10     def test_sync_products(self, supplier_activities_rates, mocker):
11         mocker.patch(
12             'backoffice.management.commands.sync_products.Command.get_api_data',
13             return_value=supplier_activities_rates
14         )
15         out = StringIO()
16         err = StringIO()
17         call_command('sync_products', action='dry-run', stdout=out, stderr=err)
18         assert err.getvalue() == ''
19         assert 'END PROCESS' in out.getvalue()

Notes

- con esta técnica no accedemos directamente al servicio externo y retornamos un "ejemplo" de respuesta

- el ejemplo de respuesta, en este caso, nos lo da el fixture: **supplier_activities_rates**

- evitamos estresar el servicio y creando un montón de datos de test aunque sea en un entorno de prueba, e.g.: stripe
pytest: recomendaciones, paquetes básicos para testing en Python y Django · Septiembre 2017 · apsl.net · @avallbona

pytest-mock (2)

detalle del método que mockeamos, get_api_data

1 def get_api_data(self):
2     self.stdout.write('Accessing API data')
3     return requests.post(self.API_URL).json()

ejemplo del fixture

 1 @pytest.fixture()
 2 def supplier_activities_rates():
 3     data = """{
 4         "id": "503618",
 5         "name": "This is a title sample",
 6         "isActive": true,
 7         "type": "ORGANIZED",
 8         "rates": {
 9             "rate": [
10                 {
11                     "ActivityDateFrom": "1/6/2017",
12                     "ActivityDateTo": "31/10/2017",
13                     "id": "4879",
14                     "name": "This is a sample name",
15                     "isActive": true
16                 }
17             ]
18         }
19     }"""
20     return json.loads(data)

Notes

- llamamos al método get_api_data, el cual es el que accede en realidad al servio externo
- se "mockea" la respuesta
pytest: recomendaciones, paquetes básicos para testing en Python y Django · Septiembre 2017 · apsl.net · @avallbona

pytest-checkipdb

plugin que nos sirve para verificar que no nos dejamos ningún breakpoint insertado en nuestro código

1 def render_to_message(self, *args, **kwargs):
2     msg = super().render_to_message(*args, **kwargs)
3     if self.adj:
4         import ipdb; ipdb.set_trace()
5         msg.attach(filename=self.adj.name, content=self.adj.read(), mimetype=self.adj.content_type)
6     return msg

pytest-checkipdb

Notes

- analiza el árbol sintáctico del código

- visita cada llamada a una función i verifica que no sean del tipo pdb o ipdb

- plugin desarrollado en APSL
pytest: recomendaciones, paquetes básicos para testing en Python y Django · Septiembre 2017 · apsl.net · @avallbona

pytest-cov

plugin que nos sirve para detectar qué parte de nuestro código no está aún "cubierta" por los tests

nos permite generar informes para ver fácilmente las partes no testeadas

Notes

- cuanto más alto el porcentaje mejor

- es el porcentaje que solemos ver insertado como icono en la parte superior de los repos de github
pytest: recomendaciones, paquetes básicos para testing en Python y Django · Septiembre 2017 · apsl.net · @avallbona

pytest-cov (2)

pequeño ejemplo de caso sin testear

Notes

- en el ejemplo nos faltaria implementar el test de reactivación de una subscripción que ya está activa, que no está cancelada
pytest: recomendaciones, paquetes básicos para testing en Python y Django · Septiembre 2017 · apsl.net · @avallbona

pytest-flake8

plugin que nos sirve para asegurar que nuestro código sigue una guía de estilo, por ejemplo, en el caso de python, el PEP8