Prof. André Hora
Objetivo: Reproduzir uma sessão de desenvolvimento de software usando TDD (Test-Driven Development), com base no livro Test-Driven-Development with Python. Especificamente, iremos reproduzir os exemplos do Cap. 6 e 7 do livro. Faça o exercício com o mindset de TDD. Se necessário, estude antes o material visto em sala de aula.
Instruções:
- Siga o roteiro, passo a passo.
- Após cada passo, rode os testes.
- Nos passos marcados com COMMIT & PUSH, faça um commit e um push no seu repositório. Isso será usado no momento da correção, para garantir que seguiu a sequência do roteiro, passo-a-passo.
Terminamos a última aula prática com os Testes Funcionais e de Unidade passando com sucesso. No entanto, existe um comportamento errado da aplicação com relação aos Testes Funcionais. Isto é, cada vez que rodamos os Testes Funcionais, estamos deixando dados no banco de dados, o que pode intereferir na próxima vez rodarmos os testes. Devemos garantir isolamento entre os Testes Funcionais.
Veja que esse problema não ocorre com os Testes de Unidade, pois o framework Django gerencia automaticamente um banco de dados de testes (separado do BD real) para os Testes de Unidade. No entanto, nossos Testes Funcionais rodam diretamente no BD real, db.sqlite3.
Uma solução simples seria limpar o BD através dos métodos setUp
e tearDown
.
No entanto, existe uma classe chamada LiveServerTestCase
que gerencia essa tarefa.
Tal classe cria automaticamente um BD de teste (como nos Testes de Unidade) e inicia um servidor de desenvolvimento para rodar os Testes Funcionais.
Vamos organizar os Testes Funcionais em uma pasta. Para isso crie uma pasta functional_tests com um arquivo chamado __init__.py (para ser um pacote válido em Python):
$ mkdir functional_tests
$ touch functional_tests/__init__.py
Mova o Teste Funcional functional_tests.py para a pasta functional_tests e renomeie o arquivo para tests.py. Use seguinte comando para notificar o Git:
$ git mv functional_tests.py functional_tests/tests.py
Devemos ter a estrutura:
.
├── db.sqlite3
├── functional_tests
│ ├── __init__.py
│ └── tests.py
├── lists
│ ├── admin.py
│ ├── apps.py
│ ├── __init__.py
│ ├── migrations
│ │ ├── 0001_initial.py
│ │ ├── 0002_item_text.py
│ │ ├── __init__.py
│ │ └── __pycache__
│ ├── models.py
│ ├── __pycache__
│ ├── templates
│ │ └── home.html
│ ├── tests.py
│ └── views.py
├── manage.py
├── superlists
│ ├── __init__.py
│ ├── __pycache__
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
└── virtualenv
├── [...]
A partir de agora, nosso Teste Funcional é functional_tests/tests.py.
Agora, rodamos o Teste Funcional através do comando python manage.py test functional_tests
.
Altere o Teste Funcional functional_tests/tests.py para utilizar a classe LiveServerTestCase
:
from django.test import LiveServerTestCase
from selenium import webdriver
from selenium.webdriver.common.keys import Keys
import time
class NewVisitorTest(LiveServerTestCase):
def setUp(self):
self.browser = webdriver.Firefox()
...
Em seguida, remova o endereço hardcoded para visitar o localhost porta 8000.
A classe LiveServerTestCase
fornece o atributo live_server_url
para isso:
...
def test_can_start_a_list_and_retrieve_it_later(self):
# Maria decidiu utilizar o novo app TODO. Ela entra em sua página principal:
self.browser.get(self.live_server_url)
...
Podemos também remover o if __name__ == '__main__'
no final do arquivo functional_tests/tests.py, já que o framework Django será o responsável por inicar o teste.
Rode o Teste Funcional através do comando python manage.py test functional_tests
:
$ python manage.py test functional_tests
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 8.366s
OK
O Teste Funcional continua passando, assim como já estava passando antes da refatoração. Note também que ao rodar o teste uma segunda vez, os dados não são mais mantidos no BD como anteriormente. Ou seja, o BD é limpo cada vez que rodamos o teste. Dessa forma garantimos isolamento entre os Testes Funcionais.
IMPORTANTE: agora, o comando python manage.py test
roda ambos os testes: Funcional + Unidade.
Para rodar os testes separamente, utilize os seguintes comandos:
- Rodar Testes de Unidade:
python manage.py test lists
- Rodar Testes Funcionais:
python manage.py test functional_tests
Note que o Teste Funcional utiliza a função time.sleep(1)
para esperar a página carregar, também conhecidos como explicit wait.
Mas quem garante que 1 segundo é suficiente? Ou seria 1 segundo muito tempo? Por exemplo, se tivermos centenas de Testes Funcionais, cada um demorando 1 segundo, teriamos uma suite de testes que levaria vários minutos para executar. Seria 0.1 segundo o bastante? Ou 0.1 segundo não seria suficiente para carregar a página?
De fato, mesmo com 1 segundo, não teremos certeza que página irá carregar, o que pode causar uma falha no teste, ou seja, um falso positivo (leia mais sobre não determinismo em testes aqui).
Então, vamos substituir os sleeps por uma ferramenta que espera pelo tempo necessário.
Renomeie o método check_for_row_in_list_table
para wait_for_row_in_list_table
em functional_tests/tests.py e adicione a seguinte lógica nele:
...
from selenium.common.exceptions import WebDriverException
...
MAX_WAIT = 5
...
def wait_for_row_in_list_table(self, row_text):
start_time = time.time()
while True:
try:
table = self.browser.find_element_by_id('id_list_table')
rows = table.find_elements_by_tag_name('tr')
self.assertIn(row_text, [row.text for row in rows])
return
except (AssertionError, WebDriverException) as e:
if time.time() - start_time > MAX_WAIT:
raise e
time.sleep(0.5)
...
Utilizamos a constante MAX_WAIT
para definir o limite máximo de tempo para esperar, 5 segundos.
Em seguida temos um loop que termina apenas com duas condições: (1) se o assert passar (return
) ou (2) se uma exceção for lançada (raise e
).
Durante a execução do try, se uma exceção for capturada, esperamos 0.5 segundos e iniciamos o loop novamente.
Queremos capturar dois tipos de exceção: WebDriverException
para quando a página ainda não foi carregada e AssertionError
para quando a tabela está carregada, mas com dados desatualizados.
Continuando...
Atualize as chamadas de métodos de check_for_row_in_list_table
para wait_for_row_in_list_table
em test_can_start_a_list_and_retrieve_it_later
.
Também remova os time.sleep(1)
em test_can_start_a_list_and_retrieve_it_later
.
Rode o Teste Funcional e veja como ele foi executado mais rápido (e mais importante: com menor risco de não determinismo):
$ python manage.py test functional_tests
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 8.366s
OK
Note que a nossa aplicação suporta apenas uma lista global. Ou seja, todos os usuários alteram a mesma lista de tarefas, o que, claro, não é o comportamento esperado. Vamos alterar nosso design para suportar múltiplas listas. Isto é, vamos gerar URLs únicas para os usuários de modo que um usuário não veja a lista do outro, conforme já especificado em nosso Teste Funcional:
...
# Maria se pergunta se o site vai lembrar da sua lista. Então, ela verifica que
# o site gerou uma URL única para ela -- existe uma explicação sobre essa feature
# Ela visita a URL: a sua lista TODO ainda está armazenada
# Satisfeita, ela vai dormir
...
Em suma:
- Queremos que cada usuário armazene sua própria lista
- Uma lista contém vários itens; cada item contém uma descrição textual
- Precisamos salvar as listas para os usuários poderem alterá-las depois. Por enquanto, podemos gerar uma URL única, para cada usuário, contendo a lista. Futuramente, podemos melhorar esse processo.
Iremos criar uma API REST para facilitar o uso da nossa aplicação. REST sugere que tenhamos uma estrutura URL que corresponda a nossa estrutura de dados, isto é, lista e items.
Para criar uma nova lista, podemos ter a seguinte URL:
/lists/new
Cada lista deve ter sua própria URL:
/lists/<list identifier>/
Para adicionar um item em uma lista existente, temos outra URL:
/lists/<list identifier>/add_item
Vamos expandir nosso Teste Funcional functional_tests/tests.py.
Primeiro, renomeie o método test_can_start_a_list_and_retrieve_it_later
para test_can_start_a_list_for_one_user
:
def test_can_start_a_list_for_one_user(self):
# Maria decidiu utilizar o novo app TODO. Ela entra em sua página principal:
self.browser.get(self.live_server_url)
...
Vamos agora testar que múltiplos usuários podem utilizar nossa aplicação.
Queremos verificar que um segundo usuário (João) não irá ver a lista do primeiro usuário (Maria).
Em functional_tests/tests.py, adicione o seguinte método de teste test_multiple_users_can_start_lists_at_different_urls
:
def test_multiple_users_can_start_lists_at_different_urls(self):
# Maria começa uma nova lista
self.browser.get(self.live_server_url)
inputbox = self.browser.find_element_by_id('id_new_item')
inputbox.send_keys('Estudar testes funcionais')
inputbox.send_keys(Keys.ENTER)
self.wait_for_row_in_list_table('1: Estudar testes funcionais')
# Ela nota que sua lista possui uma URL única
maria_list_url = self.browser.current_url
self.assertRegex(maria_list_url, '/lists/.+')
# Agora, um novo usuário, João, entra no site
self.browser.quit()
self.browser = webdriver.Firefox()
# João visita a página inicial. Não existe nenhum sinal da lista de Maria
self.browser.get(self.live_server_url)
page_text = self.browser.find_element_by_tag_name('body').text
self.assertNotIn('1: Estudar testes funcionais', page_text)
self.assertNotIn('2: Estudar testes de unidade', page_text)
# João inicia uma nova lista
inputbox = self.browser.find_element_by_id('id_new_item')
inputbox.send_keys('Comprar leite')
inputbox.send_keys(Keys.ENTER)
self.wait_for_row_in_list_table('1: Comprar leite')
# João pega sua URL única
joao_list_url = self.browser.current_url
self.assertRegex(joao_list_url, '/lists/.+')
self.assertNotEqual(joao_list_url, maria_list_url)
# Novamente, não existe sinal da lista de Maria
page_text = self.browser.find_element_by_tag_name('body').text
self.assertNotIn('Estudar testes funcionais', page_text)
self.assertIn('Comprar leite', page_text)
# Satisfeitos, ambos vão dormir
Note o uso de assertRegex
, que verifica se uma string corresponde a uma expressão regular.
Rode o Teste Funcional e veja a falha esperada:
$ python manage.py test functional_tests
...
self.assertRegex(maria_list_url, '/lists/.+')
AssertionError: Regex didn't match: '/lists/.+' not found in 'http://localhost:58214/'
----------------------------------------------------------------------
Ran 2 tests in 13.757s
FAILED (failures=1)
Vamos iniciar de fato a implementação do novo projeto da nossa aplicação, isto é, Listas possuem URLs únicas e são formadas por Itens.
Como sempre no TDD, iniciamos o desenvolvimento pelos Testes de Unidade.
Altere o método test_redirects_after_POST
no Teste de Unidade lists/tests.py de:
def test_redirects_after_POST(self):
response = self.client.post('/', data={'item_text': 'A new list item'})
self.assertEqual(response.status_code, 302)
self.assertEqual(response['location'], '/')
Para:
def test_redirects_after_POST(self):
response = self.client.post('/', data={'item_text': 'A new list item'})
self.assertEqual(response.status_code, 302)
self.assertEqual(response['location'], '/lists/the-only-list-in-the-world/')
Para entender o teste acima pense o seguinte: atualmente, não existe nada implementado em nossa aplicação para lidar com URLs únicas. O teste acima é o primeiro (e pequeno) passo para implementarmos essa funcionalidade. Em outras palavras: quando o teste acima passar, estaremos mais próximo da solução esperada.
Rode o Teste de Unidade e veja a falha esperada:
$ python manage.py test lists
...
AssertionError: '/' != '/lists/the-only-list-in-the-world/'
Vamos ajustar a view home_page
em lists/views.py:
from django.shortcuts import redirect, render
from lists.models import Item
def home_page(request):
if request.method == 'POST':
new_item_text = request.POST['item_text']
Item.objects.create(text=new_item_text)
return redirect('/lists/the-only-list-in-the-world/')
items = Item.objects.all()
return render(request, 'home.html', {'items': items})
Tente rodar o Teste Funcional e você verá que ele foi totalmente quebrado.
Ou seja, uma regressão foi introduzida na nossa aplicação, pois a URL '/lists/the-only-list-in-the-world/'
ainda não existe.
Tentaremos voltar para um estado estável da aplicação o quanto antes.
Vamos voltar para os Testes de Unidade.
Adicione uma classe ListViewTest
em lists/tests.py, conforme segue:
...
class ListViewTest(TestCase):
def test_displays_all_items(self):
Item.objects.create(text='itemey 1')
Item.objects.create(text='itemey 2')
response = self.client.get('/lists/the-only-list-in-the-world/')
self.assertContains(response, 'itemey 1')
self.assertContains(response, 'itemey 2')
Note que estamos utilizando self.assertContains
(ao invés de self.assertIn
), um assert "mais inteligente" que sabe lidar com respostas e bytes.
Rode o Teste de Unidade:
$ python manage.py test lists
...
self.assertContains(response, 'itemey 1')
...
AssertionError: 404 != 200 : Couldn't retrieve content: Response code was 404 (expected 200)
A mensagem (404 != 200
) indica que o teste está falhando pois a nova URL ainda não existe, logo o erro HTTP 404 é retornado.
Vamos adicionar a nova URL.
Abra o arquivo superlists/urls.py e altere urlpatterns
, conforme segue:
urlpatterns = [
url(r'^$', views.home_page, name='home'),
url(r'^lists/the-only-list-in-the-world/$', views.view_list, name='view_list'),
]
Rode o Teste de Unidade:
$ python manage.py test lists
...
AttributeError: module 'lists.views' has no attribute 'view_list'
O erro é autoexplicativo.
Crie a nova view view_list
em lists/views.py:
def view_list(request):
pass
Rode o Teste de Unidade novamente:
$ python manage.py test lists
...
ValueError: The view lists.views.view_list didn't return an HttpResponse object. It returned None instead.
Atualize a view view_list
em lists/views.py para:
def view_list(request):
items = Item.objects.all()
return render(request, 'home.html', {'items': items})
Rode o Teste de Unidade e veja ele passar com sucesso:
$ python manage.py test lists
...
Ran 7 tests in 0.029s
OK
Agora vamos voltar para o Teste Funcional. Rode o Teste Funcional:
$ python manage.py test functional_tests
...
AssertionError: '2: Estudar testes de unidade' not found in ['1: Estudar testes funcionais']
...
AssertionError: '1: Estudar testes funcionais' unexpectedly found in 'Your To-Do list\n1: Estudar testes funcionais'
...
Parece que a situação melhorou um pouco (em relação a última vez que rodamos o Teste Funcional), mas o teste ainda está falhando. Note que o problema ocorre exatamente ao tentar inserir o segundo item na lista, logo, o primeiro item está sendo adicionado com sucesso.
IMPORTANTE: nosso Teste de Unidade está passando, mas o Teste Funcional não está passando. Isso indica que algum problema importante não está sendo coberto pelo Teste de Unidade.
A resposta é que nosso template home.html atualmente não especifica a URL no POST. Atualize a tag <form>
em lists/templates/home.html de:
<form method="POST">
Para:
<form method="POST" action="/">
Rode o Teste Funcional novamente:
$ python manage.py test functional_tests
...
test_multiple_users_can_start_lists_at_different_urls
self.assertNotIn('1: Estudar testes funcionais', page_text)
AssertionError: '1: Estudar testes funcionais' unexpectedly found in 'Your To-Do list\n1: Estudar testes funcionais'
----------------------------------------------------------------------
Ran 2 tests in 22.790s
FAILED (failures=1)
Note que o primeiro teste (isto é, test_can_start_a_list_for_one_user
) voltou a passar com sucesso.
Ou seja, removemos a regressão que foi adicionada na etapa anterior.
No entanto, o novo teste (isto é, test_multiple_users_can_start_lists_at_different_urls
) está falhando.
Isso é esperado, pois a implementação da nova funcionalidade ainda não foi finalizada.
Por fim, podemos remover o método de teste de unidade test_displays_all_list_items
, pois ele não é mais necessário.
Logo, ao rodar nosso Teste de Unidade, teremos 6 testes, ao invés de 7:
$ python manage.py test lists
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
......
----------------------------------------------------------------------
Ran 6 tests in 0.064s
OK
Atualmente temos apenas um template home.html para view home_page
.
Nesta seção, vamos criar um novo template list.html a view view_list
.
Como sempre, começamos pelos testes e com pequenos passos.
No Teste de Unidade lists/tests.py, adicione o método test_uses_list_template
na classe ListViewTest
para garantir que o template correto será sempre utizado:
...
class ListViewTest(TestCase):
def test_uses_list_template(self):
response = self.client.get('/lists/the-only-list-in-the-world/')
self.assertTemplateUsed(response, 'list.html')
def test_displays_all_items(self):
...
Rode o Teste de Unidade:
$ python manage.py test lists
...
AssertionError: False is not true : Template 'list.html' was not a template used to render the response. Actual template(s) used: home.html
Agora atualize a view view_list
em lists/views.py de:
def view_list(request):
items = Item.objects.all()
return render(request, 'home.html', {'items': items})
Para:
def view_list(request):
items = Item.objects.all()
return render(request, 'list.html', {'items': items})
Rode o Teste de Unidade:
$ python manage.py test lists
...
django.template.exceptions.TemplateDoesNotExist: list.html
Agora, crie o novo template list.html vazio, com comando:
$ touch lists/templates/list.html
Rode o Teste de Unidade para ver, finalmente, o teste falhando:
$ python manage.py test lists
...
AssertionError: False is not true : Couldn't find 'itemey 1' in response
Para implementar o template list, vamos reaproveitar o template home. Então, copie o template home.html para list.html:
$ cp lists/templates/home.html lists/templates/list.html
Rode o Teste de Unidade e veja ele passar:
$ python manage.py test lists
Ran 7 tests in 0.069s
OK
Como agora temos dois template (home.html e list.html), podemos simplificar home.html. Logo, altere o template lists/templates/home.html para:
<html>
<head>
<title>To-Do lists</title>
</head>
<body>
<h1>Start a new To-Do list</h1>
<form method="POST">
<input name="item_text" id="id_new_item" placeholder="Enter a to-do item" />
{% csrf_token %}
</form>
</body>
</html>
Rode o Teste de Unidade novamente para garantir que nada foi quebrado:
$ python manage.py test lists
Ran 7 tests in 0.069s
OK
Em seguida, simplifique o código a view home_page
em lists/views.py:
def home_page(request):
if request.method == 'POST':
new_item_text = request.POST['item_text']
Item.objects.create(text=new_item_text)
return redirect('/lists/the-only-list-in-the-world/')
return render(request, 'home.html')
Rode o Teste de Unidade novamente e veja que nada foi quebrado:
$ python manage.py test lists
Ran 7 tests in 0.067s
OK
Agora rode o Teste Funcional:
$ python manage.py test functional_tests
...
FAIL: test_multiple_users_can_start_lists_at_different_urls (functional_tests.tests.NewVisitorTest)
...
AssertionError: '1: Comprar leite' not found in ['1: Estudar testes funcionais', '2: Comprar leite']
Veja que nenhuma regressão foi inserida na aplicação, ou seja, o teste antigo test_can_start_a_list_for_one_user
continua passando.
O teste que está falhando é o novo teste que verifica múltiplos usuários, isto é, test_multiple_users_can_start_lists_at_different_urls
.
Ou seja, apesar do novo teste não estar passando, garantimos que nenhuma regressão foi inserida na aplicação.
Vamos trabalhar um pouco na API REST /lists/new
.
Mova os métodos test_can_save_a_POST_request
e test_redirects_after_POST
para uma nova classe NewListTest
e altere o código deles conforme segue em lists/tests.py:
class NewListTest(TestCase):
def test_can_save_a_POST_request(self):
self.client.post('/lists/new', data={'item_text': 'A new list item'})
self.assertEqual(Item.objects.count(), 1)
new_item = Item.objects.first()
self.assertEqual(new_item.text, 'A new list item')
def test_redirects_after_POST(self):
response = self.client.post('/lists/new', data={'item_text': 'A new list item'})
self.assertRedirects(response, '/lists/the-only-list-in-the-world/')
Rode o Teste de Unidade e veja a falha esperada:
$ python manage.py test lists
...
self.assertEqual(Item.objects.count(), 1)
AssertionError: 0 != 1
...
self.assertRedirects(response, '/lists/the-only-list-in-the-world/')
...
AssertionError: 404 != 302 : Response didn't redirect as expected: Response code was 404 (expected 302)
...
Precisamos atualizar o arquivo superlists/urls.py com a nova URL lists/new
:
urlpatterns = [
url(r'^$', views.home_page, name='home'),
url(r'^lists/new$', views.new_list, name='new_list'),
url(r'^lists/the-only-list-in-the-world/$', views.view_list, name='view_list'),
]
Crie também a nova view new_list
em lists/views.py:
def new_list(request):
Item.objects.create(text=request.POST['item_text'])
return redirect('/lists/the-only-list-in-the-world/')
Rode o Teste de Unidade e veja ele passar:
$ python manage.py test lists
...
Ran 7 tests in 0.030s
OK
Note as que novas views view_list
e new_list
estão fazendo boa parte do trabalho que a view home_page
estava fazendo orginalmente, isto é, listar e criar tarefas.
Desse modo, podemos simplificar bastante a nossa primeira view home_page
.
Remova o teste test_only_saves_items_when_necessary
em HomePageTest
.
Além disso, abra o arquivo lists/views.py e altere o código de home_page
para:
def home_page(request):
return render(request, 'home.html')
Rode o Teste de Unidade:
$ python manage.py test lists
...
Ran 6 tests in 0.030s
OK
Agora, rode o Teste Funcional:
$ python manage.py test functional_tests
...
ERROR: test_can_start_a_list_for_one_user (functional_tests.tests.NewVisitorTest)
...
ERROR: test_multiple_users_can_start_lists_at_different_urls (functional_tests.tests.NewVisitorTest)
...
Temos uma regressão em test_can_start_a_list_for_one_user
.
Isso ocorreu pois esquecemos de atualizar as URLs do POSTs nos templates.
Abra os arquivos lists/templates/home.html e lists/templates/list.html, e altere em ambos de:
<form method="POST" action="/">
Para:
<form method="POST" action="/lists/new">
Rode o Teste Funcional novamente e teremos apenas a nossa falha esperada:
...
FAIL: test_multiple_users_can_start_lists_at_different_urls (functional_tests.tests.NewVisitorTest)
Atualmente, nosso modelo contém apenas a classe Item
.
Vamos adicionar mais uma classe no modelo, List
, para representar a lista de tarefas de um usuário.
Iniciando pelos Testes de Unidade lists/tests.py.
Renomeie a classe ItemModelTest
para ListAndItemModelsTest
, e altere seu código conforme segue:
from django.test import TestCase
from lists.models import Item, List
...
class ListAndItemModelsTest(TestCase):
def test_saving_and_retrieving_items(self):
my_list = List()
my_list.save()
first_item = Item()
first_item.text = 'O primeiro item'
first_item.list = my_list
first_item.save()
second_item = Item()
second_item.text = 'O segundo item'
second_item.list = my_list
second_item.save()
saved_list = List.objects.first()
self.assertEqual(saved_list, my_list)
saved_items = Item.objects.all()
self.assertEqual(saved_items.count(), 2)
first_saved_item = saved_items[0]
second_saved_item = saved_items[1]
self.assertEqual(first_saved_item.text, 'O primeiro item')
self.assertEqual(first_saved_item.list, my_list)
self.assertEqual(second_saved_item.text, 'O segundo item')
self.assertEqual(second_saved_item.list, my_list)
Note que estamos criando um novo objeto List
com uma propriedade .list
.
Além disso, estamos verificando (1) que my_list
é salvo corretamente e (2) que os dois itens estão relacionados com my_list
.
Rode o Teste de Unidade e tente implementar o código mínimo da aplicação para suprimir cada mensagem de erro:
$ python manage.py test lists
...
ImportError: cannot import name 'List'
Para resolver o erro acima, você deve criar a classe List
em lists/models.py:
class List(models.Model):
pass
Rode o Teste de Unidade novamente:
$ python manage.py test lists
...
django.db.utils.OperationalError: no such table: lists_list
Rode o makemigrations
:
$ python manage.py makemigrations
Migrations for 'lists':
lists/migrations/0003_list.py
- Create model List
Rode o Teste de Unidade novamente:
$ python manage.py test lists
...
AttributeError: 'Item' object has no attribute 'list'
Em lists/models.py, adicione o atributo list
na classe Item
para representar uma chave estrageira:
from django.db import models
class List(models.Model):
pass
class Item(models.Model):
text = models.TextField(default='')
list = models.ForeignKey(List, default=None)
Rode o makemigrations
novamente:
$ python manage.py makemigrations
Migrations for 'lists':
lists/migrations/0004_item_list.py
- Add field list to item
Rode o Teste de Unidade:
$ python manage.py test lists
...
django.db.utils.IntegrityError: NOT NULL constraint failed: lists_item.list_id
Note que os testes de algumas views estão falhando.
Ocorre que precisamos atualizar os testes a aplicação para lidar com o nosso novo modelo, que agora contém List
e Item
.
No Teste de Unidade lists/tests.py, atualize o método test_displays_all_items
na classe ListViewTest
de:
...
def test_displays_all_items(self):
Item.objects.create(text='itemey 1')
Item.objects.create(text='itemey 2')
...
Para:
...
def test_displays_all_items(self):
my_list = List.objects.create()
Item.objects.create(text='itemey 1', list=my_list)
Item.objects.create(text='itemey 2', list=my_list)
...
Atualize também a view new_list
em lists/views.py de:
from lists.models import Item
...
def new_list(request):
Item.objects.create(text=request.POST['item_text'])
return redirect('/lists/the-only-list-in-the-world/')
Para:
from lists.models import Item, List
...
def new_list(request):
my_list = List.objects.create()
Item.objects.create(text=request.POST['item_text'], list=my_list)
return redirect('/lists/the-only-list-in-the-world/')
Rode o Teste de Unidade novamente, com sucesso:
$ python manage.py test lists
...
Ran 6 tests in 0.039s
OK
Estamos chegando na parte final do nosso novo projeto: cada lista deve ter uma URL única.
Talvez a solução mais simples seja gerar um id
para cada lista.
Atualize o teste ListViewTest
conforme segue, renomeando também o método test_displays_all_items
para test_displays_only_items_for_that_list
:
class ListViewTest(TestCase):
def test_uses_list_template(self):
my_list = List.objects.create()
response = self.client.get(f'/lists/{my_list.id}/')
self.assertTemplateUsed(response, 'list.html')
def test_displays_only_items_for_that_list(self):
correct_list = List.objects.create()
Item.objects.create(text='itemey 1', list=correct_list)
Item.objects.create(text='itemey 2', list=correct_list)
other_list = List.objects.create()
Item.objects.create(text='other list item 1', list=other_list)
Item.objects.create(text='other list item 2', list=other_list)
response = self.client.get(f'/lists/{correct_list.id}/')
self.assertContains(response, 'itemey 1')
self.assertContains(response, 'itemey 2')
self.assertNotContains(response, 'other list item 1')
self.assertNotContains(response, 'other list item 2')
Rode o Teste de Unidade e veja a falha esperada:
$ python manage.py test lists
...
AssertionError: 404 != 200 : Couldn't retrieve content: Response code was 404 (expected 200)
...
AssertionError: No templates used to render the response
Vamos implementar a funcionalidade. Primeiro atualize o arquivo superlists/urls.py de:
urlpatterns = [
url(r'^$', views.home_page, name='home'),
url(r'^lists/new$', views.new_list, name='new_list'),
url(r'^lists/the-only-list-in-the-world/$', views.view_list, name='view_list'),
]
Para:
urlpatterns = [
url(r'^$', views.home_page, name='home'),
url(r'^lists/new$', views.new_list, name='new_list'),
url(r'^lists/(\d+)/$', views.view_list, name='view_list'),
]
(\d+)
indica que o inteiro capturado nessa URL será passado como um argumento para a view view_list
.
Por exemplo, se o usuário digitar a URL /lists/123/
, a view view_list
receberá como segundo argumento 123
.
Se o usuário digitar a URL /lists/9/
, a view view_list
receberá como segundo argumento 9
.
Rode o Teste de Unidade e veja a falha esperada:
$ python manage.py test lists
...
TypeError: view_list() takes 1 positional argument but 2 were given
De fato, a view view_list
possui atualmente apenas um parâmetro, request
.
Em lists/views.py, atualize a view view_list
para incluir o segundo parâmetro list_id
e utilizar List
:
def view_list(request, list_id):
my_list = List.objects.get(id=list_id)
items = Item.objects.filter(list=my_list)
return render(request, 'list.html', {'items': items})
Rode o Teste de Unidade e veja uma nova falha, agora no teste test_redirects_after_POST
em NewListTest
:
$ python manage.py test lists
...
ERROR: test_redirects_after_POST (lists.tests.NewListTest)
...
ValueError: invalid literal for int() with base 10: 'the-only-list-in-the-world'
Abra o Teste de Unidade lists/tests.py e atualize test_redirects_after_POST
em NewListTest
:
def test_redirects_after_POST(self):
response = self.client.post('/lists/new', data={'item_text': 'A new list item'})
new_list = List.objects.first()
self.assertRedirects(response, f'/lists/{new_list.id}/')
Atualize também a view new_list
em lists/views.py:
def new_list(request):
my_list = List.objects.create()
Item.objects.create(text=request.POST['item_text'], list=my_list)
return redirect(f'/lists/{my_list.id}/')
Rode o Teste de Unidade com sucesso:
$ python manage.py test lists
...
Ran 6 tests in 0.044s
OK
Rode o Teste Funcional, e você verá que não é possível adicionar um segundo item em uma lista existente:
$ python manage.py test functional_tests
...
AssertionError: '2: Estudar testes de unidade' not found in ['1: Estudar testes de unidade']
Precisamos implementar nossa última API REST para adicionar itens em uma lista existente:
/lists/<list identifier>/add_item
.
Em lists/tests.py, adicione a seguinte classe de teste:
class NewItemTest(TestCase):
def test_can_save_a_POST_request_to_an_existing_list(self):
other_list = List.objects.create()
correct_list = List.objects.create()
self.client.post(
f'/lists/{correct_list.id}/add_item',
data={'item_text': 'A new item for an existing list'}
)
self.assertEqual(Item.objects.count(), 1)
new_item = Item.objects.first()
self.assertEqual(new_item.text, 'A new item for an existing list')
self.assertEqual(new_item.list, correct_list)
def test_redirects_to_list_view(self):
other_list = List.objects.create()
correct_list = List.objects.create()
response = self.client.post(
f'/lists/{correct_list.id}/add_item',
data={'item_text': 'A new item for an existing list'}
)
self.assertRedirects(response, f'/lists/{correct_list.id}/')
Rode o Teste de Unidade e veja a falha esperada (pois a URL /lists/<list identifier>/add_item
ainda não existe):
$ python manage.py test lists
...
AssertionError: 404 != 302 : Response didn't redirect as expected: Response code was 404 (expected 302)
Atualize o arquivo superlists/urls.py para incluir a nova URL:
urlpatterns = [
url(r'^$', views.home_page, name='home'),
url(r'^lists/new$', views.new_list, name='new_list'),
url(r'^lists/(\d+)/$', views.view_list, name='view_list'),
url(r'^lists/(\d+)/add_item$', views.add_item, name='add_item'),
]
Rode o Teste de Unidade:
$ python manage.py test lists
AttributeError: module 'lists.views' has no attribute 'add_item'
Adicione a view add_item
em lists/views.py:
def add_item(request):
pass
Rode o Teste de Unidade:
$ python manage.py test lists
TypeError: add_item() takes 1 positional argument but 2 were given
Atualize a view add_item
em lists/views.py:
def add_item(request, list_id):
pass
Rode o Teste de Unidade novamente:
$ python manage.py test lists
ValueError: The view lists.views.add_item didn't return an HttpResponse object. It returned None instead.
Modifique a view add_item
em lists/views.py:
def add_item(request, list_id):
list_ = List.objects.get(id=list_id)
Item.objects.create(text=request.POST['item_text'], list=list_)
return redirect(f'/lists/{list_.id}/')
Rode o Teste de Unidade, com sucesso.
A nova view add_item
está funcionando!
$ python manage.py test lists
...
Ran 8 tests in 0.055s
OK
COMMIT & PUSH com a mensagem: Implementando o Novo Projeto: View para Adicionar Items em uma Lista Existente
Vamos agora atualizar o template para utilizar a nova view. Desse modo, modifique o template list em lists/templates/list.html para:
...
<form method="POST" action="/lists/{{ list.id }}/add_item">
...
{% for item in list.item_set.all %}
...
Note que para a modificação acima funcionar, a view deverá passar uma List
para o template.
Adicione o seguinte Teste de Unidade na classe ListViewTest
em lists/tests.py:
def test_passes_correct_list_to_template(self):
other_list = List.objects.create()
correct_list = List.objects.create()
response = self.client.get(f'/lists/{correct_list.id}/')
self.assertEqual(response.context['list'], correct_list)
Por fim, em lists/views.py, faça com que a view view_list
passe uma List
.
Logo, altere view_list
de:
def view_list(request, list_id):
my_list = List.objects.get(id=list_id)
items = Item.objects.filter(list=my_list)
return render(request, 'list.html', {'items': items})
Para:
def view_list(request, list_id):
my_list = List.objects.get(id=list_id)
return render(request, 'list.html', {'list': my_list})
Rode o Teste de Unidade com sucesso:
$ python manage.py test lists
...
Ran 9 tests in 0.066s
OK
Rode também o Teste Funcional com sucesso:
$ python manage.py test functional_tests
...
Ran 2 tests in 25.576s
OK
Teste a aplicação em http://localhost:8000.
Caso obtenha o erro no such table: lists_list
:
- Delete o arquivo
db.sqlite3
- Rode o comando
python manage.py migrate
para que o banco de dados seja atualizado de acordo com novo esquema (List, Item, etc) - Teste novamente a aplicação em http://localhost:8000