Testing de modelos en Django, buenas prácticas.

El testing es una de las partes más importantes de cualquier proyecto de software, ya que aporta calidad y seguridad a nuestro código.  En esta entrada voy a tratar de exponer algunas prácticas muy recomendables para testear modelos en Django de forma eficiente y segura.

Evita los mocks

Por norma general, en la gran mayoría de los errores relacionados con los modelos está involucrada la base de datos y suelen ocurrir por:

  • Migraciones obsoletas.
  • Tipos de datos erróneos.
  • Restricciones de referencia / integridad.
  • Errores en las consultas de recuperación de datos.

Dichos errores no se reproducirán si estamos “mockeando” los datos, por lo tanto el mocking en el testing de modelos deberíamos tratar de minimizarlo, ya que las pruebas nos proporcionarían una falsa sensación de seguridad, dejando que las cuestiones anteriores se pasen por alto.

Esto no implica que nunca se deban mockear los objetos de los tests de modelos,  simplemente debemos ser cuidadosos a la hora de utilizarlos.

No testees framework

Los modelos son simplemente una colección de campos que dependen de la funcionalidad estándar de Django. Dicha funcionalidad ya está más que testada, así que no seas redundante.

Utilizaremos como ejemplo el modelo User que utilizamos en este post:

from __future__ import unicode_literals

from django.db import models
from django.contrib.auth.models import PermissionsMixin
from django.contrib.auth.base_user import AbstractBaseUser
from django.utils.translation import ugettext_lazy as _

from .managers import UserManager

class User(AbstractBaseUser, PermissionsMixin):
    email = models.EmailField(_('email address'), unique=True)
    first_name = models.CharField(_('first name'), max_length=30, blank=True)
    last_name = models.CharField(_('last name'), max_length=30, blank=True)
    date_joined = models.DateTimeField(_('date joined'), auto_now_add=True)
    is_active = models.BooleanField(_('active'), default=True)
    avatar = models.ImageField(upload_to='avatars/', null=True, blank=True)

    objects = UserManager()

    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = []

    class Meta:
        verbose_name = _('user')
        verbose_name_plural = _('users')

    def get_full_name(self):
        '''
        Returns the first_name plus the last_name, with a space in between.
        '''
        full_name = '%s %s' % (self.first_name, self.last_name)
        return full_name.strip()

    def get_short_name(self):
        '''
        Returns the short name for the user.
        '''
        return self.first_name

En muchos tutoriales nos encontraríamos con pruebas como esta:

;from django.test import TestCase
from .models import User


class UserModelTest(TestCase):

    def test_user_creation(self):
        User(email = "prueba@prueba.com", name='prueba user').save()

        users = User.objects.all()
        self.assertEquals(users.count(), 1)

        user_from_db = users_in_db[0]
        self.assertEquals(user.email, "prueba@prueba.com")
        self.assertEquals(user.name, "prueba user")

Felicidades, ya has escrito tu primer test de modelos!! (dirían)

Lo único que hemos hecho es comprobar que el ORM de Django puede almacenar un modelo correctamente. Pero, ¿qué sentido tiene realizar este tipo de pruebas? Pues la verdad es que no demasiado, no necesitamos probar la funcionalidad inherente al framework.

Prueba tu funcionalidad

En lugar de gastar tiempo en crear pruebas inútiles que realmente no son necesarias, tratar de seguir esta regla: Prueba sólo la funcionalidad personalizada que creaste en tu modelo.

Prueba tu funcionalidad, no las inherentes al framework. Click Para Twittear

En el modelo User utilizado de ejemplo, no tenemos demasiadas funcionalidades personalizadas. Se me ocurre que podríamos probar que nuestro modelo utiliza una dirección de correo electrónico para el USERNAME_FIELD sólo para asegurarnos de que otro desarrollador no lo cambia.

También podríamos añadir una función get_by_id(uid) que reemplace las llamadas a User.objects.get(pk), veamos como quedaría el modelo:

from __future__ import unicode_literals
from django.db import models
from django.contrib.auth.models import PermissionsMixin
from django.contrib.auth.base_user import AbstractBaseUser
from django.utils.translation import ugettext_lazy as _

from .managers import UserManager

class User(AbstractBaseUser, PermissionsMixin):
    email = models.EmailField(_('email address'), unique=True)
    first_name = models.CharField(_('first name'), max_length=30, blank=True)
    last_name = models.CharField(_('last name'), max_length=30, blank=True)
    date_joined = models.DateTimeField(_('date joined'), auto_now_add=True)
    is_active = models.BooleanField(_('active'), default=True)
    avatar = models.ImageField(upload_to='avatars/', null=True, blank=True)

    objects = UserManager()

    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = []

    class Meta:
        verbose_name = _('user')
        verbose_name_plural = _('users')

    def get_full_name(self):
        '''
        Returns the first_name plus the last_name, with a space in between.
        '''
        full_name = '%s %s' % (self.first_name, self.last_name)
        return full_name.strip()

    def get_short_name(self):
        '''
        Returns the short name for the user.
        '''
        return self.first_name

    @classmethod
    def get_by_id(cls, uid):
        return User.objects.get(pk=uid)

    def __unicode__(self):
        return self.email

