No capítulo anterior, interrompemos a implementação da nossa aplicação pela necessidade de processarmos os itens a serem armazenados em nossa lista. O primeiro passo para isso é permitir que nossa página envie uma requisição POST para ser processada.
Para fazer isso precisamos realizar duas operações no nosso template: 1) inserir um atributo name no elemento input(name="item_text") (linha 8); e 2) encapsular o elemento input em uma tag form com método POST (linhas 7 a 9). O resultado é mostrado abaixo:
Com essa alteração, ao executar o teste funcional temos o resultado abaixo:
(superlists) tdd@mlp:~/superlists/superlists$pythonfunctional_tests.pyE======================================================================ERROR:test_can_start_a_list_and_retrieve_it_later (__main__.NewVisitorTest)----------------------------------------------------------------------Traceback (most recentcalllast):File"/home/tdd/superlists/superlists/functional_tests.py",line53,intest_can_start_a_list_and_retrieve_it_latertable=self.browser.find_element(By.ID,'id_list_table') File "/home/tdd/.pyenv/versions/superlists/lib/python3.10/site-packages/selenium/webdriver/remote/webdriver.py", line 748, in find_element
returnself.execute(Command.FIND_ELEMENT,{"using":by,"value":value})["value"] File "/home/tdd/.pyenv/versions/superlists/lib/python3.10/site-packages/selenium/webdriver/remote/webdriver.py", line 354, in execute
self.error_handler.check_response(response) File "/home/tdd/.pyenv/versions/superlists/lib/python3.10/site-packages/selenium/webdriver/remote/errorhandler.py", line 229, in check_response
raiseexception_class(message,screen,stacktrace)selenium.common.exceptions.NoSuchElementException: Message: Unable to locate element: [id="id_list_table"]; For documentation on this error, please visit: https://www.selenium.dev/documentation/webdriver/troubleshooting/errors#no-such-element-exception
Stacktrace:RemoteError@chrome://remote/content/shared/RemoteError.sys.mjs:8:8WebDriverError@chrome://remote/content/shared/webdriver/Errors.sys.mjs:193:5NoSuchElementError@chrome://remote/content/shared/webdriver/Errors.sys.mjs:511:5dom.find/</<@chrome://remote/content/shared/DOM.sys.mjs:136:16----------------------------------------------------------------------Ran1testin4.890sFAILED (errors=1)
Pela mensagem, o Selenium não foi capaz de localizar na página o elemento com id="id_list_table" (linhas 7 e 8). Quando um elemento não é encontrado, a primeira coisa que imaginamos é que ainda não deu tempo do elemento ser renderizado e, desse modo, podemos tentar aumentar o tempo do comando time.sleep e verificar o que acontece. No caso, substituímos o antigo time.sleep(1) por time.sleep(10) no nosso caso de teste funcional (functional_tests.py). Agora, ao executarmos os testes, a espera será bem maior e será possível verificarmos se o Selenium é capaz de achar o elemento procurado. Entretanto, o que vemos é que o problema persiste mas, com o tempo maior conseguimos ler a tela e a mensagem de erro que ela apresenta, exibida abaixo.
Basicamente, o Django está nos alertando que, quando utilizamos métodos POST em formulário, é necessária a utilização de um token para proteger nossa aplicação contra o ataque de Cross Site Request Forgery (CSRF). Para mais informações o leitor interessado pode consultar a documentação do Django em https://docs.djangoproject.com/en/3.2/ref/csrf/. O procedimento básico para resolver o problema no nosso exemplo é incluir a tag {% csrf_token %}dentro de todo elemento <form> que for para uma URL interna. No Django, tags envolvidas por {% ... %} são chamadas de tags de template.
Assim sendo, a correção do nosso template é apresentada abaixo.
Agora, ao executarmos os testes, apesar de demorar um tempo, o resultado de falha é o mesmo que estávamos obtendo anteriormente e podemos retornar o comando time.sleep(10) para time.sleep(1) em nosso caso de teste funcional (functional_tests.py), já que o problema não era de sincronização.
Como podemos observar no nosso form de submissão de dados via método POST, o mesmo não contém um atributo action e, dessa forma, a submissão dos dados é feita para a mesma URL, ou seja, para a raiz de nosso site ('/'), sendo nossa função home_page responsável por atender essa requisição. Desse modo, para darmos andamento na evolução da aplicação precisamos de um novo teste unitário para nos guiar nessa missão. Para isso, acrescentaremos um novo método de teste (test_can_save_a_POST_request- linhas 15 a 17) no nosso arquivo lists/tests.py, conforme ilustrado abaixo:
from django.urls import resolvefrom django.test import TestCasefrom lists.views import home_pageclassHomePageTest(TestCase):deftest_root_url_resolves_to_home_page_view(self): found =resolve('/') self.assertEquals(found.func, home_page)deftest_home_page_returns_correct_html(self): response = self.client.get('/') self.assertTemplateUsed(response, 'home.html')deftest_can_save_a_POST_request(self): response = self.client.post('/', data={'item_text': 'A new list item'}) self.assertIn('A new list item', response.content.decode())
Utilizamos o método self.client.post (linha 16) no caso de teste para enviar uma requisição POST e passamos no argumento data as informações que desejamos que sejam enviadas.
Ao executar o teste obtemos o resultado abaixo, indicando que o item 'A new list item' não foi encontrado (linha 12).
(superlists) tdd@mlp:~/superlists/superlists$pythonmanage.pytestFound3test(s).Creatingtestdatabaseforalias'default'...Systemcheckidentifiednoissues (0 silenced).F..======================================================================FAIL:test_can_save_a_POST_request (lists.tests.HomePageTest)----------------------------------------------------------------------Traceback (most recentcalllast):File"/home/tdd/superlists/superlists/lists/tests.py",line17,intest_can_save_a_POST_requestself.assertIn('A new list item',response.content.decode())AssertionError: 'A new list item' not found in '<html>\n\t<head>\n\t\t<title>To-Do lists</title>\n\t</head>\n\t<body>\n\t\t<h1>Your To-Do list</h1>\n\t\t<form method="POST">\n\t\t\t<input name="item_text" id="id_new_item" placeholder="Enter a to-do item" />\n\t\t\t<input type="hidden" name="csrfmiddlewaretoken" value="n680kiOXpueVd6KpeHXOxrkzAoH2HxLSAO6Y2xJTXVsekFlqlSOkTYbbYMChks7v">\n\t\t</form>\n\t\t<table id="id_list_table">\n\t\t</table>\n\t</body>\n</html>'
----------------------------------------------------------------------Ran3testsin0.012sFAILED (failures=1)Destroyingtestdatabaseforalias'default'...
A correção da aplicação para fazer o teste passar pode ser a inclusão de um simples if no arquivo lists/view.py com um tratamento mínimo, conforme abaixo. Obviamente essa ainda não é a solução que desejamos, com certeza, mas lembre-se que estamos seguindo a risca o que prega o TDD.
from django.http import HttpResponsefrom django.shortcuts import render# Create your views here.defhome_page(request):if (request.method =='POST'):returnHttpResponse(request.POST['item_text'])returnrender(request, 'home.html')
Ao reexecutar os testes temos o resultado de sucesso ilustrado abaixo:
A forma de integrarmos variáveis do Python dentro de templates Django é por meio da notação {{...}}. O objeto presente dentro desses pares de chaves serão convertidos para uma string dentro de nosso template. Desse modo, podemos iniciar as modificações no código de nosso template para aceitar variáveis Python. O exemplo a seguir ilustra o uso da variável {{ new_item_text }} dentro de nosso template do arquivo home.html (linha 12).
Vamos alterar o nosso teste unitário (lists/tests.py) para verificar o uso do nosso template alterado.
from django.urls import resolvefrom django.test import TestCasefrom lists.views import home_pageclassHomePageTest(TestCase):deftest_root_url_resolves_to_home_page_view(self): found =resolve('/') self.assertEquals(found.func, home_page)deftest_home_page_returns_correct_html(self): response = self.client.get('/') self.assertTemplateUsed(response, 'home.html')deftest_can_save_a_POST_request(self): response = self.client.post('/', data={'item_text': 'A new list item'}) self.assertIn('A new list item', response.content.decode()) self.assertTemplateUsed(response, 'home.html')
Ao executar o teste temos o resultado abaixo, ou seja, uma falha pois nosso código com aquela implementação simples não está mais conseguindo enganar nosso teste e, desse modo, podemos melhorar nossa view.
(superlists) tdd@mlp:~/superlists/superlists$pythonmanage.pytestFound3test(s).Creatingtestdatabaseforalias'default'...Systemcheckidentifiednoissues (0 silenced).F..======================================================================FAIL:test_can_save_a_POST_request (lists.tests.HomePageTest)----------------------------------------------------------------------Traceback (most recentcalllast):File"/home/tdd/superlists/superlists/lists/tests.py",line18,intest_can_save_a_POST_requestself.assertTemplateUsed(response,'home.html') File "/home/tdd/.pyenv/versions/superlists/lib/python3.10/site-packages/django/test/testcases.py", line 712, in assertTemplateUsed
self._assert_template_used(template_name,template_names,msg_prefix,count) File "/home/tdd/.pyenv/versions/superlists/lib/python3.10/site-packages/django/test/testcases.py", line 676, in _assert_template_used
self.fail(msg_prefix+"No templates used to render the response")AssertionError:Notemplatesusedtorendertheresponse----------------------------------------------------------------------Ran3testsin0.024sFAILED (failures=1)Destroyingtestdatabaseforalias'default'...
A correção envolve usarmos o template e passarmos os dados que desejamos para ele, conforme ilustrado abaixo na alteração do arquivo lists/view.py. A função render que estamos usando aceita um terceiro parâmetro que consiste de um dicionário de dados que faz o mapeamento de nomes de variáveis do template para os seus respectivos valores.
from django.shortcuts import render# Create your views here.defhome_page(request):returnrender(request, 'home.html', {'new_item_text': request.POST['item_text']} )
Após a alteração, ao reexecutar o teste, passamos a ter a chamada "falha esperada" mas de um modo diferente do que havíamos presenciado até o momento. O teste que estávamos trabalhando passou e o que falhou foi o teste anterior que estava funcionando (observe a linha 5 abaixo).
(superlists) tdd@mlp:~/superlists/superlists$pythonmanage.pytestFound3test(s).Creatingtestdatabaseforalias'default'...Systemcheckidentifiednoissues (0 silenced)..E.======================================================================ERROR:test_home_page_returns_correct_html (lists.tests.HomePageTest)----------------------------------------------------------------------Traceback (most recentcalllast): File "/home/tdd/.pyenv/versions/superlists/lib/python3.10/site-packages/django/utils/datastructures.py", line 84, in __getitem__
list_=super().__getitem__(key)KeyError:'item_text'Duringhandlingoftheaboveexception,anotherexceptionoccurred:Traceback (most recentcalllast):File"/home/tdd/superlists/superlists/lists/tests.py",line12,intest_home_page_returns_correct_htmlresponse=self.client.get('/')File"/home/tdd/.pyenv/versions/superlists/lib/python3.10/site-packages/django/test/client.py",line1049,ingetresponse=super().get(path,data=data,secure=secure,headers=headers,**extra)File"/home/tdd/.pyenv/versions/superlists/lib/python3.10/site-packages/django/test/client.py",line465,ingetreturnself.generic(File"/home/tdd/.pyenv/versions/superlists/lib/python3.10/site-packages/django/test/client.py",line617,ingenericreturnself.request(**r)File"/home/tdd/.pyenv/versions/superlists/lib/python3.10/site-packages/django/test/client.py",line1013,inrequestself.check_exception(response) File "/home/tdd/.pyenv/versions/superlists/lib/python3.10/site-packages/django/test/client.py", line 743, in check_exception
raiseexc_value File "/home/tdd/.pyenv/versions/superlists/lib/python3.10/site-packages/django/core/handlers/exception.py", line 55, in inner
response=get_response(request) File "/home/tdd/.pyenv/versions/superlists/lib/python3.10/site-packages/django/core/handlers/base.py", line 197, in _get_response
response=wrapped_callback(request,*callback_args,**callback_kwargs)File"/home/tdd/superlists/superlists/lists/views.py",line5,inhome_pagereturnrender(request,'home.html',{'new_item_text':request.POST['item_text']} ) File "/home/tdd/.pyenv/versions/superlists/lib/python3.10/site-packages/django/utils/datastructures.py", line 86, in __getitem__
raiseMultiValueDictKeyError(key)django.utils.datastructures.MultiValueDictKeyError:'item_text'----------------------------------------------------------------------Ran3testsin0.083sFAILED (errors=1)Destroyingtestdatabaseforalias'default'...
A correção do código para fazer o teste passar é usarmos a função dict.get do Python para obter os valores a serem passados. O código abaixo mostra a correção. Para saber mais sobre o dict.get podemos consultar a documentação oficial em https://docs.python.org/3/library/stdtypes.html#dict. Basicamente, o método get retorna o valor da chave se a chave for encontrada no dicionário, do contrário, ela retorna o valor padrão que, no nosso exemplo, é o string vazio ('').
from django.shortcuts import render# Create your views here.defhome_page(request):returnrender(request, 'home.html', {'new_item_text': request.POST.get('item_text', '') })
Ao executar os testes unitário novamente após a alteração os mesmos passam com sucesso, conforme mensagem abaixo:
Existe uma forma de melhorarmos um pouco mais essa mensagem de erro exibida. O Python oferece na versão 3.6 em diante o recurso chamado de f-string que permite presceder uma string com a letra f e então fazer uso das chaves para inserir variáveis locais na string. Observe como ficou o trecho de código dos testes funcionais (functional_tests.py) com o uso do recurso de f-string.
... self.assertTrue(any(row.text =='1: Buy peacock feathers'for row in rows), f"New to-do item not appear in table. Content were:\n{table.text}" )...
Após a alteração e reexecução dos testes funcionais, a mensagem de falha aparece como abaixo (linhas 9 e 10):
Entretanto, conseguimos melhorar ainda mais nosso caso de teste e substituir o assertTrue por um assertIn e, gratuitamente, ganharmos a mensagens de erro sem fazer uso do f-string.
...self.assertIn('1: Buy peacock feathers', [row.text for row in rows])...
Após a troca do assert, o resultado da execução fica assim (linha 9):
Como podemos observar a diferença entre o resultado obtido e o esperado é que o teste funcional deseja que o resultado seja enumerado e o retorno ainda não está enumerando os itens retornados. Para conseguirmos ter o teste aprovado podemos fazer essa simples alteração no template (linha 12).
E agora ao reexecutar os testes funcionais conseguimos avançar e parar o self.fail que incluimos propositalmente em nosso teste.
(superlists) tdd@mlp:~/superlists/superlists$pythonfunctional_tests.pyF======================================================================FAIL:test_can_start_a_list_and_retrieve_it_later (__main__.NewVisitorTest)----------------------------------------------------------------------Traceback (most recentcalllast):File"/home/tdd/superlists/superlists/functional_tests.py",line62,intest_can_start_a_list_and_retrieve_it_laterself.fail('Finish the test!')AssertionError:Finishthetest!----------------------------------------------------------------------Ran1testin5.160sFAILED (failures=1)
Podemos então estender mais um pouco o nosso teste funcional e o fizemos complementando as descrições da história entre as linhas 60 a 77. Veja código completo abaixo:
from selenium import webdriverfrom selenium.webdriver.common.keys import Keysfrom selenium.webdriver.common.by import Byimport timeimport unittestclassNewVisitorTest(unittest.TestCase):defsetUp(self): self.browser = webdriver.Firefox()deftearDown(self): self.browser.quit()deftest_can_start_a_list_and_retrieve_it_later(self):# Edith ouviu falar de uma nova aplicação online interessante# para lista de tarefas. Ela decide verificar a homepage self.browser.get("http://localhost:8000")# Ela percebe que o título da página e o cabeçalho mencionam# listas de tarefas (to-do) self.assertIn('To-Do', self.browser.title) header_text = self.browser.find_element(By.TAG_NAME, 'h1').text self.assertIn('To-Do', header_text)# Ela é convidada a inserir um item de tarefa imediatamente inputbox = self.browser.find_element(By.ID, 'id_new_item') self.assertEqual( inputbox.get_attribute('placeholder'),'Enter a to-do item' )# Ela digita "Buy peacock feathers" (Comprar penas de pavão)# em uma nova caixa de texto (o hobby de Edith é fazer iscas# para pesca com fly) inputbox.send_keys('Buy peacock feathers')# Quando ela tecla enter, a página é atualizada, e agora# a página lista "1 - Buy peacock feathers" como um item em # uma lista de tarefas inputbox.send_keys(Keys.ENTER) time.sleep(1) table = self.browser.find_element(By.ID,'id_list_table') rows = table.find_elements(By.TAG_NAME, 'tr') self.assertIn('1: Buy peacock feathers', [row.text for row in rows])# Ainda continua havendo uma caixa de texto convidando-a a # acrescentar outro item. Ela insere "Use peacock feathers # make a fly" (Usar penas de pavão para fazer um fly - # Edith é bem metódica) inputbox = self.browser.find_element(By.ID,'id_new_item') inputbox.send_keys("Use peacock feathers to make a fly") inputbox.send_keys(Keys.ENTER) time.sleep(1)# A página é atualizada novamente e agora mostra os dois# itens em sua lista table = self.browser.find_element(By.ID,'id_list_table') rows = table.find_elements(By.TAG_NAME,'tr') self.assertIn('1: Buy peacock feathers', [row.text for row in rows]) self.assertIn('2: Use peacock feathers to make a fly', [row.text for row in rows])# Edith se pergunta se o site lembrará de sua lista. Então# ela nota que o site gerou um URL único para ela -- há um # pequeno texto explicativo para isso. self.fail('Finish the test!')# Ela acessa essa URL -- sua lista de tarefas continua lá.# Satisfeita, ela volta a dormirif__name__=='__main__': unittest.main()
O problema é que com essa complementação, nossos testes funcionais voltam a falhar uma vez que o código da aplicação não envolve toda a lista de itens enumerada. Na verdade, sequer essa lista é salva para ser recuperada posteriormente.
(superlists) tdd@mlp:~/superlists/superlists$pythonfunctional_tests.pyF======================================================================FAIL:test_can_start_a_list_and_retrieve_it_later (__main__.NewVisitorTest)----------------------------------------------------------------------Traceback (most recentcalllast):File"/home/tdd/superlists/superlists/functional_tests.py",line69,intest_can_start_a_list_and_retrieve_it_laterself.assertIn('1: Buy peacock feathers', [row.text forrowinrows])AssertionError:'1: Buy peacock feathers'notfoundin ['1: Use peacock feathers to make a fly']----------------------------------------------------------------------Ran1testin6.212sFAILED (failures=1)
Antes de continuarmos e alterarmos o código da aplicação, podemos melhorar um pouco mais nosso código funcional removendo a redundância presente nele. Primeiro vamos confirmar essas alterações realizadas até aqui e, em seguida, melhoraremos o nosso caso de teste.,
(superlists) tdd@mlp:~/superlists/superlists$gitcommit-am"Estending user story to deal with a second list item"[master 24e8605] Estending user story to deal with a second list item2fileschanged,14insertions(+),8deletions(-)(superlists) auri@av:~/superlists/superlists$gitpushUsernamefor'https://github.com':aurimrvPasswordfor'https://aurimrv@github.com':Enumeratingobjects:11,done.Countingobjects:100% (11/11), done.Deltacompressionusingupto12threadsCompressingobjects:100% (5/5), done.Writingobjects:100% (6/6), 693 bytes |693.00KiB/s,done.Total6 (delta 4), reused 0 (delta0)remote:Resolvingdeltas:100% (4/4), completed with 4 local objects.Tohttps://github.com/aurimrv/superlists.gitd0698d2..24e8605master ->master
Se observarmos, estamos fazendo uma verificação dos itens da lista mais de uma vez e isso gera redundância no código de testes. Podemos melhorá-lo conforme abaixo.
from selenium import webdriverfrom selenium.webdriver.common.keys import Keysfrom selenium.webdriver.common.by import Byimport timeimport unittestclassNewVisitorTest(unittest.TestCase):defsetUp(self): self.browser = webdriver.Firefox()deftearDown(self): self.browser.quit()# Auxiliary method defcheck_for_row_in_list_table(self,row_text): 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])deftest_can_start_a_list_and_retrieve_it_later(self):# Edith ouviu falar de uma nova aplicação online interessante# para lista de tarefas. Ela decide verificar a homepage self.browser.get("http://localhost:8000")# Ela percebe que o título da página e o cabeçalho mencionam# listas de tarefas (to-do) self.assertIn('To-Do', self.browser.title) header_text = self.browser.find_element(By.TAG_NAME, 'h1').text self.assertIn('To-Do', header_text)# Ela é convidada a inserir um item de tarefa imediatamente inputbox = self.browser.find_element(By.ID, 'id_new_item') self.assertEqual( inputbox.get_attribute('placeholder'),'Enter a to-do item' )# Ela digita "Buy peacock feathers" (Comprar penas de pavão)# em uma nova caixa de texto (o hobby de Edith é fazer iscas# para pesca com fly) inputbox.send_keys('Buy peacock feathers')# Quando ela tecla enter, a página é atualizada, e agora# a página lista "1 - Buy peacock feathers" como um item em # uma lista de tarefas inputbox.send_keys(Keys.ENTER) time.sleep(1) self.check_for_row_in_list_table('1: Buy peacock feathers')# Ainda continua havendo uma caixa de texto convidando-a a # acrescentar outro item. Ela insere "Use peacock feathers # make a fly" (Usar penas de pavão para fazer um fly - # Edith é bem metódica) inputbox = self.browser.find_element(By.ID,'id_new_item') inputbox.send_keys("Use peacock feathers to make a fly") inputbox.send_keys(Keys.ENTER) time.sleep(1)# A página é atualizada novamente e agora mostra os dois# itens em sua lista self.check_for_row_in_list_table('1: Buy peacock feathers') self.check_for_row_in_list_table('2: Use peacock feathers to make a fly')# Edith se pergunta se o site lembrará de sua lista. Então# ela nota que o site gerou um URL único para ela -- há um # pequeno texto explicativo para isso. self.fail('Finish the test!')# Ela acessa essa URL -- sua lista de tarefas continua lá.# Satisfeita, ela volta a dormirif__name__=='__main__': unittest.main()
Observe que criamos o método auxiliar check_for_row_in_list_table (linhas 16 a 20) e removemos a redundância fazendo a chamada a esse método nas linhas 56, 69 e 70. Refatorado nosso caso de teste, vamos confirmar novamente nossas mudanças colocando o código sob controle de versão.
(superlists) tdd@mlp:~/superlists/superlists$ git commit -am "Refactoring the functional testing to eliminate redundancy"
[master eef7e77] Refactoring the functional testing to eliminate redundancy1filechanged,9insertions(+),10deletions(-)(superlists) auri@av:~/superlists/superlists$gitpushUsernamefor'https://github.com':aurimrvPasswordfor'https://aurimrv@github.com':Enumeratingobjects:5,done.Countingobjects:100% (5/5), done.Deltacompressionusingupto12threadsCompressingobjects:100% (3/3), done.Writingobjects:100% (3/3), 452 bytes |452.00KiB/s,done.Total3 (delta 2), reused 0 (delta0)remote:Resolvingdeltas:100% (2/2), completed with 2 local objects.Tohttps://github.com/aurimrv/superlists.git24e8605..eef7e77master ->master