8. Prueba y Depuración

Esta sección introduce unos temás básicas en relación a pruebas, reportería y depuración.

8.1 Prueba

8.1.1 Las pruebas son buenas, la depuración apesta

La naturaleza dinámica de Python hace que las pruebas sean de vital importancia para la mayoría de las aplicaciones. No hay un compilador para encontrar sus errores. La única forma de encontrar errores es ejecutar el código y asegurarse de probar todas sus funciones.

8.1.2 Afirmaciones

La assertdeclaración es una verificación interna del programa. Si una expresión no es verdadera, genera una AssertionErrorexcepción.

assert sintaxis de declaración.

assert <expression> [, 'Mensaje de diagnostico']

Por ejemplo.

assert isinstance(10, int), 'Esperaba int'

No debe usarse para verificar la entrada del usuario (es decir, datos ingresados ​​en un formulario web o algo así). Su propósito es más para controles internos e invariantes (condiciones que siempre deben ser verdaderas).

8.1.3 Programación por contrato

También conocido como Diseño por contrato, el uso liberal de afirmaciones es un enfoque para diseñar software. Prescribe que los diseñadores de software deben definir especificaciones de interfaz precisas para los componentes del software.

Por ejemplo, puede poner aserciones en todas las entradas de una función.

def add(x, y):
    assert isinstance(x, int), 'Esperaba int'
    assert isinstance(y, int), 'Esperaba int'
    return x + y

La verificación de las entradas detectará inmediatamente a las personas que llaman que no estén usando los argumentos adecuados.

>>> add(2, 3)
5
>>> add('2', '3')
Traceback (most recent call last):
...
AssertionError: Expected int
>>>

8.1.4 Pruebas en línea

Las afirmaciones también se pueden utilizar para pruebas sencillas.

def add(x, y):
    return x + y

assert add(2,2) == 4

De esta manera, está incluyendo la prueba en el mismo módulo que su código.

Beneficio: si el código está claramente roto, los intentos de importar el módulo fallarán.

Esto no se recomienda para pruebas exhaustivas. Es más una "prueba de humo" básica. ¿La función funciona en algún ejemplo? Si no es así, definitivamente algo está roto.

8.1.5 Módulo unittest

Suponga que tiene algún código.

# simple.py
def add(x, y):
    return x + y

Ahora, suponga que quiere probarlo. Cree un archivo de prueba separado como este.

# test_simple.py
import simple
import unittest

Luego defina una clase de prueba.

# test_simple.py
import simple
import unittest

# Notice that it inherits from unittest.TestCase
class TestAdd(unittest.TestCase):
    ...

La clase de prueba debe heredar unittest.TestCase.

En la clase de prueba, define los métodos de prueba.

# test_simple.py
import simple
import unittest

# Notice that it inherits from unittest.TestCase
class TestAdd(unittest.TestCase):
    def test_simple(self):
        # Test with simple integer arguments
        r = simple.add(2, 2)
        self.assertEqual(r, 5)
    def test_str(self):
        # Test with strings
        r = simple.add('hello', 'world')
        self.assertEqual(r, 'helloworld')

Importante: Cada método debe comenzar con test.

8.1.6 Utilizando unittest

Hay varias afirmaciones integradas que vienen con unittest. Cada uno de ellos afirma algo diferente.

# Afirme que expr es True
self.assertTrue(expr)

# Afirme que x == y
self.assertEqual(x,y)

# Afirme que x != y
self.assertNotEqual(x,y)

# Afirme que x está cerca de y
self.assertAlmostEqual(x,y,places)

# Afirme que callable(arg1,arg2,...) alza una excepcion
self.assertRaises(exc, callable, arg1, arg2, ...)

Esta no es una lista exhaustiva. Hay otras afirmaciones en el módulo.

8.1.7 Corriendo unittest

Para ejecutar las pruebas, convierta el código en un script.

# test_simple.py
...

if __name__ == '__main__':
    unittest.main()

