ThoughtWorks
Testando Aplicações Django Unitariamente
Bernardo Fontes
Recife/PE
15 de Fevereiro de 2016
I <3
## DISCLAIMER
- ### Esta é só a minha verdade
- ### Não estou propondo embates religiosos
- ### Testem
- ### Testem confortavelmente
## Problemas de MVC
- ### Uma decisão estritamente **arquitetural**
- ### O nível de **abstração é alto**
- ### **Esconde** muitas outras camadas do framework
- ### Não implica **pensar o design** da sua aplicação
- ### Alta chance de **misturar responsabilidades**
- ### Resultado: **legados bebês**
Minimizando os Problemas
Object Mentor (SOLID)
## **Single Responsibility Principle**
- ### Nunca deve exstir mais de **uma razão para modificar** algo em uma classe.
## Single Responsibility Principle **para testes**
- ### Nunca deve exstir mais de uma razão para **TESTAR** algo em um objeto.
## Responsabilidades por Camada
- ### **Models**: abstração para armazenar e consultar dados
- ### **Views**: garantir fluxo HTTP de Request-Response para uma rota após executar o comportamento desejado
- ### **Templates**: apresendar dados em algum formato (HTML em geral)
## Nossa TODO List
```python
from django.db import models
class Todo(models.Model):
HIGH_PRIORITY, LOW_PRIORITY = 2, 1
PRIORITY_CHOICES = [(HIGH_PRIORITY, 'High'), (LOW_PRIORITY, 'Low')]
content = models.CharField(max_length=100)
is_done = models.BooleanField(default=False)
owner = models.ForeignKey('auth.User')
priority = models.IntegerField(choices=PRIORITY_CHOICES, default=1)
```
Melhore seu vocabulário
## Domínio no Banco VS Domínio Desacoplado
- ### Nosso domínio viverá no model do Django (domínio no banco)
## Responsabilidades por Camada
### **Models**: abstração para armazenar e consultar dados **com semântica de domínio**
- ### Testes de models **precisam** bater no banco
- ### Alguém pensou em fixtures?
## Model Mommy FTW!
### pip install model_mommy
Model-mommy offers you a smart way to **create fixtures** for testing in Django. With a simple and powerful API you can create many objects with a **single line of code**.
## Testando o **Modelo** de Dados
- ### Defina uma API que represente seu domínio e **encapsule** seus dados
- ### Teste como essa API manipula os dados
- ### Toda a aplicação só deve falar com o seu domínio **através dessa API**
- ### Use dados mais próximos do mundo real
```python
from model_mommy import mommy
from django.tests import TestCase
from app.models import Todo
class TodoModelTests(TestCase):
def test_mark_as_high_priority(self):
todo = mommy.prepare(Todo, priority=Todo.LOW_PRIORITY)
todo.mark_as_high_priority() # share messages, not objects
self.assertEqual(Todo.HIGH_PRIORITY, todo.priority) # testamos dados
def test_mark_as_low_priority(self):
todo = mommy.prepare(Todo, priority=Todo.HIGH_PRIORITY)
todo.mark_as_low_priority()
self.assertEqual(Todo.LOW_PRIORITY, todo.priority)
```
## Testando a **Manipulação** de Dados
- ### Pare de pensar em **query** e pense em **intenções**
- ### O que significa a consulta ```Todo.objects.filter(priority=1)```?
- ### Gere semântica de domínio através de **Model Managers** e **QuerySets** customizados
```python
from model_mommy import mommy
from django.tests import TestCase
from app.models import Todo
class TodoModelManagerTests(TestCase):
def test_filter_high_priority(self):
expected = mommy.make(Todo, priority=Todo.HIGH_PRIORITY, _quantity=2)
mommy.make(Todo, priority=Todo.LOW_PRIORITY, _quantity=3)
qs = Todo.objects.high_priority()
self.assertQuerysetEqual(qs, expected)
def test_filter_incomplete(self):
expected = mommy.make(Todo, is_done=False, _quantity=3)
mommy.make(Todo, is_done=True, _quantity=2)
qs = Todo.objects.incomplete()
self.assertQuerysetEqual(qs, expected)
def test_filter_chained_incomplete_and_high_priority(self):
expected = mommy.make(
Todo, is_done=False, priority=Todo.HIGH_PRIORITY, _quantity=3
)
mommy.make(Todo, is_done=True, priority=Todo.HIGH_PRIORITY)
mommy.make(Todo, is_done=True, _quantity=2)
qs = Todo.objects.incomplete().high_priority()
self.assertQuerysetEqual(qs, expected)
```
Testando os Buracos Negros
## Testar Views é **chato**!
- ### Tenho que configurar o banco
- ### Tenho que logar o usuário
- ### Tenho que configurar permissões
- ### Tenho que checar vários fluxos de sucesso
- ### Tenho que possíveis fluxos de falha
## Se é **chato/difícil de testar**, isso pode ser um grande **bad smell** sobre seu código...
## Responsabilidades por Camada
- ### Views: garantir **fluxo HTTP** de Request-Response para uma rota após **executar o comportamento desejado**
## **fluxo HTTP**
- ### 404 para página não encontrada
- ### 200 para sucesso
- ### 302 para um redirect e por aí vai...
## **executar o comportamento desejado**
### O calcanhar de Aquiles de toda aplicação Web e do MVC
- ### Só pensar em MVC é **pouco**
### Um exemplo de 2010
```python
def index(request):
today = datetime.today().date()
hangovers = str(len(Hangover.objects.filter(day=today)))
if request.method == 'GET':
hangovers = '0' * (4 - len(hangovers)) + hangovers
context = RequestContext(request, {'hangovers':hangovers})
return render_to_response('index.html', context)
elif request.method == 'POST':
msg = 'Eu sou a %sª pessoa #deressaca hoje! http://deressaca.net' % hangovers
twitter_message = urllib.urlencode({'status': msg})
twitter_url = 'http://twitter.com/home?%s' % twitter_message
if 'new_hangover' in request.POST:
Hangover.objects.create()
return HttpResponseRedirect(twitter_url)
else:
return HttpResponseRedirect(reverse('counter'))
```
## Problemas para testar dessa view
- Formatação de String no contexto
- Fluxo de Get Vs Fluxo de Post
- Formatação da mensagem do Tweet
- Redirecionamento pro twitter com mensagem
- Criação de objeto Hangover
- Não criação de objeto Hangover
- Redirect para reverse de counter
## Responsabilidades **Reais** da View
- Formatação de String no contexto (discutível)
- Fluxo de Get Vs Fluxo de Post
- ~~Formatação da mensagem do Tweet~~
- ~~Redirecionamento pro twitter com mensagem~~
- ~~Criação de objeto Hangover~~
- ~~Não criação de objeto Hangover~~
- Redirect para alguma url (reverse de counter ou do twitter)
- ### **Quem** faz o resto então?
Habemus Use Cases
##Use Cases
*"The software in this layer contains **application specific business rules**. It **encapsulates and implements** all of the use cases of the system. These use cases **orchestrate the flow of data** to and from the entities, and direct those entities to use their enterprise wide business rules to achieve the goals of the use case."*
- Uncle Bob
## Responsabilidades por Camada
- ### Views: garantir **fluxo HTTP** de Request-Response para uma rota após **executar um CASO DE USO**
- ### Como testar caso de uso? **Mockando** sua chamada!
- ### Garantimos o funcionamento testando **unitariamente** o caso de uso
## Mock
- ### **Simulam** funcionamento de objetos
- ### Respeitam **API** dos objetos
- ### Viabilizam maneira de fazer **asserções**
### Testes
```python
class HangoverIndexTests(TestCase):
url = reverse_lazy('index')
def test_show_counter_on_context(self):
mommy.make(Hangover, day=date.today(), _quantity=4)
response = self.client.get(self.url)
self.assertEqual('0004', response.context['hangovers'])
def test_uses_correct_template_on_get(self):
response = self.client.get(self.url)
self.assertTemplateUsed(response, 'index.html')
@patch.object(SendTwitterHangoverMessageUseCase, 'execute')
def test_redirects_to_use_case_url(self, mocked_execute):
mocked_execute.return_value = reverse('counter')
data = {'new_hangover': True}
response = self.client.post(self.url, data)
mocked_execute.assert_called_once_with(create_hangover=True)
self.assertRedirects(response, reverse('counter'))
```
### Possível implementação
```python
def index(request):
today = datetime.today().date()
hangovers = str(len(Hangover.objects.filter(day=today)))
if request.method == 'GET':
hangovers = '0' * (4 - len(hangovers)) + hangovers
context = RequestContext(request, {'hangovers':hangovers})
return render_to_response('index.html', context)
elif request.method == 'POST':
use_case = SendTwitterHangoverMessageUseCase()
create_hangover = 'new_hangover' in request.POST
redirect_url = use_case.execute(create_hangover=create_hangover)
return HttpResponseRedirect(redirect_url)
```
### Possível implementação
```python
class SendTwitterHangoverMessageUseCase(object):
def execute(self, create_hangover):
msg = 'Eu sou a %sª pessoa #deressaca hoje! http://deressaca.net' % hangovers
twitter_message = urllib.urlencode({'status': msg})
twitter_url = 'http://twitter.com/home?%s' % twitter_message
redirect_url = reverse('counter')
if create_hangover:
Hangover.objects.create()
redirect_url = twitter_url
return redirect_url
```
### Dicas extras para testar Views
- ### Crie **asserções customizadas** para Views
- ### Crie métodos auxiliares para **gerência de usuários**
- ### **Nunca** mockar o comportamento dos Forms (bad smell)
- ### Utlize mommy recipes =)
### Asserções customizadas para Views
```python
def assertLoginRequired(self, url):
response = self.client.get(url)
redirect_url = settings.LOGIN_URL + '?next=' + url
self.assertRedirects(response, redirect_url)
def assertContextItem(self, response, key, value):
self.assertIn(
key, response.context, msg='Key "%s" not found in context' % key
)
context_obj = response.context[key]
self.assertEqual(value, context_obj)
def assertFormInContext(self, response, key, form_class):
self.assertIn(
key, response.context, msg='Key "%s" not found in context' % key
)
form = response.context[key]
self.assertIsInstance(form, form_class)
def assertStatusCode(self, status_code, response):
msg = "Response wasn't %d: %d" % (status_code, response.status_code)
self.assertEqual(status_code, response.status_code, msg=msg)
def assert404(self, response):
self.assertStatusCode(404, response)
def assert200(self, response):
self.assertStatusCode(200, response)
```
## Testando a Área Cinza: **Forms**
## Responsabilidades pros Forms
- ### Validação, Sanitização e Formatação
- ### Teste o que for dos Forms **unitariamente**! Lembre-se: testar pela view é chato
- ### ModelForms **nem sempre** são uma boa idéia...
- ### Garanta um **output de dados** desacoplado da API do Django
- ### Forms também são bons canditados de **asserções customizadas**
```python
class TodoListModelFormTests(TestCase):
def setUp(self):
self.data = {'content': 'content', 'owner': 42}
self.user = mommy.make(User, id=42)
def test_required_fields(self):
required_fields = ['content', 'owner']
form = TodoListForm({})
form.is_valid()
self.assertEqual(len(required_fields), len(form.errors))
for field in required_fields:
self.assertIn(field, form.errors)
def test_cleans_content_to_upper_case(self):
form = TodoListForm(self.data)
self.assertTrue(form.is_valid())
self.assertEqual('CONTENT', form.cleaned_data['content'])
def test_get_form_result_data(self):
form = TodoListForm(self.data)
self.assertTrue(form.is_valid())
data = form.get_result_data()
self.assertEqual('CONTENT', data['content'])
self.assertEqual(self.user, data['owner'])
def test_priority_choices_range(self):
priority_choices = [(1, 'High'), (2, 'Low')]
form = TodoListForm()
self.assertEqual(priority_choices, list(form.fields['priority'].choices))
```
Asserções Customizadas para Forms
```python
def assertRequiredFields(self, required_fields, form_class):
form = form_class({})
self.assertTrue(form.is_valid(), msg='Form does not has required fields')
self.assertEqual(len(required_fields), len(form.errors))
for field in required_fields:
self.assertIn(field, form.errors, msg='Field {} is required'.format(field))
def assertChoiceFieldOptions(self, expected_choices, form, field_name):
expected_choices = list(expected_choices)
self.assertEqual(expected_choices, list(form.fields[field_name].choices))
```
## Responsabilidades dos Templates
- ### **Renderizar variáveis** corretamente
- ### Funcionamento de **Template Filters**
- ### Funcionamento de **Template Tags**
### Renderizar variáveis corretamente
```python
# settings.py
TEMPLATE_STRING_IF_INVALID = 'XXXXX-INVALID_CONTEXT_VAR-XXXXXX'
# sobreescrita de assertTemplateUsed
def assertTemplateUsed(self, response, template_name):
super(TestCase, self).assertTemplateUsed(response, template_name)
self.longMessage = False
self.assertIn('MEDIA_URL', response.context)
self.assertEqual(response.context['MEDIA_URL'], settings.MEDIA_URL)
self.assertIn('STATIC_URL', response.context)
self.assertEqual(response.context['STATIC_URL'], settings.STATIC_URL)
self.assertNotIn(settings.TEMPLATE_STRING_IF_INVALID, response.content, self.__clean_template_output(response))
```
### Template Tags e Filters
- ### Validar o **funcionamento**
- ### Validar que **foi registrada**
- ### **Template Library** pode ser usada para ambos
```python
def test_my_crazy_upper(self):
content = '{% load app_tags %}{{ "bernardo"|crazy_upper }}'
template = Template(content)
output = template.render({})
self.assertEqual("BeRnArDo", output)
```
## Isso é só o começo...
- ### Middlewares
- ### Caching
- ### Emails
- ### Controle de permissões
- ### APIs Rest e por aí vai
## Resumindo
- Models, Managers e QuerySet com **linguagem de domínio encapsulando detalhes** de implentação e persistência
- Views só são responsáveis por **fluxo HTTP** e **disparo de execução**
- MVC define sua **arquitetura**, não seu design
- Use Cases podem ser seus amigos
- Teste Forms **unitariamente** para facilitar testes em camadas mais altas (views, por exemplo)
- Utilize a **linguagem de Template do Django** como amiga para testar Templates