Python Sudeste 2018


Além do MVC: Testando Unitariamente Apps Django



Bernardo Fontes


São Paulo / SP

30 de Março de 2018

Olar!

twitter.com/bbfontes

github.com/berinhard

berinfontes.com

bernardoxhc@gmail.com

berinhard.github.io/talks/

## 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)
## Testando os Models
## 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 as **Views**

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 os Templates
## 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)) ```
## Testando os Templates
## 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

Obrigado!

twitter.com/bbfontes

github.com/berinhard

berinfontes.com

bernardoxhc@gmail.com