ThoughtWorks


Testando Aplicações Django Unitariamente



Bernardo Fontes


Recife/PE

15 de Fevereiro de 2016

Olar!

twitter.com/bbfontes

github.com/berinhard

garimpo.fm

pessoas.cc

Medium

bernardoxhc@gmail.com

berinhard.github.io/talks/


slideshare.net/bernardofontes


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

Obrigado!

twitter.com/bbfontes

github.com/berinhard

garimpo.fm

pessoas.cc

Medium

bernardoxhc@gmail.com