Python - PyTest

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.

Terminal window
$ python3 test.py
Hello

En 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:

Terminal window
$ python3 test.py

Però si hello no és "Hello" perquè t’has equivocat:

msg = "Hello World!"
hello = msg[:3]
assert hello == "Hello"

Veuràs un missatge d’error:

Terminal window
$ python3 test.py
Traceback (most recent call last):
File "/home/box/py/test.py", line 4, in <module>
assert hello == "Hello"
^^^^^^^^^^^^^^^^
AssertionError

Test-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:

Terminal window
$ 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 defined

La 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:

Terminal window
$ python3 test.py
Traceback (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”.

Terminal window
$ python3 test.py
box@python:~/py$ /bin/python3 /home/box/py/test.py
Traceback (most recent call last):
File "/home/box/py/test.py", line 12, in <module>
assert result["nom"] == "Laura"
^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError

El 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”:

Terminal window
$ cd

Crea un projecte test:

Terminal window
uv init test
cd test

Afegeix una dependència amb pytest:

Terminal window
uv add --dev pytest

Creeu un fitxer nou anomenat test_division.py, que conté un test:

def test_division():
assert division(6,3) == 2

Per executar els tests farem servir pytest.

Quins tests s'executen?
Terminal window
uv run pytest

Executa 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.

Terminal window
uv run pytest nom_fitxer

Executa les funcions de test que hi ha en el fitxer nom_fitxer

Terminal window
uv run pytest nom_fitxer::nom_funció

Només executa la funció de test nom_funció que es troba a nom_fitxer

Ho executem:

Terminal window
uv run pytest
assert division(6,3) == 2
E NameError: name 'division' is not defined

El 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) == 2

Ara podem executar la prova i veure que falla.

Terminal window
$ pytest
> assert division(6,3) == 2
E assert None == 2
E + where None = division(6, 3)
test_division.py:6: AssertionError

El 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):
2

I 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) == 2

I la nostra prova passa:

Terminal window
$ pytest
test_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) == 3

I quan provem la nostra funció tenim una petita sorpresa. La prova ha fallat!

Terminal window
$ pytest
E assert 2 == 3
E + where 2 = division(9, 3)
test_division.py:7: AssertionError

Bé, hi ha alguna cosa malament en la nostra funció de divisió. Arreglem doncs la funció de divisió:

def division(a,b):
return a / b

Ara 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.5

Amb 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.

Activitat

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:

  1. Escriure tests que fallin
  2. Aconseguir que el test passi
  3. 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ó:

  1. typename. El nom de la subclasse.

  2. 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 == t2

TODO 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:

Terminal window
$ pytest
FAILED 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:

Terminal window
$ pytest
test_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)
Terminal window
$ pytest
collected 2 items
test_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.

Activitat

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 == expected

Com 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.age

Activitats

Activitat

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 == expected
Activitat

Crea 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 counter

Pendent