Luego, ejecute Python en el archivo de prueba.

bash % python3 test_simple.py
F.
========================================================
FAIL: test_simple (__main__.TestAdd)
--------------------------------------------------------
Traceback (most recent call last):
  File "testsimple.py", line 8, in test_simple
    self.assertEqual(r, 5)
AssertionError: 4 != 5
--------------------------------------------------------
Ran 2 tests in 0.000s
FAILED (failures=1)

8.1.8 Comentario

Las pruebas unitarias efectivas son un arte y pueden volverse bastante complicadas para aplicaciones grandes.

El unittest módulo tiene una gran cantidad de opciones relacionadas con los corredores de pruebas, la recopilación de resultados y otros aspectos de las pruebas. Consulte la documentación para obtener más detalles.

8.1.9 Herramientas de prueba de terceros

El unittest módulo integrado tiene la ventaja de estar disponible en todas partes: es parte de Python. Sin embargo, a muchos programadores también les parece bastante detallado. Una alternativa popular es pytest. Con pytest, su archivo de prueba se simplifica a algo como lo siguiente:

# test_simple.py import simple

def test_simple():
    assert simple.add(2,2) == 4

def test_str():
    assert simple.add('hello','world') == 'helloworld'

Para ejecutar las pruebas, simplemente escriba un comando como python -m pytest. Descubrirá todas las pruebas y las ejecutará.

Hay mucho más pytestque este ejemplo, pero por lo general es bastante fácil comenzar si decides probarlo.

8.1.10 Ejercicios

En este ejercicio, explorará la mecánica básica del uso del unittestmódulo de Python .

En ejercicios anteriores, escribió un archivo stock.py que contenía una clase Stock. Para este ejercicio, asumió que está usando el código escrito para el Ejercicio 7.9 que involucra propiedades con tipo. Si, por alguna razón, eso no funciona, es posible que desee copiar la solución desde Solutions/7_9 su directorio de trabajo.

Ejercicio 8.1: Escritura de pruebas unitarias

En un archivo separado test_stock.py, escriba un conjunto de pruebas unitarias para la clase Stock. Para comenzar, aquí hay un pequeño fragmento de código que prueba la creación de instancias:

# test_stock.py
import unittest
import stock

class TestStock(unittest.TestCase):
    def test_create(self):
        s = stock.Stock('GOOG', 100, 490.1)
        self.assertEqual(s.name, 'GOOG')
        self.assertEqual(s.shares, 100)
        self.assertEqual(s.price, 490.1)

if __name__ == '__main__':
    unittest.main()

Ejecute sus pruebas unitarias. Debería obtener una salida que se ve así:

.
----------------------------------------------------------------------
Ran 1 tests in 0.000s

OK

Una vez que esté satisfecho de que funciona, escriba pruebas unitarias adicionales que verifiquen lo siguiente:

  • Asegúrese de que la s.cost propiedad devuelva el valor correcto (49010.0)
  • Asegúrese de que el s.sell() método funcione correctamente. Debería disminuir el valor de en s.shares consecuencia.
  • Asegúrese de que el s.sharesatributo no se pueda establecer en un valor que no sea entero.

Para la última parte, deberá verificar que se genere una excepción. Una forma fácil de hacerlo es con un código como este:

class TestStock(unittest.TestCase):
    ...
    def test_bad_shares(self):
         s = stock.Stock('GOOG', 100, 490.1)
         with self.assertRaises(TypeError):
             s.shares = '100'

8.2 Registro, manejo de errores y diagnóstico

Esta sección presenta brevemente el módulo de registro.

módulo de registro

El módulo logging es un módulo de biblioteca estándar para registrar información de diagnóstico. También es un módulo muy grande con muchas funciones sofisticadas. Mostraremos un ejemplo sencillo para ilustrar su utilidad.

Excepciones revisadas

En los ejercicios, escribimos una función parse() que se parecía a esto:

