Un codi ha de tenir un conjunt de proves automatitzades que et permeten dissenyar nou codi i poder modificar codi verificant que tot segueix funcionant correctament.
Introducció
Per escriure un programa has de fer proves que aquest funciona.
🤔Què ens diu l’Uncle Bob sobre els tests?
Assert
Crea el fitxer test.py:
msg = "Hello World!"hello = msg[:5]
print(hello)La manera antiga de provar un codi era fer un “print” i mirar que el resultat que apareix per pantalla és el que esperaves.
$ python3 test.pyHelloEn lloc de fer un print i mirar per pantalla, pots fer un “assert” del resultat esperat:
msg = "Hello World!"hello = msg[:5]
assert hello == "Hello"Si tot va bé, no veuràs res:
$ python3 test.pyPerò si hello no és "Hello" perquè t’has equivocat:
msg = "Hello World!"hello = msg[:3]
assert hello == "Hello"Veuràs un missatge d’error:
$ python3 test.py
Traceback (most recent call last): File "/home/box/py/test.py", line 4, in <module> assert hello == "Hello" ^^^^^^^^^^^^^^^^AssertionErrorTest-Driven development
Modifica el fitxer test.py.
Afegeix una llista de persones:
persons = [ {"nom": "Eva", "cognom": "Vilaregut", "sexe": "Dona", "edat": 19, "altura": 162}, {"nom": "Joan", "cognom": "Sales", "sexe": "Home", "edat": 25, "altura": 173}, {"nom": "Raquel", "cognom": "Viñales", "sexe": "Dona", "edat": 12, "altura": 123}, {"nom": "Esther", "cognom": "Parra", "sexe": "Dona", "edat": 33, "altura": 178}, {"nom": "Miquel", "cognom": "Amorós", "sexe": "Home", "edat": 56, "altura": 166}, {"nom": "Laura", "cognom": "Casademunt", "sexe": "Dona", "edat": 41, "altura": 182},]Escriurem un codi que torni el nom de la persona més alta de la llista.
Enlloc d’escriure el codi que busca la persona més alta, el primer que has de fer és escriure el test:
assert result["nom"] == "Laura"Si executes el codi tindras un error bastant evident:
$ pyhton3 test.py
Traceback (most recent call last): File "/home/box/py/test.py", line 10, in <module> assert result["nom"] == "Laura" ^^^^^^NameError: name 'result' is not definedLa variable result no està definida.
Et pot semblar una mica absurd, però l’art de la programació consisteix a poder avançar en passos molt petits quan és necessari.
L’error t’indica el que has de fer a continuació, definir una variable result on es guardarà el resultat.
result = {}
assert result["nom"] == "Laura"Al execuar el codi ara l’error serà diferent:
$ python3 test.pyTraceback (most recent call last): File "/home/box/py/test.py", line 12, in <module> assert result["nom"] == "Laura" ~~~~~~^^^^^^^KeyError: 'nom'La variable result no té la clau “nom”.
El que podem fer és afegir la primera persona de la llista com a resultat (si el seu nom no és “Laura”!):
result = persons[0]
assert result["nom"] == "Laura"En executar el codi l’error serà que el resultat és l‘“Eva” i no pas la “Laura”.
$ python3 test.pybox@python:~/py$ /bin/python3 /home/box/py/test.pyTraceback (most recent call last): File "/home/box/py/test.py", line 12, in <module> assert result["nom"] == "Laura" ^^^^^^^^^^^^^^^^^^^^^^^^AssertionErrorEl que hem de fer ara és recórrer tots els elements de la llista amb un for:
result = {}for person in persons: result = person
assert result["nom"] == "Laura"Ara el test funciona perquè la “Laura” és l’últim element de la llista!
Que un test funcioni no vol dir que el codi estigui ben implementat.
Modifica la llista de persones perquè la “Laura” no sigui l’última de la llista.
Verifica que el test ja no funciona.
Ja pots modificar el codi per tal que verifiqui que person és més alt que result abans de modificar result:
result = persons[0]for person in persons: if person["altura"] > result["altura"]: result = person
assert result["nom"] == "Laura"I podem optimitzar el codi començant pel segon element de la llista:
result = persons[0]for person in persons[1:]: if person["altura"] > result["altura"]: result = person
assert result["nom"] == "Laura"Pytest
El mòdul pytest et permet gestionar un conjunt de tests.
Ves al teu directori “home”:
$ cdCrea un projecte test:
uv init testcd testAfegeix una dependència amb pytest:
uv add --dev pytestCreeu un fitxer nou anomenat test_division.py, que conté un test:
def test_division(): assert division(6,3) == 2Per executar els tests farem servir pytest.
uv run pytestExecuta TOTS els tests. Cerca els fitxers que comencin per test_ dins del directori.
Per cada fitxer, cerca les funcions que continguin la paraula ‘test’ en el seu nom a l’inici o al final.
Aquestes funcions són les que executa.
uv run pytest nom_fitxerExecuta les funcions de test que hi ha en el fitxer nom_fitxer
uv run pytest nom_fitxer::nom_funcióNomés executa la funció de test nom_funció que es troba a nom_fitxer
Ho executem:
uv run pytest assert division(6,3) == 2E NameError: name 'division' is not definedEl test que acabem d’escriure ni tan sols es compila. Això és prou fàcil d’arreglar.
Què és el mínim que podem fer perquè es compili, encara que no s’executi?
Necessitem una implementació bàsica de division().
De nou farem el mínim de treball possible només per fer que la prova es compila:
def division(a,b): 0
def test_division(): assert division(6,3) == 2Ara podem executar la prova i veure que falla.
$ pytest> assert division(6,3) == 2E assert None == 2E + where None = division(6, 3)
test_division.py:6: AssertionErrorEl nostre marc de proves ha executat el petit fragment de codi amb el qual vam començar i ens vam adonar que tot i que esperàvem “2” com a resultat, vam veure “Cap”. Tristesa.
No, no. El fracàs és progrés. Ara tenim una mesura concreta del fracàs. Això és millor que només saber vagament que estem fallant. El nostre problema de programació s’ha transformat de “fer una divisió” a “fer que aquesta prova funcioni i després fer que la resta de proves funcionin”. Molt més senzill. Un marge molt més petit per a la por. Podem fer que aquesta prova funcioni.
Probablement no us agradarà la solució, però l’objectiu ara mateix no és obtenir la resposta perfecta, l’objectiu és aprovar la prova. Més tard farem el nostre sacrifici a l’altar de la veritat i la bellesa.
Aquí teniu el canvi més petit que podria imaginar que faria que la nostra prova passés:
def division(a,b): 2I la prova falla. Que passa?
En alguns llenguatges una funció sempre retorna un resultat que és l’última sentència, però resultat que Python no i ho hem d’indicar de manera explícita amb return.
Gràcies a escriure la prova primer, l’hem après. Ho podem arreglar:
def division(a,b): return 2
def test_division(): assert division(6,3) == 2I la nostra prova passa:
$ pytesttest_division.py . [100%]==================== 1 passed in 0.01s =======================Però, podem estar segurs que la funció està ben implementada? Escrivim un altra afirmació per provar la funció de divisió perquè qui sap…
def test_division(): assert division(6,3) == 2 assert division(9,3) == 3I quan provem la nostra funció tenim una petita sorpresa. La prova ha fallat!
$ pytestE assert 2 == 3E + where 2 = division(9, 3)test_division.py:7: AssertionErrorBé, hi ha alguna cosa malament en la nostra funció de divisió. Arreglem doncs la funció de divisió:
def division(a,b): return a / bAra tornem a obtenir la barra verda:
============================== 1 passat en 0,01 s =============== ========Et semblen massa petits aquests passos? Recordeu que el TDD no es tracta de fer petits passos, sinó de poder fer petits passos.
Codificaria el dia a dia amb passos tan petits? No. Proveu petits passos amb un exemple de la vostra pròpia elecció. Si podeu fer passos massa petits, segur que podeu fer els passos de la mida adequada. Si només feu passos més grans, mai sabreu si els passos més petits són adequats.
Perquè què passarà si fem aquesta prova?
assert division(5, 2) == 2.5Amb Python el quocient retornat per l’operador / sempre és un float encara que els operands siguin int i el resultat pugui ser representat amb un int.
Però això no és cert en altres idiomes. Per exemple, en Java si dividim dos int el resultat és un int, en aquest cas el resultat seria 2.
I què passa amb division(2,3) i la division(4,0)? Quin resultat esperes?
assert division(2, 3) == ???assert division(4, 0) == ???Amb totes aquestes proves implementades, podeu estar segurs que si alguna cosa canvia a la funció de divisió ho sabràs.
Sí, sé que l’operador / no canviarà el seu comportament.
Enumera tots els casos de test que caldria provar de l’operador divisió (/).
Però les funcions no són tan senzilles, i algunes són molt complexes i depenen d’altres funcions i biblioteques.
En resum, el TDD consta de tres passos:
- Escriure tests que fallin
- Aconseguir que el test passi
- Refactoritzar el codi (millorar-lo)
Aprèn fent tests
A més de provar codi que has fet tu, pots provar codi que n’han fet altres.
Això és important per aprendre com funciona el codi dels altres.
namedtuple() és una funció disponible a collections que et permet crear subclasses de tuples amb noms.
D’aquesta manera el codi és més llegible perquè en lloc d’un índex pots utilitzar un nom per accedir als diferents elements de la tupla mitjançant la notació obj.attr.
Per crear una namedtuple has de proporcionar dos arguments posicionals a la funció:
-
typename. El nom de la subclasse. -
field_names. Una llista dels noms
Crea el fitxer test_tuple.py:
"""Test the Task data type."""from collections import namedtuple
Task = namedtuple('Task',['summary','owner','done','id'])
def test_defaults(): """Using no parameters should invoke defaults.""" t1 = Task() t2 = Task(None, None, False, None) assert t1 == t2TODO Revisar: explicacions més correctes i concises
Suposem que la tupla anomenada es crearà amb valors predeterminats. Perquè no? Python és un llenguatge amigable. Però som desenvolupadors de TDD, així que deixarem que pytest comprovi si aquesta suposició és certa:
$ pytestFAILED test_task.py::test_defaults - TypeError: Task.__new__() missing 4 required positional arguments: 'summary...============================== 1 failed in 0.02s ===============================I estàs veient la temuda barra vermella.
El problema de Python és que és un llenguatge amigable sense escriptura estàtica, de manera que no pot saber el tipus de dada de les nostres tuples i inferir quins valors predeterminats utilitzar.
Tenim però una manera d’assignar valors per defecte a cada atribut amb ‘defaults’
Task = namedtuple('Task',['summary','owner','done','id'])Task.__new__.__defaults__ = (None,None,False,None)Ara tenim la barra verda, legendaria en cançons i història:
$ pytesttest_task.py . [100%]============================== 1 passed in 0.01s ===============================Ara provarem com accedir als membres per nom i no per índex, que és un dels motius principals per utilitzar namedtuple. Afegiu aquesta prova a test_task.py:
def test_member_access(): """Check .field functionality of namedtuple."""
t = Task('buy milk', 'brian') assert t.summary == 'buy milk' assert t.owner == 'brian' assert (t.done,t.id) == (False,None)$ pytestcollected 2 itemstest_task.py .. [100%]============================== 2 passed in 0.01s ===============================La prova passa! Però el més important és que la prova anterior també s’ha provat i podem veure que segueix passant.
Podeu convertir les instàncies de tupla en diccionaris utilitzant ._asdict(). Aquest mètode retorna un diccionari nou que utilitza els noms dels camps com a claus. Les claus del diccionari resultant estan en el mateix ordre que els camps del namedtuple original
def test_as_dict():
Person = namedtuple("Person", "name age height") jane = Person("Jane", 25, 1.75) assert jane._asdict() == ""Aquest test falla ja que la tupla que hem creat és molt diferent a un string buit.
Prova que una namedtuple és pot convertir en un dict:
def test_asdict(): """_asdict() should return a dictionary."""
task = Task('do something', 'tokken', True, 21)
# your code
assert dict == expecteddef test_asdict(): """_asdict() should return a dictionary."""
task = Task('do something', 'tokken', True, 21) dict = task._asdict() expected = { 'summary':'do something', 'owner': 'tokken', 'done': True, 'id': 21 }
assert dict == expectedCom que les tuples amb nom són immutables, pot semblar contraintuïtiu que l’objecte namedtuple ve amb el mètode ._replace(), que us permet substituir valors en una tupla amb nom.
La forma en què això funciona és que es crea una nova tupla.
L’avantatge d’aquest enfocament és que ens permet modificar només valors específics, tot conservant els valors originals
def test_replace():
Person = namedtuple('Person', ['name', 'age', 'location', 'profession'])
mike = Person('Mike', 33, 'Toronto', 'Veterinari') assert mike.age == 33
newMike = mike._replace(age=44) assert newMike.age == 44
assert mike.age == 33 assert mike.age != newMike.ageActivitats
Prova que pots canviar els valors d’una namedtuple amb _replace. En aquest cas volem canviar ‘done’ i ‘id’ de la tupla Task.
def test_replace(): """replace() should change passed in fields."""
task = Task('finish book', 'brian', False)
# your code
assert task == expecteddef test_replace(): """replace() should change passed in fields."""
task = Task('finish book', 'brian', False) task = task._replace(id=10, done=True)
expected = Task('finish book', 'brian', True, 10)
assert task == expectedCrea 3 asserts que provin el funcionament d’aquesta funció.
def adn_count_base(adn, base): """Compta el número d'aparicions de la base dintre de la cadena d'adn""" counter = 0 for x in adn: if x == base: counter += 1
return counterA continuació del mètode principal, poseu aquestes funcions de test.
def test_adn_count(): assert adn_count_base("GATACT","A") == 2 assert adn_count_base("GATACT","G") == 1 assert adn_count_base("ATGGATTAG","T") == 3