Software Crafters ® | Creado con 🖤 para elevar el nivel de la conversación sobre programación en español| Legal
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.
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:
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.
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 = "[email protected]", name='prueba user').save() users = User.objects.all() self.assertEquals(users.count(), 1) user_from_db = users_in_db[0] self.assertEquals(user.email, "[email protected]") 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.
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.
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="[email protected]", name='test user') cls.test_user.save() def test_user_to_string_email(self): self.assertEquals(__unicode__(self.test_user), "[email protected]") 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="[email protected]", name='test user') self.test_user.save() def test_user_to_string_email(self): self.assertEquals(str(self.test_user), "[email protected]") def test_get_by_id(self): self.assertEquals(User.get_by_id(1), self.test_user) def tearDown(self): self.test_user.delete()
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":"[email protected]", "first_name": "John", "last_name": "Lennon" } }, { "model": "accounts.user", "pk": 2, "fields": { "email":"[email protected]", "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.
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)
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.
Si te ha gustado el artículo, valora y comparte en tus redes sociales. No dudes en comentar dudas, aportes o sugerencias, estaré encantado de responder.
Este artículo se distribuye bajo una Licencia Creative Commons Reconocimiento-CompartirIgual 4.0 Internacional (CC BY-SA 4.0)