# fileparse.py
def parse(f, types=None, names=None, delimiter=None):
    records = []
    for line in f:
        line = line.strip()
        if not line: continue
        try:
            records.append(split(line,types,names,delimiter))
        except ValueError as e:
            print("Couldn't parse :", line)
            print("Reason :", e)
    return records

Concéntrese en la try-exceptdeclaración. ¿Qué debes hacer en el bloque except?

¿Debería imprimir un mensaje de advertencia?

try:
    records.append(split(line,types,names,delimiter))
except ValueError as e:
    print("Couldn't parse :", line)
    print("Reason :", e)

¿O lo ignoras en silencio?

try:
    records.append(split(line,types,names,delimiter))
except ValueError as e:
    pass

Ninguna solución es satisfactoria porque a menudo desea ambos comportamientos (seleccionables por el usuario).

Usando el registro

El módulo logging puede abordar esto.

# fileparse.py
import logging
log = logging.getLogger(__name__)

def parse(f,types=None,names=None,delimiter=None):
    ...
    try:
        records.append(split(line,types,names,delimiter))
    except ValueError as e:
        log.warning("Couldn't parse : %s", line)
        log.debug("Reason : %s", e)

El código se modifica para emitir mensajes de advertencia o un objeto Logger especial. El creado con logging.getLogger(__name__).

Conceptos básicos de registro

Crea un objeto de registrador.

log = logging.getLogger(name)   # name is a string

Emitir mensajes de registro.

log.critical(message [, args])
log.error(message [, args])
log.warning(message [, args])
log.info(message [, args])
log.debug(message [, args])

Cada método representa un nivel de gravedad diferente.

Todos ellos crean un mensaje de registro formateado. args se utiliza con el %operador para crear el mensaje.

logmsg = message % args # Escrito al log

Configuración de registro

El comportamiento de registro se configura por separado.

# main.py
...

if __name__ == '__main__':
    import logging
    logging.basicConfig(
        filename  = 'app.log',      # archivo de salida
        level     = logging.INFO,   # nivel de salida
    )

Por lo general, esta es una configuración única al inicio del programa. La configuración es independiente del código que realiza las llamadas de registro.

Comentarios

El registro es altamente configurable. Puede ajustar todos los aspectos: archivos de salida, niveles, formatos de mensajes, etc. Sin embargo, el código que usa el registro no tiene que preocuparse por eso.

Ejercicios

Ejercicio 8.2: Agregar registro a un módulo

En fileparse.py, hay algún manejo de errores relacionado con las excepciones causadas por una entrada incorrecta. Se parece a esto:

# fileparse.py
import csv

def parse_csv(lines, select=None, types=None, has_headers=True, delimiter=',', silence_errors=False):
    '''
    Parse a CSV file into a list of records with type conversion.
    '''
    if select and not has_headers:
        raise RuntimeError('select requires column headers')

    rows = csv.reader(lines, delimiter=delimiter)

    # lee el encabezado (si es que existe)
    headers = next(rows) if has_headers else []

    # si columnas especificas fueron seleccionadas, haga indices para filtrar y defina las columnas de salida
    if select:
        indices = [ headers.index(colname) for colname in select ]
        headers = select

    records = []
    for rowno, row in enumerate(rows, 1):
        if not row:     # salte las columnas sin data
            continue

        # si columnas especificas son seleccionadas, seleccionelas
        if select:
            row = [ row[index] for index in indices]

        # aplique la conversión de tipo a la fila
        if types:
            try:
                row = [func(val) for func, val in zip(types, row)]
            except ValueError as e:
                if not silence_errors:
                    print(f"Row {rowno}: Couldn't convert {row}")
                    print(f"Row {rowno}: Reason {e}")
                continue

        # haga un diccionario o una tupla
        if headers:
            record = dict(zip(headers, row))
        else:
            record = tuple(row)
        records.append(record)

    return records

