Python Sudeste 2018
Além do MVC: Testando Unitariamente Apps Django
Bernardo Fontes
São Paulo / SP
30 de Março de 2018
## DISCLAIMER
- ### Esta é só a minha verdade
- ### Testem
- ### Testem confortavelmente
Arquitetura MVC
## 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
class Todo(models.Model):
HIGH_PRIORITY, LOW_PRIORITY = 2, 1
PRIORITY_CHOICES = [(HIGH_PRIORITY, 'High'), (LOW_PRIORITY, 'Low')]
owner = models.ForeignKey(settings.AUTH_USER_MODEL)
content = models.CharField(max_length=100)
is_done = models.BooleanField(default=False)
priority = models.IntegerField(choices=PRIORITY_CHOICES, default=LOW_PRIORITY)
```
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ó operar sobre o banco **através dessa API**
```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() # trocamos mensagens
asser 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()
asser Todo.LOW_PRIORITY == todo.priority # testamos dados
```
## 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 lidar com os 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**
- ### Request GET/POST...
- ### 404 para página não encontrada
- ### 200 para sucesso com conteúdo
- ### 302 para um redirect e por aí vai...
executar o comportamento desejado
### Um exemplo antigo e meu
```python
def index(request):
today = date().today()
hangovers = Hangover.objects.filter(day=today).count()
if request.method == 'GET':
context = RequestContext(request, {'hangovers': hangovers})
return render_to_response('index.html', context)
elif request.method == 'POST':
msg = 'Eu sou a {}ª pessoa #deressaca hoje! http://deressaca.net'.format(hangovers)
twitter_message = urllib.urlencode({'status': msg})
twitter_url = 'http://twitter.com/home?{}'.format(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
- Requisições Get + Requisições 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
- 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(4, 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 = date.today()
hangovers = Hangover.objects.filter(day=today)
if request.method == 'GET':
context = RequestContext(request, {'hangovers': hangovers})
return render_to_response('index.html', context)
elif request.method == 'POST':
if 'new_hangover' not in request.POST:
return reverse('counter')
redirect_url = use_case.execute()
return HttpResponseRedirect(redirect_url)
```
### Possível implementação
```python
class SendTwitterHangoverMessageUseCase():
def execute(self):
today = date.today()
Hangover.objects.create()
hangovers = Hangover.objects.filter(day=today).count()
msg = 'Eu sou a {}ª pessoa #deressaca hoje! http://deressaca.net'.format(hangovers)
twitter_message = urllib.urlencode({'status': msg})
twitter_url = 'http://twitter.com/home?{}'.format(twitter_message)
return twitter_url
```
Definitivamente leia mais aqui:
### 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 padrão do Forms (bad smell)
- ### Utlize mommy recipes =)
## Testando a Área Cinza: **Forms/Serializers**
## Responsabilidades
- ### 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() # redução de acoplamento de objeto
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, *args, **kwargs):
form = form_class(*args, **kwargs)
form.is_valid()
self.assertEqual(len(required_fields), len(form.errors))
for field in required_fields:
self.assertIn(field, form.errors)
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.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