6.7 URL Próprio para Cada Lista

Conforme nossa história, cada usuário deve ver sua lista em um URL próprio e único. Podemos pensar em inúmeras maneiras de gerar URLs únicos. Nossa primeira solução será utilizar o próprio identificador de uma lista em nosso banco de dados, ou seja, o id de cada lista. Como o id é utilizado como chave primária no banco de dados, temos a garantia de que será único para cada lista.

Iniciamos alterando nossos testes unitários (classe ListViewTest - linhas de 34 a 56) para que os testes apontem para novas URLs conforme abaixo. Também alteraremos o nome do método de teste test_displays_all_items para test_displays_only_items_for_that_list.

from django.urls import resolve
from django.test import TestCase
from lists.views import home_page
from lists.models import Item, List #

class HomePageTest(TestCase):

	def test_root_url_resolves_to_home_page_view(self):
		found = resolve('/')
		self.assertEquals(found.func, home_page)

	def test_home_page_returns_correct_html(self):
		response = self.client.get('/')
		self.assertTemplateUsed(response, 'home.html')

	def test_only_saves_items_when_necessary(self):
		self.client.get('/')
		self.assertEquals(Item.objects.count(), 0)


class NewListTest(TestCase):

	def test_can_save_a_POST_request(self):
		self.client.post('/lists/new', data={'item_text': 'A new list item'})
		self.assertEquals(Item.objects.count(), 1)
		new_item = Item.objects.first()
		self.assertEquals(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/')


class ListViewTest(TestCase):

	def test_uses_list_template(self):
		list_ = List.objects.create()
		response = self.client.get(f'/lists/{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')


class ListAndItemModelTest(TestCase): #

	def test_saving_and_retriving_items(self):
		list_ = List() #
		list_.save() #

		first_item = Item()
		first_item.text = 'The first (ever) list item'
		first_item.list = list_ #
		first_item.save()

		second_item = Item()
		second_item.text = 'Item the second'
		second_item.list = list_
		second_item.save()

		saved_list = List.objects.first() #
		self.assertEquals(saved_list, list_) #

		saved_items = Item.objects.all()
		self.assertEquals(saved_items.count(),2)

		first_saved_item = saved_items[0]
		second_saved_item = saved_items[1]

		self.assertEquals(first_saved_item.text, 'The first (ever) list item')
		self.assertEquals(first_saved_item.list, list_)	#
		self.assertEquals(second_saved_item.text, 'Item the second')
		self.assertEquals(second_saved_item.list, list_) #
		

Ao executar os testes resulta no 404 que era esperado e outro erro relacionado, visto que o Django ainda não sabe como atender a essas novas URLs.

(superlists) auri@av:~/superlists/superlists$ python manage.py test lists
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
....FF..
======================================================================
FAIL: test_displays_only_items_for_that_list (lists.tests.ListViewTest)
----------------------------------------------------------------------
Traceback (most recent call last):
...
    self.assertEqual(
AssertionError: 404 != 200 : Couldn't retrieve content: Response code was 404 (expected 200)

======================================================================
FAIL: test_uses_list_template (lists.tests.ListViewTest)
----------------------------------------------------------------------
Traceback (most recent call last):
...
AssertionError: No templates used to render the response

----------------------------------------------------------------------
Ran 8 tests in 0.019s

FAILED (failures=2)
Destroying test database for alias 'default'...

Capturando Parâmetros de URLs

Observe que precisamos de URLs parametrizadas agora e, para isso, precisamos aprender a passar parâmetros para nossas views. Alteramos o nosso arquivo superlists/urls.py para que o mesmo trate as URLs com id varável. Usamos para isso expressões regulares.

from django.urls import path, re_path
from lists import views

urlpatterns = [
    path('', views.home_page, name='home'),
    path('lists/new', views.new_list, name='new_list'),
    re_path(r'^lists/(.+)/$', views.view_list, name='view_list'),
]

Ao executar os testes após essa alteração o resultado é conforme mostrado abaixo:

(superlists) auri@av:~/superlists/superlists$ python manage.py test lists
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
....EE.E
======================================================================
ERROR: test_displays_only_items_for_that_list (lists.tests.ListViewTest)
----------------------------------------------------------------------
Traceback (most recent call last):
...
TypeError: view_list() takes 1 positional argument but 2 were given

======================================================================
ERROR: test_uses_list_template (lists.tests.ListViewTest)
----------------------------------------------------------------------
Traceback (most recent call last):
...
TypeError: view_list() takes 1 positional argument but 2 were given

======================================================================
ERROR: test_redirects_after_POST (lists.tests.NewListTest)
----------------------------------------------------------------------
Traceback (most recent call last):
...
TypeError: view_list() takes 1 positional argument but 2 were given

----------------------------------------------------------------------
Ran 8 tests in 0.035s

FAILED (errors=3)
Destroying test database for alias 'default'...

A queixa que nossa função de view (view_list) agora precisa receber mais um argumento, exatamente o id de uma lista para poder apresentar seu conteúdo. A forma mais simples de corrigir isso é incluindo um parâmetro fictício na função da view (linha 8), conforme abaixo:

from django.shortcuts import redirect, render
from lists.models import Item, List

# Create your views here.
def home_page(request):
	return render(request, 'home.html')

def view_list(request, list_id):
	items = Item.objects.all()
	return render(request, 'list.html', {'items': items})

def new_list(request):
	list_ = List.objects.create()
	Item.objects.create(text=request.POST['item_text'], list=list_)
	return redirect('/lists/the-only-list-in-the-world/')

Ao executar os testes agora nosso erro muda.

(superlists) auri@av:~/superlists/superlists$ python manage.py test lists
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
....F...
======================================================================
FAIL: test_displays_only_items_for_that_list (lists.tests.ListViewTest)
----------------------------------------------------------------------
Traceback (most recent call last):
...
AssertionError: 1 != 0 : Response should not contain 'other list item 1'

----------------------------------------------------------------------
Ran 8 tests in 0.018s

FAILED (failures=1)
Destroying test database for alias 'default'...

Isso porque nossa view não está diferenciando para qual lista ela manda cada item e, desse modo, tudo está presente em uma única lista, ocasionando o erro. Para corrigir, vamos novamente alterar o código de lists/views.py, conforme abaixo:

from django.shortcuts import redirect, render
from lists.models import Item, List

# Create your views here.
def home_page(request):
	return render(request, 'home.html')

def view_list(request, list_id):
	list_ = List.objects.get(id=list_id)
	items = Item.objects.filter(list=list_)
	return render(request, 'list.html', {'items': items})

def new_list(request):
	list_ = List.objects.create()
	Item.objects.create(text=request.POST['item_text'], list=list_)
	return redirect('/lists/the-only-list-in-the-world/')

Com isso, agora passamos a ter erros em outros testes conforme abaixo:

(superlists) auri@av:~/superlists/superlists$ python manage.py test lists
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.......E
======================================================================
ERROR: test_redirects_after_POST (lists.tests.NewListTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/mlptdd/superlists/lib/python3.8/site-packages/django/db/models/fields/__init__.py", line 1823, in get_prep_value
    return int(value)
ValueError: invalid literal for int() with base 10: 'the-only-list-in-the-world'

The above exception was the direct cause of the following exception:
...
ValueError: Field 'id' expected a number but got 'the-only-list-in-the-world'.

----------------------------------------------------------------------
Ran 8 tests in 0.035s

FAILED (errors=1)
Destroying test database for alias 'default'...

Adaptando new_list para lidar com URLs parametrizadas

O erro apresentado acima indica que o teste unitário test_redirects_after_POST ainda não foi adaptado para a nossa nova realizada. Desse modo, o mesmo precisa ser alterado conforme apresentado nas linhas 29 a 32 do código abaixo:

from django.urls import resolve
from django.test import TestCase
from lists.views import home_page
from lists.models import Item, List

class HomePageTest(TestCase):

	def test_root_url_resolves_to_home_page_view(self):
		found = resolve('/')
		self.assertEquals(found.func, home_page)

	def test_home_page_returns_correct_html(self):
		response = self.client.get('/')
		self.assertTemplateUsed(response, 'home.html')

	def test_only_saves_items_when_necessary(self):
		self.client.get('/')
		self.assertEquals(Item.objects.count(), 0)


class NewListTest(TestCase):

	def test_can_save_a_POST_request(self):
		self.client.post('/lists/new', data={'item_text': 'A new list item'})
		self.assertEquals(Item.objects.count(), 1)
		new_item = Item.objects.first()
		self.assertEquals(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'})
		new_list = List.objects.first()
		self.assertRedirects(response, f'/lists/{new_list.id}/')


class ListViewTest(TestCase):

	def test_uses_list_template(self):
		list_ = List.objects.create()
		response = self.client.get(f'/lists/{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')


class ListAndItemModelTest(TestCase):

	def test_saving_and_retriving_items(self):
		list_ = List()
		list_.save()

		first_item = Item()
		first_item.text = 'The first (ever) list item'
		first_item.list = list_
		first_item.save()

		second_item = Item()
		second_item.text = 'Item the second'
		second_item.list = list_
		second_item.save()

		saved_list = List.objects.first()
		self.assertEquals(saved_list, list_)

		saved_items = Item.objects.all()
		self.assertEquals(saved_items.count(),2)

		first_saved_item = saved_items[0]
		second_saved_item = saved_items[1]

		self.assertEquals(first_saved_item.text, 'The first (ever) list item')
		self.assertEquals(first_saved_item.list, list_)
		self.assertEquals(second_saved_item.text, 'Item the second')
		self.assertEquals(second_saved_item.list, list_)

Mesmo com a correção acima, continuamos obtendo o erro de literal inválido como antes.

(superlists) auri@av:~/superlists/superlists$ python manage.py test lists
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.......E
======================================================================
ERROR: test_redirects_after_POST (lists.tests.NewListTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/mlptdd/superlists/lib/python3.8/site-packages/django/db/models/fields/__init__.py", line 1823, in get_prep_value
    return int(value)
ValueError: invalid literal for int() with base 10: 'the-only-list-in-the-world'

The above exception was the direct cause of the following exception:
...
ValueError: Field 'id' expected a number but got 'the-only-list-in-the-world'.

----------------------------------------------------------------------
Ran 8 tests in 0.034s

FAILED (errors=1)
Destroying test database for alias 'default'...

Nesse caso, precisamos também corrigir nossa view (lists/views.py) e atualizar os redirecionamentos para os locais corretos, conforme abaixo (linha 16).

from django.shortcuts import redirect, render
from lists.models import Item, List

# Create your views here.
def home_page(request):
	return render(request, 'home.html')

def view_list(request, list_id):
	list_ = List.objects.get(id=list_id)
	items = Item.objects.filter(list=list_)
	return render(request, 'list.html', {'items': items})

def new_list(request):
	list_ = List.objects.create()
	Item.objects.create(text=request.POST['item_text'], list=list_)
	return redirect(f'/lists/{list_.id}/')

Isso faz com que nossos testes unitários passem e fiquem novamente consistentes com a implementação.

(superlists) auri@av:~/superlists/superlists$ python manage.py test lists
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
........
----------------------------------------------------------------------
Ran 8 tests in 0.021s

OK
Destroying test database for alias 'default'...

Regressão nos Testes Funcionais

Apesar do sucesso na execução dos testes unitários, os testes funcionais mostram que tivemos uma regressão.

(superlists) auri@av:~/superlists/superlists$ python manage.py test functional_tests
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F.
======================================================================
FAIL: test_can_start_a_list_for_one_user (tests.NewVisitorTest)
----------------------------------------------------------------------
Traceback (most recent call last):
...
AssertionError: '2: Use peacock feathers to make a fly' not found in ['1: Use peacock feathers to make a fly']

----------------------------------------------------------------------
Ran 2 tests in 18.979s

FAILED (failures=1)
Destroying test database for alias 'default'...

Os testes funcionais indicam que nossa solução preliminar ainda não resolve o prolema de forma adequada. Como criamos uma lista nova a cada item, não é possível adicionar mais de um item na mesma lista. Tal requisito está associado ao último item da nossa lista de tarefas.

View adicional para inclusão de item em lista existente

Para a inclusão de um item em uma lista existente, precisamos de um novo URL e de uma nova view que trate essa nova URL.

A URL em questão pode ser algo do tipo /lists/<list_id>/add_item. O primeiro passo é a criação de casos de teste unitários para lidar com a inclusão de itens em uma lista existente. Para isso, criamos a classe de teste NewItemTest (linhas 35 a 59).

from django.urls import resolve
from django.test import TestCase
from lists.views import home_page
from lists.models import Item, List

class HomePageTest(TestCase):

	def test_root_url_resolves_to_home_page_view(self):
		found = resolve('/')
		self.assertEquals(found.func, home_page)

	def test_home_page_returns_correct_html(self):
		response = self.client.get('/')
		self.assertTemplateUsed(response, 'home.html')

	def test_only_saves_items_when_necessary(self):
		self.client.get('/')
		self.assertEquals(Item.objects.count(), 0)


class NewListTest(TestCase):

	def test_can_save_a_POST_request(self):
		self.client.post('/lists/new', data={'item_text': 'A new list item'})
		self.assertEquals(Item.objects.count(), 1)
		new_item = Item.objects.first()
		self.assertEquals(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'})
		new_list = List.objects.first()
		self.assertRedirects(response, f'/lists/{new_list.id}/')


class NewItemTets(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}/')


class ListViewTest(TestCase):

	def test_uses_list_template(self):
		list_ = List.objects.create()
		response = self.client.get(f'/lists/{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')


class ListAndItemModelTest(TestCase):

	def test_saving_and_retriving_items(self):
		list_ = List()
		list_.save()

		first_item = Item()
		first_item.text = 'The first (ever) list item'
		first_item.list = list_
		first_item.save()

		second_item = Item()
		second_item.text = 'Item the second'
		second_item.list = list_
		second_item.save()

		saved_list = List.objects.first()
		self.assertEquals(saved_list, list_)

		saved_items = Item.objects.all()
		self.assertEquals(saved_items.count(),2)

		first_saved_item = saved_items[0]
		second_saved_item = saved_items[1]

		self.assertEquals(first_saved_item.text, 'The first (ever) list item')
		self.assertEquals(first_saved_item.list, list_)
		self.assertEquals(second_saved_item.text, 'Item the second')
		self.assertEquals(second_saved_item.list, list_)

Ao executar os testes unitários, temos a seguinte saída.

(superlists) auri@av:~/superlists/superlists$ python manage.py test lists
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
......FF..
======================================================================
FAIL: test_can_save_a_POST_request_to_an_existing_list (lists.tests.NewItemTets)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/mlptdd/superlists/superlists/lists/tests.py", line 45, in test_can_save_a_POST_request_to_an_existing_list
    self.assertEqual(Item.objects.count(), 1)
AssertionError: 0 != 1

======================================================================
FAIL: test_redirects_to_list_view (lists.tests.NewItemTets)
----------------------------------------------------------------------
Traceback (most recent call last):
...
AssertionError: 301 != 302 : Response didn't redirect as expected: 
Response code was 301 (expected 302)

----------------------------------------------------------------------
Ran 10 tests in 0.022s

FAILED (failures=2)
Destroying test database for alias 'default'...

A falha decorrente do teste indica que as URLs não são resolvidas corretamente. Na verdade, o que ocorre é que nossa expressão regular está capturando muito mais URLs do que desejamos. A explicação para isso é que o Django considera que um URL é identificado por uma expressão regular se o mesmo diferir apenas por uma barra '/' que é o que está ocorrendo nesse caso, ou seja, lists/(.+)/está capturando também nossa URL lists/1/add_item, por exemplo.

A solução para isso é sermos mais específicos em nossas expressões regulares para a solução de URLs. Por exemplo, se alterarmos superlists/urls.y conforme abaixo, a URL para adição de item deixa de ser identificada por essa regra. O \d+ na linha 7 faz com que apenas dígitos (um ou mais) passem a ser reconhecidos.

from django.urls import path, re_path
from lists import views

urlpatterns = [
    path('', views.home_page, name='home'),
    path('lists/new', views.new_list, name='new_list'),
    re_path(r'^lists/(\d+)/$', views.view_list, name='view_list'),
]

Com essa alteração, o resultado dos testes unitários passa a ser conforme abaixo:

(superlists) auri@av:~/superlists/superlists$ python manage.py test lists
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
......FF..
======================================================================
FAIL: test_can_save_a_POST_request_to_an_existing_list (lists.tests.NewItemTets)
----------------------------------------------------------------------
Traceback (most recent call last):
...
AssertionError: 0 != 1

======================================================================
FAIL: test_redirects_to_list_view (lists.tests.NewItemTets)
----------------------------------------------------------------------
Traceback (most recent call last):
...
AssertionError: 404 != 302 : Response didn't redirect as expected: Response code was 404 (expected 302)

----------------------------------------------------------------------
Ran 10 tests in 0.023s

FAILED (failures=2)
Destroying test database for alias 'default'...

Novo URL para adicionar item

Para resolver agora o problema do 404 acima precisamos incluir nossa nova URL para adição de novo item no arquivo superlists/urls.py, conforme abaixo:

from django.urls import path, re_path
from lists import views

urlpatterns = [
    path('', views.home_page, name='home'),
    path('lists/new', views.new_list, name='new_list'),
    re_path(r'^lists/(\d+)/$', views.view_list, name='view_list'),
    re_path(r'^lists/(\d+)/add_item$', views.add_item, name='add_item'),
]

Após a alteração, observamos que existem três URLs bastante semelhantes e, posteriormente, pode ser que queiramos refatorar esse arquivo para melhorar isso. Por hora, vamos apenas executar os testes para ver se avançamos.

(superlists) auri@av:~/superlists/superlists$ python manage.py test lists
Creating test database for alias 'default'...
Destroying test database for alias 'default'...
Traceback (most recent call last):
...
AttributeError: module 'lists.views' has no attribute 'add_item'

Perfeito, URL resolvida, mas função de tratamento na view ainda não implementada. Vamos implementa-la em lists/views.py.

from django.shortcuts import redirect, render
from lists.models import Item, List

def home_page(request):
	return render(request, 'home.html')

def view_list(request, list_id):
	list_ = List.objects.get(id=list_id)
	items = Item.objects.filter(list=list_)
	return render(request, 'list.html', {'items': items})

def new_list(request):
	list_ = List.objects.create()
	Item.objects.create(text=request.POST['item_text'], list=list_)
	return redirect(f'/lists/{list_.id}/')

def add_item(request):
	pass

Com a reexecução dos testes observamos que a função foi reconhecida, mas não recebe ainda a quantidade suficiente de parâmetros prevista em nossos testes.

(superlists) auri@av:~/superlists/superlists$ python manage.py test lists
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
......EE..
======================================================================
ERROR: test_can_save_a_POST_request_to_an_existing_list (lists.tests.NewItemTets)
----------------------------------------------------------------------
Traceback (most recent call last):
...
TypeError: add_item() takes 1 positional argument but 2 were given

======================================================================
ERROR: test_redirects_to_list_view (lists.tests.NewItemTets)
----------------------------------------------------------------------
Traceback (most recent call last):
...
TypeError: add_item() takes 1 positional argument but 2 were given

----------------------------------------------------------------------
Ran 10 tests in 0.036s

FAILED (errors=2)
Destroying test database for alias 'default'...

Vamos então atualizar o código da view novamente e colocar mais um argumento (list_id - linha 17) na função add_item, conforme abaixo:

from django.shortcuts import redirect, render
from lists.models import Item, List

def home_page(request):
	return render(request, 'home.html')

def view_list(request, list_id):
	list_ = List.objects.get(id=list_id)
	items = Item.objects.filter(list=list_)
	return render(request, 'list.html', {'items': items})

def new_list(request):
	list_ = List.objects.create()
	Item.objects.create(text=request.POST['item_text'], list=list_)
	return redirect(f'/lists/{list_.id}/')

def add_item(request, list_id):
	pass

Agora nossos testes apresentam o erro abaixo:

(superlists) auri@av:~/superlists/superlists$ python manage.py test lists
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
......EE..
======================================================================
ERROR: test_can_save_a_POST_request_to_an_existing_list (lists.tests.NewItemTets)
----------------------------------------------------------------------
Traceback (most recent call last):
...
ValueError: The view lists.views.add_item didn't 
return an HttpResponse object. It returned None instead.

======================================================================
ERROR: test_redirects_to_list_view (lists.tests.NewItemTets)
----------------------------------------------------------------------
Traceback (most recent call last):
...
ValueError: The view lists.views.add_item didn't 
return an HttpResponse object. It returned None instead.

----------------------------------------------------------------------
Ran 10 tests in 0.039s

FAILED (errors=2)
Destroying test database for alias 'default'...

Ou seja, nossa função de view add_item ainda não retorna um objeto do tipo HttpResponse. Vamos corrigir isso conforme abaixo:

from django.shortcuts import redirect, render
from lists.models import Item, List

def home_page(request):
	return render(request, 'home.html')

def view_list(request, list_id):
	list_ = List.objects.get(id=list_id)
	items = Item.objects.filter(list=list_)
	return render(request, 'list.html', {'items': items})

def new_list(request):
	list_ = List.objects.create()
	Item.objects.create(text=request.POST['item_text'], list=list_)
	return redirect(f'/lists/{list_.id}/')

def add_item(request, list_id):
	list_ = List.objects.get(id=list_id)
	return redirect(f'/lists/{list_.id}/')

Agora nosso erro nos testes unitários passou a ser o exibido abaixo:

(superlists) auri@av:~/superlists/superlists$ python manage.py test lists
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
......F...
======================================================================
FAIL: test_can_save_a_POST_request_to_an_existing_list (lists.tests.NewItemTets)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/mlptdd/superlists/superlists/lists/tests.py", line 45, in test_can_save_a_POST_request_to_an_existing_list
    self.assertEqual(Item.objects.count(), 1)
AssertionError: 0 != 1

----------------------------------------------------------------------
Ran 10 tests in 0.027s

FAILED (failures=1)
Destroying test database for alias 'default'...

Vamos então incluir o item solicitado na lista para que o assert acima passe a funcionar. Novamente vamos então alterar nossa função add_item conforme abaixo:

from django.shortcuts import redirect, render
from lists.models import Item, List

def home_page(request):
	return render(request, 'home.html')

def view_list(request, list_id):
	list_ = List.objects.get(id=list_id)
	items = Item.objects.filter(list=list_)
	return render(request, 'list.html', {'items': items})

def new_list(request):
	list_ = List.objects.create()
	Item.objects.create(text=request.POST['item_text'], list=list_)
	return redirect(f'/lists/{list_.id}/')

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}/')

E com essa alteração nossos testes unitários passam com sucesso.

(superlists) auri@av:~/superlists/superlists$ python manage.py test lists
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..........
----------------------------------------------------------------------
Ran 10 tests in 0.025s

OK
Destroying test database for alias 'default'...

Testando objetos de contextos de respostas diretamente

Agora que temos nosso URL e uma view para a adição de items na lista, precisamos apenas fazer nosso template funcionar para exibir a lista corretamente. Com isso, vamos então alterar o nosso arquivo de template list.html, atualizando o atributo action conforme linha 7 abaixo, e o criar um caso de teste na classe ListViewTest para nos assegurar de que isso está funcionando corretamente (linhas 86 a 90) dos testes unitários.

<html>
	<head>
		<title>To-Do lists</title>
	</head>
	<body>
		<h1>Your To-Do list</h1>
		<form method="POST" action="/lists/{{list.id}}/add_item">
			<input name="item_text" id="id_new_item" placeholder="Enter a to-do item" />
			{% csrf_token %}
		</form>
		<table id="id_list_table">
			{% for item in items %}
			<tr><td>{{ forloop.counter }}: {{ item.text }}</td></tr>
			{% endfor %}
		</table>
	</body>
</html>
from django.urls import resolve
from django.test import TestCase
from lists.views import home_page
from lists.models import Item, List

class HomePageTest(TestCase):

	def test_root_url_resolves_to_home_page_view(self):
		found = resolve('/')
		self.assertEquals(found.func, home_page)

	def test_home_page_returns_correct_html(self):
		response = self.client.get('/')
		self.assertTemplateUsed(response, 'home.html')

	def test_only_saves_items_when_necessary(self):
		self.client.get('/')
		self.assertEquals(Item.objects.count(), 0)


class NewListTest(TestCase):

	def test_can_save_a_POST_request(self):
		self.client.post('/lists/new', data={'item_text': 'A new list item'})
		self.assertEquals(Item.objects.count(), 1)
		new_item = Item.objects.first()
		self.assertEquals(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'})
		new_list = List.objects.first()
		self.assertRedirects(response, f'/lists/{new_list.id}/')


class NewItemTets(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}/')


class ListViewTest(TestCase):

	def test_uses_list_template(self):
		list_ = List.objects.create()
		response = self.client.get(f'/lists/{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')

	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)


class ListAndItemModelTest(TestCase):

	def test_saving_and_retriving_items(self):
		list_ = List()
		list_.save()

		first_item = Item()
		first_item.text = 'The first (ever) list item'
		first_item.list = list_
		first_item.save()

		second_item = Item()
		second_item.text = 'Item the second'
		second_item.list = list_
		second_item.save()

		saved_list = List.objects.first()
		self.assertEquals(saved_list, list_)

		saved_items = Item.objects.all()
		self.assertEquals(saved_items.count(),2)

		first_saved_item = saved_items[0]
		second_saved_item = saved_items[1]

		self.assertEquals(first_saved_item.text, 'The first (ever) list item')
		self.assertEquals(first_saved_item.list, list_)
		self.assertEquals(second_saved_item.text, 'Item the second')
		self.assertEquals(second_saved_item.list, list_)

Nos testes acima, na linha 90 usamos response.context['list']. Isso representa o contexto que passamos para nossa função de renderização. O Django Test Client o insere no objeto response para nós visando a facilitar nossos testes.

Ao executar os testes unitários acima temos a seguinte saída:

(superlists) auri@av:~/superlists/superlists$ python manage.py test lists
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.....E.....
======================================================================
ERROR: test_passes_correct_list_to_template (lists.tests.ListViewTest)
----------------------------------------------------------------------
Traceback (most recent call last):
...
KeyError: 'list'

----------------------------------------------------------------------
Ran 11 tests in 0.029s

FAILED (errors=1)
Destroying test database for alias 'default'...

O erro ocorre pois, apesar de estarmos solicitando essa chave no nosso caso de teste, ela ainda não está em nosso objeto response. Temos passá-la no processamento da nossa função de view (linha 9), conforme abaixo:

from django.shortcuts import redirect, render
from lists.models import Item, List

def home_page(request):
	return render(request, 'home.html')

def view_list(request, list_id):
	list_ = List.objects.get(id=list_id)
	return render(request, 'list.html', {'list': list_})

def new_list(request):
	list_ = List.objects.create()
	Item.objects.create(text=request.POST['item_text'], list=list_)
	return redirect(f'/lists/{list_.id}/')

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}/')

Ao fazer tal alteração e reexecutar os testes, obtemos o erro abaixo pois, um de nossos testes antigos que usava a chave item que foi substituída por list.

(superlists) auri@av:~/superlists/superlists$ python manage.py test lists
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
....F......
======================================================================
FAIL: test_displays_only_items_for_that_list (lists.tests.ListViewTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/mltdd/superlists/superlists/lists/tests.py", line 81, in test_displays_only_items_for_that_list
    self.assertContains(response, 'itemey 1')
  File "/home/mlptdd/superlists/lib/python3.8/site-packages/django/test/testcases.py", line 471, in assertContains
    self.assertTrue(real_count != 0, msg_prefix + "Couldn't find %s in response" % text_repr)
AssertionError: False is not true : Couldn't find 'itemey 1' in response

----------------------------------------------------------------------
Ran 11 tests in 0.030s

FAILED (failures=1)
Destroying test database for alias 'default'...

A correção para esse problema pode ser feita no nosso template list.html, ajustando o for que faz a iteração sobre os items de uma lista.

<html>
	<head>
		<title>To-Do lists</title>
	</head>
	<body>
		<h1>Your To-Do list</h1>
		<form method="POST" action="/lists/{{list.id}}/add_item">
			<input name="item_text" id="id_new_item" placeholder="Enter a to-do item" />
			{% csrf_token %}
		</form>
		<table id="id_list_table">
			{% for item in list.item_set.all %}
			<tr><td>{{ forloop.counter }}: {{ item.text }}</td></tr>
			{% endfor %}
		</table>
	</body>
</html>

No nosso template acima usamos .item_set, conhecido como lookup reverso e se mostra muito útil aqui. Esse recurso possibilita recuperarmos items relacionados a um objeto a partir de uma tabela diferente. Mais informações sobre o recurso estão disponíveis na documentação oficial do Django em https://docs.djangoproject.com/en/3.2/topics/db/queries/#following-relationships-backward.

Ao finalizar a alteração nossos testes unitários passam com sucesso.

(superlists) auri@av:~/superlists/superlists$ python manage.py test lists
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...........
----------------------------------------------------------------------
Ran 11 tests in 0.028s

OK
Destroying test database for alias 'default'...

E nossos testes funcionais?

(superlists) auri@av:~/superlists/superlists$ python manage.py test functional_tests
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..
----------------------------------------------------------------------
Ran 2 tests in 9.392s

OK
Destroying test database for alias 'default'...

Excelente, também estão aprovados com sucesso. Hora de confirmar nossas alterações e colocar tudo sob controle de versão.

(superlists) auri@av:~/superlists/superlists$ git status
No ramo master
Your branch is up to date with 'origin/master'.

Changes not staged for commit:
  (utilize "git add <arquivo>..." para atualizar o que será submetido)
  (use "git restore <file>..." to discard changes in working directory)
	modified:   functional_tests/tests.py
	modified:   lists/templates/list.html
	modified:   lists/tests.py
	modified:   lists/views.py
	modified:   superlists/urls.py

nenhuma modificação adicionada à submissão (utilize "git add" e/ou "git commit -a")

(superlists) auri@av:~/superlists/superlists$ git commit -am "New URL + view for adding to existing list. FT passes :-)"
[master 9152a2e] New URL + view for adding to existing list. FT passes :-)
 5 files changed, 72 insertions(+), 27 deletions(-)

(superlists) auri@av:~/superlists/superlists$ git push
Username for 'https://github.com': aurimrv
Password for 'https://aurimrv@github.com': 
Enumerating objects: 21, done.
Counting objects: 100% (21/21), done.
Delta compression using up to 12 threads
Compressing objects: 100% (10/10), done.
Writing objects: 100% (11/11), 1.54 KiB | 1.54 MiB/s, done.
Total 11 (delta 8), reused 0 (delta 0)
remote: Resolving deltas: 100% (8/8), completed with 8 local objects.
To https://github.com/aurimrv/superlists.git
   20f8359..9152a2e  master -> master

Última refatoração

Para encerrar, podemos refatorar nosso código para considerar o recurso de inclusões de URLs. Definitivamente, o arquivo superlists/urls.py deveria ser utilizado para URLs relacionadas com todo o nosso site e não necessariamente com as URLs de uma aplicação particular.

Desse modo, o Django nos orienta a criarmos nosso próprio arquivo lists/urls.py para conter as URLs relacionadas com o nosso projeto particular.

Para realizar essa alteração, vamos primeiro fazer uma cópia do nosso arquivo urls.py atual para o novo que iremos alterar.

(superlists) auri@av:~/superlists/superlists$ cp superlists/urls.py lists/urls.py

Em seguida, alteramos superlists/urls.py conforme abaixo, usando o recurso do Django denominado de inclusões de URLs.

from django.conf.urls import include
from django.urls import re_path
from lists import views as list_views
from lists import urls as list_urls

urlpatterns = [
    re_path(r'^$', list_views.home_page, name='home'),
    re_path(r'^lists/', include(list_urls)),
]

Em seguida, editamos nosso novo lists/urls.py para ficar conforme abaixo:

from django.urls import re_path
from lists import views

urlpatterns = [
    re_path(r'^new$', views.new_list, name='new_list'),
    re_path(r'^(\d+)/$', views.view_list, name='view_list'),
    re_path(r'^(\d+)/add_item$', views.add_item, name='add_item'),
]

Ao executar nossos testes de unidade... Sucesso!!!

(superlists) auri@av:~/superlists/superlists$ python manage.py test lists
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...........
----------------------------------------------------------------------
Ran 11 tests in 0.028s

OK
Destroying test database for alias 'default'...

Ao executar nossos testes de integração... Também sucesso!!!

(superlists) auri@av:~/superlists/superlists$ python manage.py test functional_tests
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..
----------------------------------------------------------------------
Ran 2 tests in 10.334s

OK
Destroying test database for alias 'default'...

Hora para mais um commit de confirmação de nossa refatoração bem sucedida e encerramento de um ciclo completo de TDD.

(superlists) auri@av:~/superlists/superlists$ git status
No ramo master
Your branch is up to date with 'origin/master'.

Changes not staged for commit:
  (utilize "git add <arquivo>..." para atualizar o que será submetido)
  (use "git restore <file>..." to discard changes in working directory)
	modified:   superlists/urls.py

Arquivos não monitorados:
  (utilize "git add <arquivo>..." para incluir o que será submetido)
	lists/urls.py

nenhuma modificação adicionada à submissão (utilize "git add" e/ou "git commit -a")

(superlists) auri@av:~/superlists/superlists$ git add lists/urls.py 

(superlists) auri@av:~/superlists/superlists$ git add superlists/urls.py 

(superlists) auri@av:~/superlists/superlists$ git commit -am "URL include successful. Unit and Functional test pass."
[master 0f1bd85] URL include successful. Unit and Functional test pass.
 2 files changed, 29 insertions(+), 6 deletions(-)
 create mode 100644 lists/urls.py

(superlists) auri@av:~/superlists/superlists$ git push
Username for 'https://github.com': aurimrv
Password for 'https://aurimrv@github.com': 
Enumerating objects: 10, done.
Counting objects: 100% (10/10), done.
Delta compression using up to 12 threads
Compressing objects: 100% (6/6), done.
Writing objects: 100% (6/6), 674 bytes | 674.00 KiB/s, done.
Total 6 (delta 5), reused 0 (delta 0)
remote: Resolving deltas: 100% (5/5), completed with 4 local objects.
To https://github.com/aurimrv/superlists.git
   9152a2e..0f1bd85  master -> master

Finalmente, antes de utilizar o sistema em produção, é necessário executar o comando abaixo para atualizar o esquema do banco de dados de produção.

(superlists) tdd@mlp:~/superlists/superlists$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, lists, sessions
Running migrations:
  Applying lists.0003_list... OK
  Applying lists.0004_item_list... OK
  Applying lists.0005_alter_item_list... OK

Feito o migrate, basta abrir a URL http://localhost:8000 e testar o nosso sistema.

Last updated