Observe las declaraciones impresas que emiten mensajes de diagnóstico. Reemplazar esas impresiones con operaciones de registro es relativamente simple. Cambie el código así:

# fileparse.py
import csv
import logging
log = logging.getLogger(__name__)

def parse_csv(lines, select=None, types=None, has_headers=True, delimiter=',', silence_errors=False):
    '''
    Parse a CSV file into a list of records with type conversion.
    '''
    if select and not has_headers:
        raise RuntimeError('select requires column headers')

    rows = csv.reader(lines, delimiter=delimiter)

    # lee el encabezado (si es que existe)
    headers = next(rows) if has_headers else []

    # si columnas especificas fueron seleccionadas, haga indices para filtrar y defina las columnas de salida
    if select:
        indices = [ headers.index(colname) for colname in select ]
        headers = select

    records = []
    for rowno, row in enumerate(rows, 1):
        if not row:     # salte las columnas sin data
            continue

        # si columnas especificas son seleccionadas, seleccionelas
        if select:
            row = [ row[index] for index in indices]

        # aplique la conversión de tipo a la fila
        if types:
            try:
                row = [func(val) for func, val in zip(types, row)]
            except ValueError as e:
                if not silence_errors:
                    log.warning("Row %d: Couldn't convert %s", rowno, row)
                    log.debug("Row %d: Reason %s", rowno, e)
                continue

        # haga un diccionario o una tupla
        if headers:
            record = dict(zip(headers, row))
        else:
            record = tuple(row)
        records.append(record)

    return records

Ahora que ha realizado estos cambios, intente utilizar parte de su código en datos incorrectos.

>>> import report
>>> a = report.read_portfolio('Data/missing.csv')
Row 4: Bad row: ['MSFT', '', '51.23']
Row 7: Bad row: ['IBM', '', '70.44']
>>>

Si no hace nada, solo recibirá mensajes de registro para el WARNING nivel y superior. La salida se verá como simples declaraciones impresas. Sin embargo, si configura el módulo de registro, obtendrá información adicional sobre los niveles de registro, el módulo y más. Escriba estos pasos para ver que:

>>> import logging
>>> logging.basicConfig()
>>> a = report.read_portfolio('Data/missing.csv')
WARNING:fileparse:Row 4: Bad row: ['MSFT', '', '51.23']
WARNING:fileparse:Row 7: Bad row: ['IBM', '', '70.44']
>>>

Notará que no ve el resultado de la operación log.debug(). Escriba esto para cambiar el nivel.

>>> logging.getLogger('fileparse').level = logging.DEBUG
>>> a = report.read_portfolio('Data/missing.csv')
WARNING:fileparse:Row 4: Bad row: ['MSFT', '', '51.23']
DEBUG:fileparse:Row 4: Reason: invalid literal for int() with base 10: ''
WARNING:fileparse:Row 7: Bad row: ['IBM', '', '70.44']
DEBUG:fileparse:Row 7: Reason: invalid literal for int() with base 10: ''
>>>

Apague todos, excepto los mensajes de registro más críticos:

>>> logging.getLogger('fileparse').level=logging.CRITICAL
>>> a = report.read_portfolio('Data/missing.csv')
>>>

Ejercicio 8.3: Agregar registro a un programa

Para agregar registro a una aplicación, necesita tener algún mecanismo para inicializar el módulo de registro en el módulo principal. Una forma de hacerlo es incluir un código de configuración que se parezca a esto:

# Este archivo crea configuración basica del modulo logging
# Cambie la configuracion aqui para ajustar la salida de logging
import logging
logging.basicConfig(
    filename = 'app.log',            # Nombre del archivo (omita para usar stderr)
    filemode = 'w',                  # Modo de archivo (use 'a' para adjuntar)
    level    = logging.WARNING,      # Nivel de logging (DEBUG, INFO, WARNING, ERROR, or CRITICAL)
)

Nuevamente, necesitaría poner esto en algún lugar en los pasos de inicio de su programa. Por ejemplo, ¿dónde pondría esto en su programa report.py?

8.3 Depuración

8.3.1 Sugerencias de depuración

Entonces, su programa se bloqueó...

bash % python3 blah.py
Traceback (most recent call last):
  File "blah.py", line 13, in ?
    foo()
  File "blah.py", line 10, in foo
    bar()
  File "blah.py", line 7, in bar
    spam()
  File "blah.py", 4, in spam
    line x.append(3)
AttributeError: 'int' object has no attribute 'append'

¡¿Ahora que?!

8.3.2 Lectura de trazas

La última línea es la causa específica del accidente.

bash % python3 blah.py
Traceback (most recent call last):
  File "blah.py", line 13, in ?
    foo()
  File "blah.py", line 10, in foo
    bar()
  File "blah.py", line 7, in bar
    spam()
  File "blah.py", 4, in spam
    line x.append(3)

# Cause of the crash
AttributeError: 'int' object has no attribute 'append'

Sin embargo, no siempre es fácil de leer o comprender.

SUGERENCIA PRO: Pegue todo el rastreo en Google.

8.3.3 Usando el REPL

Utilice la opción -i para mantener activo Python al ejecutar un script.

bash % python3 -i blah.py
Traceback (most recent call last):
  File "blah.py", line 13, in ?
    foo()
  File "blah.py", line 10, in foo
    bar()
  File "blah.py", line 7, in bar
    spam()
  File "blah.py", 4, in spam
    line x.append(3)
AttributeError: 'int' object has no attribute 'append'
>>>

Conserva el estado de intérprete. Eso significa que puedes andar hurgando después del accidente. Comprobación de valores de variables y otros estados.

8.3.4 Depurar con impresión

print() la depuración es bastante común.

Consejo: asegúrese de utilizar repr()

def spam(x):
    print('DEBUG:', repr(x))
    ...

repr() le muestra una representación precisa de un valor. No es el resultado de impresión agradable.

>>> from decimal import Decimal
>>> x = Decimal('3.4')
# SIN `repr`
>>> print(x)
3.4
# CON `repr`
>>> print(repr(x))
Decimal('3.4')
>>>

8.3.5 El depurador de Python

Puede iniciar manualmente el depurador dentro de un programa.

def some_function():
    ...
    breakpoint()  # Ingrese al depurador (Python 3.7+)
    ...

Esto inicia el depurador en la llamada breakpoint().

En versiones anteriores de Python, hiciste esto. A veces verá esto mencionado en otras guías de depuración.

import pdb
...
pdb.set_trace()  # En vez de `breakpoint()`...

8.3.6 Ejecutar bajo depurador

También puede ejecutar un programa completo en el depurador.

bash % python3 -m pdb someprogram.py

Automáticamente ingresará al depurador antes de la primera declaración. Permitiéndole establecer puntos de interrupción y cambiar la configuración.

Comandos habituales del depurador:

(Pdb) help            # ayuda
(Pdb) w(here)         # imprima el rastreo
(Pdb) d(own)          # hacia arriba
(Pdb) u(p)            # hacia abajo
(Pdb) b(reak) loc     # fije un breakpoint
(Pdb) s(tep)          # ejecute una instruccion
(Pdb) c(ontinue)      # continue la ejecucion
(Pdb) l(ist)          # liste el codigo fuente
(Pdb) a(rgs)          # imprima los argumentos de la funcion
(Pdb) !statement      # ejectue la declaracion

La ubicación de los puntos de interrupción es una de las siguientes.

(Pdb) b 45            # linea 45 en el archivo actual
(Pdb) b file.py:45    # linea 34 en file.py
(Pdb) b foo           # funcion foo() en el archivo actual
(Pdb) b module.foo    # funcion foo() en el modulo

8.3.7 Ejercicios

Ejercicio 8.4: ¿Errores? ¿Qué errores?

Corre. ¡Envíalo!