Stai utilizzando pytest
, il che ti offre ampie opzioni per interagire con test falliti. Ti offre opzioni da linea di comando e diversi hook per renderlo possibile. Spiegherò come utilizzare ciascuno e dove è possibile effettuare personalizzazioni per soddisfare le esigenze di debug specifiche.
Vado anche in opzioni più esotiche che ti permetterebbero di saltare del tutto specifiche asserzioni, se davvero senti di doverlo fare.
Gestire le eccezioni, non affermare
Si noti che un test fallito normalmente non ferma il pytest; solo se hai abilitato a dire esplicitamente di uscire dopo un certo numero di errori . Inoltre, i test falliscono perché viene sollevata un'eccezione; assert
solleva AssertionError
ma questa non è l'unica eccezione che farà fallire un test! Vuoi controllare come vengono gestite le eccezioni, non modificarle assert
.
Tuttavia, una mancanza di asserzione si terminare il singolo test. Questo perché una volta sollevata un'eccezione al di fuori di un try...except
blocco, Python scioglie il frame della funzione corrente e non si può tornare indietro.
Non penso che sia quello che vuoi, a giudicare dalla descrizione dei tuoi _assertCustom()
tentativi di ripetere l'asserzione, ma discuterò comunque le tue opzioni più in basso.
Debug post mortem in pytest con pdb
Per le varie opzioni per gestire gli errori in un debugger, inizierò con l'opzione della --pdb
riga di comando , che apre il prompt di debug standard quando un test fallisce (output eluito per brevità):
$ mkdir demo
$ touch demo/__init__.py
$ cat << EOF > demo/test_foo.py
> def test_ham():
> assert 42 == 17
> def test_spam():
> int("Vikings")
> EOF
$ pytest demo/test_foo.py --pdb
[ ... ]
test_foo.py:2: AssertionError
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> entering PDB >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
> /.../demo/test_foo.py(2)test_ham()
-> assert 42 == 17
(Pdb) q
Exit: Quitting debugger
[ ... ]
Con questa opzione, quando un test fallisce pytest avvia una sessione di debug post mortem . Questo è essenzialmente esattamente quello che volevi; per fermare il codice nel punto di un test fallito e aprire il debugger per dare un'occhiata allo stato del test. È possibile interagire con le variabili locali del test, i globali e i locali e i globali di ogni frame nello stack.
Qui pytest ti dà il pieno controllo sull'uscita o meno dopo questo punto: se usi il q
comando quit, anche pytest esce anche dalla corsa, usando c
for continue restituirà il controllo a pytest e il test successivo verrà eseguito.
Utilizzo di un debugger alternativo
Non sei vincolato al pdb
debugger per questo; è possibile impostare un debugger diverso con l' --pdbcls
opzione Qualsiasi implementazione pdb.Pdb()
compatibile funzionerebbe, inclusa l' implementazione del debugger IPython o la maggior parte degli altri debugger Python (il debugger pudb richiede l' -s
utilizzo dello switch o un plug-in speciale ). Lo switch accetta un modulo e una classe, ad es. Per usarlo pudb
puoi usare:
$ pytest -s --pdb --pdbcls=pudb.debugger:Debugger
È possibile utilizzare questa funzione per scrivere la propria classe wrapper Pdb
che ritorni immediatamente immediatamente se l'errore specifico non è qualcosa a cui si è interessati. pytest
Utilizza Pdb()
esattamente come pdb.post_mortem()
fa :
p = Pdb()
p.reset()
p.interaction(None, t)
Qui, t
è un oggetto traceback . Quando p.interaction(None, t)
ritorna, pytest
continua con il test successivo, a meno che non p.quitting
sia impostato su True
(a quel punto pytest quindi esce).
Ecco un'implementazione di esempio che stampa che stiamo rifiutando di eseguire il debug e restituisce immediatamente, a meno che il test non sia stato sollevato ValueError
, salvato come demo/custom_pdb.py
:
import pdb, sys
class CustomPdb(pdb.Pdb):
def interaction(self, frame, traceback):
if sys.last_type is not None and not issubclass(sys.last_type, ValueError):
print("Sorry, not interested in this failure")
return
return super().interaction(frame, traceback)
Quando lo uso con la demo di cui sopra, viene emesso (di nuovo, eluito per brevità):
$ pytest test_foo.py -s --pdb --pdbcls=demo.custom_pdb:CustomPdb
[ ... ]
def test_ham():
> assert 42 == 17
E assert 42 == 17
test_foo.py:2: AssertionError
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> entering PDB >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
Sorry, not interested in this failure
F
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> traceback >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
def test_spam():
> int("Vikings")
E ValueError: invalid literal for int() with base 10: 'Vikings'
test_foo.py:4: ValueError
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> entering PDB >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
> /.../test_foo.py(4)test_spam()
-> int("Vikings")
(Pdb)
Gli introspetti di cui sopra sys.last_type
per determinare se l'errore è "interessante".
Tuttavia, non posso davvero raccomandare questa opzione a meno che tu non voglia scrivere il tuo debugger usando tkInter o qualcosa di simile. Si noti che questa è una grande impresa.
Errori di filtraggio; scegli e scegli quando aprire il debugger
Il livello successivo è il debugging pytest e gli hook di interazione ; questi sono punti di aggancio per le personalizzazioni del comportamento, per sostituire o migliorare il modo in cui pytest normalmente gestisce cose come la gestione di un'eccezione o l'inserimento del debugger tramite pdb.set_trace()
o breakpoint()
(Python 3.7 o versioni successive).
L'implementazione interna di questo hook è responsabile anche della stampa del >>> entering PDB >>>
banner sopra, quindi l'utilizzo di questo hook per impedire l'esecuzione del debugger significa che non vedrai affatto questo output. Puoi avere il tuo hook personale quindi delegarlo al hook originale quando un errore di test è "interessante", e quindi i fallimenti del test di filtro sono indipendenti dal debugger che stai usando! Puoi accedere all'implementazione interna accedendo per nome ; il plugin hook interno per questo è chiamato pdbinvoke
. Per impedirne l'esecuzione è necessario annullare la registrazione ma salvare un riferimento, possiamo chiamarlo direttamente secondo necessità.
Ecco una esempio di implementazione di un tale hook; puoi metterlo in una delle posizioni in cui sono caricati i plugin ; L'ho inserito demo/conftest.py
:
import pytest
@pytest.hookimpl(trylast=True)
def pytest_configure(config):
# unregister returns the unregistered plugin
pdbinvoke = config.pluginmanager.unregister(name="pdbinvoke")
if pdbinvoke is None:
# no --pdb switch used, no debugging requested
return
# get the terminalreporter too, to write to the console
tr = config.pluginmanager.getplugin("terminalreporter")
# create or own plugin
plugin = ExceptionFilter(pdbinvoke, tr)
# register our plugin, pytest will then start calling our plugin hooks
config.pluginmanager.register(plugin, "exception_filter")
class ExceptionFilter:
def __init__(self, pdbinvoke, terminalreporter):
# provide the same functionality as pdbinvoke
self.pytest_internalerror = pdbinvoke.pytest_internalerror
self.orig_exception_interact = pdbinvoke.pytest_exception_interact
self.tr = terminalreporter
def pytest_exception_interact(self, node, call, report):
if not call.excinfo. errisinstance(ValueError):
self.tr.write_line("Sorry, not interested!")
return
return self.orig_exception_interact(node, call, report)
Il plug-in sopra utilizza il TerminalReporter
plug-in interno per scrivere le righe sul terminale; questo rende l'output più pulito quando si utilizza il formato di stato del test compatto predefinito e consente di scrivere elementi sul terminale anche con l'acquisizione dell'output abilitata.
L'esempio registra l'oggetto plugin con pytest_exception_interact
hook tramite un altro hook, pytest_configure()
ma assicurandosi che funzioni abbastanza tardi (usando @pytest.hookimpl(trylast=True)
) per poter annullare la registrazione del pdbinvoke
plugin interno . Quando viene chiamato l'hook, l'esempio verifica l' call.exceptinfo
oggetto ; puoi anche controllare il nodo o anche il rapporto .
Con il codice di esempio sopra inserito demo/conftest.py
, l' test_ham
errore di test viene ignorato, solo l' test_spam
errore di test, che genera ValueError
, provoca l'apertura del prompt di debug:
$ pytest demo/test_foo.py --pdb
[ ... ]
demo/test_foo.py F
Sorry, not interested!
demo/test_foo.py F
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> traceback >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
def test_spam():
> int("Vikings")
E ValueError: invalid literal for int() with base 10: 'Vikings'
demo/test_foo.py:4: ValueError
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> entering PDB >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
> /.../demo/test_foo.py(4)test_spam()
-> int("Vikings")
(Pdb)
Per reiterare, l'approccio sopra ha l'ulteriore vantaggio di poterlo combinare con qualsiasi debugger che funziona con pytest , incluso pudb, o il debugger IPython:
$ pytest demo/test_foo.py --pdb --pdbcls=IPython.core.debugger:Pdb
[ ... ]
demo/test_foo.py F
Sorry, not interested!
demo/test_foo.py F
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> traceback >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
def test_spam():
> int("Vikings")
E ValueError: invalid literal for int() with base 10: 'Vikings'
demo/test_foo.py:4: ValueError
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> entering PDB >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
> /.../demo/test_foo.py(4)test_spam()
1 def test_ham():
2 assert 42 == 17
3 def test_spam():
----> 4 int("Vikings")
ipdb>
Ha anche molto più contesto su quale test è stato eseguito (tramite l' node
argomento) e l'accesso diretto all'eccezione sollevata (tramite l' call.excinfo
ExceptionInfo
istanza).
Si noti che specifici plug-in di debugger pytest (come pytest-pudb
o pytest-pycharm
) registrano il proprio pytest_exception_interact
hooksp. Un'implementazione più completa dovrebbe passare in rassegna tutti i plug-in nel gestore dei plug-in per sostituire automaticamente i plug-in arbitrari, utilizzando config.pluginmanager.list_name_plugin
e hasattr()
testando ogni plug-in.
Far scomparire del tutto i fallimenti
Mentre questo ti dà il pieno controllo sul debug del test fallito, questo lascia comunque il test come fallito anche se hai scelto di non aprire il debugger per un dato test. Se si vuole fare errori andare via del tutto, si può fare uso di un gancio diverso: pytest_runtest_call()
.
Quando pytest esegue i test, eseguirà il test tramite l'hook sopra, che dovrebbe restituire None
o sollevare un'eccezione. Da questo viene creato un rapporto, facoltativamente viene creata una voce di registro e se il test ha esito negativo, pytest_exception_interact()
viene chiamato l' hook di cui sopra . Quindi tutto ciò che devi fare è cambiare il risultato che questo hook produce; invece di un'eccezione, non dovrebbe assolutamente restituire nulla.
Il modo migliore per farlo è usare un wrapper . I wrapper hook non devono svolgere il vero lavoro, ma hanno la possibilità di modificare ciò che accade al risultato di un hook. Tutto quello che devi fare è aggiungere la riga:
outcome = yield
nell'implementazione del wrapper hook e ottieni l'accesso al risultato hook , inclusa l'eccezione di test tramite outcome.excinfo
. Questo attributo è impostato su una tupla di (tipo, istanza, traceback) se nel test è stata sollevata un'eccezione. In alternativa, è possibile chiamare outcome.get_result()
e utilizzare la try...except
gestione standard .
Quindi, come si fa a superare un test fallito? Hai 3 opzioni di base:
- È possibile contrassegnare il test come errore previsto chiamando
pytest.xfail()
il wrapper.
- È possibile contrassegnare l'elemento come ignorato , il che finge che il test non sia mai stato eseguito in primo luogo, chiamando
pytest.skip()
.
- È possibile rimuovere l'eccezione, utilizzando il
outcome.force_result()
metodo ; imposta qui il risultato su un elenco vuoto (ovvero: l'hook registrato non ha prodotto altro che None
) e l'eccezione viene cancellata del tutto.
Quello che usi dipende da te. Assicurati di controllare prima il risultato per i test saltati e previsti, poiché non è necessario gestire quei casi come se il test fallisse. Puoi accedere alle eccezioni speciali sollevate da queste opzioni tramite pytest.skip.Exception
e pytest.xfail.Exception
.
Ecco un'implementazione di esempio che contrassegna i test non riusciti che non generano ValueError
, come saltati :
import pytest
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_call(item):
outcome = yield
try:
outcome.get_result()
except (pytest.xfail.Exception, pytest.skip.Exception, pytest.exit.Exception):
raise # already xfailed, skipped or explicit exit
except ValueError:
raise # not ignoring
except (pytest.fail.Exception, Exception):
# turn everything else into a skip
pytest.skip("[NOTRUN] ignoring everything but ValueError")
Quando inserito conftest.py
nell'output diventa:
$ pytest -r a demo/test_foo.py
============================= test session starts =============================
platform darwin -- Python 3.8.0, pytest-3.10.0, py-1.7.0, pluggy-0.8.0
rootdir: ..., inifile:
collected 2 items
demo/test_foo.py sF [100%]
=================================== FAILURES ===================================
__________________________________ test_spam ___________________________________
def test_spam():
> int("Vikings")
E ValueError: invalid literal for int() with base 10: 'Vikings'
demo/test_foo.py:4: ValueError
=========================== short test summary info ============================
FAIL demo/test_foo.py::test_spam
SKIP [1] .../demo/conftest.py:12: [NOTRUN] ignoring everything but ValueError
===================== 1 failed, 1 skipped in 0.07 seconds ======================
Ho usato la -r a
bandiera per rendere più chiaro che è test_ham
stato saltato ora.
Se si sostituisce la pytest.skip()
chiamata con pytest.xfail("[XFAIL] ignoring everything but ValueError")
, il test viene contrassegnato come errore previsto:
[ ... ]
XFAIL demo/test_foo.py::test_ham
reason: [XFAIL] ignoring everything but ValueError
[ ... ]
e usando lo outcome.force_result([])
contrassegna come passato:
$ pytest -v demo/test_foo.py # verbose to see individual PASSED entries
[ ... ]
demo/test_foo.py::test_ham PASSED [ 50%]
Dipende da te quale ritieni più adatto al tuo caso d'uso. Per skip()
e xfail()
ho imitato il formato del messaggio standard (con il prefisso [NOTRUN]
o [XFAIL]
), ma sei libero di utilizzare qualsiasi altro formato di messaggio che desideri.
In tutti e tre i casi pytest non aprirà il debugger per i test il cui risultato è stato modificato con questo metodo.
Modifica delle singole affermazioni
Se vuoi modificare i assert
test all'interno di un test , ti stai preparando per molto più lavoro. Sì, questo è tecnicamente possibile, ma solo riscrivendo il codice che Python eseguirà in fase di compilazione .
Quando lo usi pytest
, questo è già stato fatto . Pytest riscrive le assert
dichiarazioni per darti più contesto quando le tue affermazioni falliscono ; vedere questo post del blog per una buona panoramica di ciò che viene fatto e del _pytest/assertion/rewrite.py
codice sorgente . Si noti che quel modulo è lungo oltre 1k linee e richiede di capire come funzionano gli alberi di sintassi astratti di Python . In tal caso, è possibile eseguire il monkeypatch di quel modulo per aggiungere le proprie modifiche lì, incluso circondare il assert
con un try...except AssertionError:
gestore.
Tuttavia , non è possibile semplicemente disabilitare o ignorare le asserzioni in modo selettivo, poiché le istruzioni successive potrebbero facilmente dipendere dallo stato (disposizioni specifiche dell'oggetto, set di variabili, ecc.) Da cui un'asserzione ignorata doveva proteggere. Se un'asserzione verifica che foo
non lo è None
, quindi un'asserzione successiva si basa foo.bar
sull'esistenza, quindi ti imbatterai semplicemente in un AttributeError
lì, ecc. Attenersi al rilancio dell'eccezione, se è necessario seguire questa strada.
Non approfondirò ulteriormente la riscrittura asserts
qui, poiché non credo che valga la pena perseguire questo obiettivo, non considerando la quantità di lavoro richiesto e con il debug post mortem che consente di accedere allo stato del test al punto di errore di asserzione comunque .
Nota che se vuoi farlo, non devi usare eval()
(che non funzionerebbe comunque, assert
è un'istruzione, quindi dovresti usare exec()
invece), né dovresti eseguire l'asserzione due volte (che può causare problemi se l'espressione utilizzata nello stato di asserzione è stata modificata). Dovresti invece incorporare il ast.Assert
nodo all'interno di un ast.Try
nodo e collegare un gestore di eccezione che utilizza un ast.Raise
nodo vuoto per sollevare nuovamente l'eccezione rilevata.
Utilizzo del debugger per saltare le dichiarazioni di asserzione.
Il debugger Python in realtà ti consente di saltare le istruzioni , usando il comando j
/jump
. Se si sa in anticipo che una specifica asserzione vi fallire, è possibile utilizzare questo per bypass esso. È possibile eseguire i test con --trace
, che apre il debugger all'inizio di ogni test , quindi emettere un j <line after assert>
per saltarlo quando il debugger viene messo in pausa appena prima dell'asserzione.
Puoi persino automatizzare questo. Utilizzando le tecniche di cui sopra è possibile creare un plug-in di debugger personalizzato
- usa l'
pytest_testrun_call()
hook per catturare l' AssertionError
eccezione
- estrae il numero di riga "offensivo" dal traceback, e forse con alcune analisi del codice sorgente determina i numeri di riga prima e dopo l'asserzione richiesta per eseguire un salto riuscito
- esegue nuovamente il test , ma questa volta utilizzando una
Pdb
sottoclasse che imposta un punto di interruzione sulla linea prima dell'asserzione ed esegue automaticamente un salto al secondo quando il punto di interruzione viene colpito, seguito da un c
proseguimento.
Oppure, invece di attendere il fallimento di un'asserzione, è possibile automatizzare l'impostazione dei punti di interruzione per ciascuno assert
trovato in un test (utilizzando nuovamente l'analisi del codice sorgente, è possibile estrarre banalmente i numeri di riga per i ast.Assert
nodi in un AST del test), eseguire il test affermato usando i comandi con script debugger e usare il jump
comando per saltare l'asserzione stessa. Dovresti fare un compromesso; eseguire tutti i test con un debugger (che è lento in quanto l'interprete deve chiamare una funzione di traccia per ogni istruzione) o applicarlo solo ai test falliti e pagare il prezzo di rieseguire quei test da zero.
Un tale plugin sarebbe molto impegnativo da creare, non scriverò un esempio qui, in parte perché non si adatterebbe comunque in una risposta, e in parte perché non penso che valga la pena . Avrei solo aperto il debugger e fatto il salto manualmente. Un'asserzione non riuscita indica un bug nel test stesso o nel codice sotto test, quindi puoi anche concentrarti solo sul debug del problema.