Nótese que hemos utilizado el decorador @classmethod para definir el método get_by_id(). Hemos creado un método de clase en vez de instancia porque ya que este no tiene estado y de esta manera lo podemos llamar simplemente escribiendo User.get_by_id.

Veamos como queda el test del modelo:

from django.test import TestCase
from accounts.models import User

class UserModelTest(TestCase):
    
    @classmethod
    def setUpClass(cls):
        cls.test_user = User(email="prueba@prueba.com", name='test user')
        cls.test_user.save()

    def test_user_to_string_email(self):
        self.assertEquals(__unicode__(self.test_user), "prueba@prueba.com")

    def test_get_by_id(self):
        self.assertEquals(User.get_by_id(1), self.test_user)

Hay que prestar atención al método setUpClass(), ya que todas nuestras pruebas están compartiendo el mismo objeto User, si en alguno de los test modificamos dicho objeto, podríamos provocar que las otras pruebas fallen de forma inesperada. Esto nos puede conducir a sesiones de depuración absurdas ya que los tests fallarían sin razón aparente.

Una estrategia más segura (y más lenta), sería crear el objeto en método setup(), el cual se ejecuta antes de cada test, y luego destruir dicho objeto al final de la ejecución del mismo usando para ello el método tearDown().

Nuestro test quedaría tal que así:

from django.test import TestCase
from accounts.models import User

class UserModelTest(TestCase):
    
    def setUp(self):
        self.test_user = User(email="prueba@prueba.com", name='test user')
        self.test_user.save()

    def test_user_to_string_email(self):
        self.assertEquals(str(self.test_user), "prueba@prueba.com")

    def test_get_by_id(self):
        self.assertEquals(User.get_by_id(1), self.test_user)

    def tearDown(self):
        self.test_user.delete()

Uso de Fixtures

Django nos proporciona una funcionalidad integrada para cargar automáticamente y rellenar los datos del modelo: las denominadas fixtures de Django. No soy demasiado fan de este enfoque, ya que generamos nuevos ficheros en los que buscar a la hora de depurar errores, pero he de reconocer que en un momento dado pueden resultar útil.

Una fixture (accesorio) es una colección de datos en formato XML, YAML o JSON, que Django se encarga de importar a la base de datos, tanto para generar datos por defecto para nuestro proyecto o para nuestro entorno de pruebas.

A continuación un ejemplo de una fixture en formato JSON:

[
  {
    "model": "accounts.user",
    "pk": 1,
    "fields": {
      "email":"john@lennon.com",
      "first_name": "John",
      "last_name": "Lennon"
    }
  },
  {
    "model": "accounts.user",
    "pk": 2,
    "fields": {
      "email":"paul@mccartney.com",
      "first_name": "Paul",
      "last_name": "McCartney"
    }
  }
]

Por defecto Django busca las fixtures en el directorio app_name/fixtures, también podemos definir un directorio personalizado en nuestro fichero de configuraciónsettings.FIXTURE_DIRS

Veamos como referenciarlas en nuestro fichero de pruebas:

from django.test import TestCase
from accounts.models import User

class UserModelTest(TestCase):
    fixtures = ['users_fixture.json', ]

...
    

Indicar la extensión del fichero es opcional, debemos incluirla si queremos que Django busque sólo ficheros de un tipo en concreto.

Apps de terceros

Si te apetece seguir engordando el requirements.txt de tu proyecto puedes darle una oportunidad la app Model Mommy es una alternativa que nos ofrece la comunidad a las fixtures de Django. Nos ofrece una API simple que nos permite crear varios objetos en pocas lineas de código.

Veamos un ejemplo:

from django.test import TestCase
from model_mommy import mommy
from model_mommy.recipe import Recipe, foreign_key

from accounts.models import User

class UserModelTest(TestCase):
    def setUp(self):
        self.test_user = mommy.make(User)
  
    def test_user_creation_mommy(self):
        self.assertTrue(isinstance(test_user, User))
        self.assertEqual(test_user.__unicode__(), test_user.email)
    

 

Conclusiones

Como he dicho al principio, el Testing es una práctica en la cual todo desarrollador debe conocer los conceptos básicos y aplicarlos.

El principal problema del testing es lo sobrevalorado que en algunos casos puede llegar a estar, véase el mito del 100% de cobertura, este artículo de Martin Fowler trata sobre ello. Es una locura pretender tener testado el 100% de nuestro código, sería una pérdida de tiempo total y absoluta de nuestro preciado tiempo, como hemos visto hay muchas partes del proyecto que no merece la pena testar. 

Referencias

Documentación oficial
Django Model Mommy
Test Driven Development with Django

Testing de modelos en Django, buenas prácticas.
5 (100%) 9 votos
1 comentario

Dejar un comentario

¿Quieres unirte a la conversación?
Siéntete libre de contribuir

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *