¿Cómo evitar errores lógicos en el código, cuando TDD no ayudó?

67

Hace poco escribí un pequeño fragmento de código que indicaría de una manera amigable para los humanos qué tan antiguo es un evento. Por ejemplo, podría indicar que el evento ocurrió "Hace tres semanas" o "Hace un mes" o "Ayer".

Los requisitos eran relativamente claros y este era un caso perfecto para el desarrollo basado en pruebas. Escribí las pruebas una por una, implementando el código para pasar cada prueba, y todo parecía funcionar perfectamente. Hasta que apareció un error en producción.

Aquí está el código relevante:

now = datetime.datetime.utcnow()
today = now.date()
if event_date.date() == today:
    return "Today"

yesterday = today - datetime.timedelta(1)
if event_date.date() == yesterday:
    return "Yesterday"

delta = (now - event_date).days

if delta < 7:
    return _number_to_text(delta) + " days ago"

if delta < 30:
    weeks = math.floor(delta / 7)
    if weeks == 1:
        return "A week ago"

    return _number_to_text(weeks) + " weeks ago"

if delta < 365:
    ... # Handle months and years in similar manner.

Las pruebas comprobaron el caso de un evento que tuvo lugar hoy, ayer, hace cuatro días, hace dos semanas, hace una semana, etc., y el código se creó en consecuencia.

Lo que me perdí es que un evento puede ocurrir un día antes de ayer, mientras que hace un día: por ejemplo, un evento que sucedió hace veintiséis horas sería hace un día, mientras que no exactamente ayer si ahora es a las 1 am Más exactamente, Es un punto algo, pero como el delta es un entero, será solo uno. En este caso, la aplicación muestra "Hace un día", lo que obviamente es inesperado y no se maneja en el código. Se puede arreglar agregando:

if delta == 1:
    return "A day ago"

justo después de calcular el delta .

Si bien la única consecuencia negativa del error es que desperdicié media hora preguntándome cómo podría ocurrir este caso (y creyendo que tiene que ver con zonas horarias, a pesar del uso uniforme de UTC en el código), su presencia es preocupándome Indica que:

  • Es muy fácil cometer un error lógico incluso en un código fuente tan simple.
  • El desarrollo basado en pruebas no ayudó.

También me preocupa que no pueda ver cómo podrían evitarse estos errores. Aparte de pensar más antes de escribir código, la única forma en que puedo pensar es agregar muchas afirmaciones para los casos que creo que nunca ocurrirían (como creía que hace un día es necesariamente ayer), y luego hacer un bucle a cada segundo para los últimos diez años, verificando cualquier violación de aserción, lo que parece demasiado complejo.

¿Cómo podría evitar crear este error en primer lugar?

    
pregunta Arseni Mourzenko 12.07.2018 - 23:39

16 respuestas

57

Estos son los tipos de errores que normalmente se encuentran en el paso refactor de rojo / verde / refactor. ¡No olvides ese paso! Considere un refactor como el siguiente (sin probar):

def pluralize(num, unit):
    if num == 1:
        return unit
    else:
        return unit + "s"

def convert_to_unit(delta, unit):
    factor = 1
    if unit == "week":
        factor = 7 
    elif unit == "month":
        factor = 30
    elif unit == "year":
        factor = 365
    return delta // factor

def best_unit(delta):
    if delta < 7:
        return "day"
    elif delta < 30:
        return "week"
    elif delta < 365:
        return "month"
    else:
        return "year"

def human_friendly(event_date):
    date = event_date.date()
    today = now.date()
    yesterday = today - datetime.timedelta(1)
    if date == today:
        return "Today"
    elif date == yesterday:
        return "Yesterday"
    else:
        delta = (now - event_date).days
        unit = best_unit(delta)
        converted = convert_to_unit(delta, unit)
        pluralized = pluralize(converted, unit)
        return "{} {} ago".format(converted, pluralized)

Aquí ha creado 3 funciones en un nivel de abstracción más bajo que son mucho más cohesivos y más fáciles de probar de forma aislada. Si omitiste un período de tiempo que pretendías, quedaría como un pulgar adolorido en las funciones de ayuda más simples. Además, al eliminar la duplicación, reduce el potencial de error. En realidad, tendría que agregar código para implementar su caso roto.

Otros casos de prueba más sutiles también son más fáciles de recordar cuando se mira una forma reformulada como esta. Por ejemplo, ¿qué debería hacer best_unit si delta es negativo?

En otras palabras, refactorizar no es solo para hacerlo bonito. Facilita a los humanos detectar errores que el compilador no puede.

    
respondido por el Karl Bielefeldt 13.07.2018 - 07:28
148
  

El desarrollo basado en pruebas no ayudó.

Parece que ayudó, es solo que no se hizo una prueba para el escenario "hace un día". Presumiblemente, usted agregó una prueba después de que se encontró este caso; Esto sigue siendo TDD, ya que cuando se encuentran errores, escribe una prueba de unidad para detectar el error y luego corregirlo.

Si olvida escribir una prueba para un comportamiento, TDD no tiene nada que lo ayude; te olvidas de escribir la prueba y, por lo tanto, no escribes la implementación.

    
respondido por el esoterik 12.07.2018 - 23:54
114
  

un evento que sucedió hace veintiséis horas sería hace un día

Las pruebas no ayudarán mucho si un problema está mal definido. Evidentemente, estás mezclando días calendario con días calculados en horas. Si se apega a los días calendario, a las 1 AM, hace 26 horas, no ayer. Y si te limitas a las horas, hace 26 horas se redondea a 1 día, independientemente de la hora.

    
respondido por el Kevin Krumwiede 13.07.2018 - 07:19
38

No puedes. TDD es excelente para protegerte de posibles problemas de los que eres consciente. No ayuda si te encuentras con problemas que nunca has considerado. Lo mejor es que alguien más esté probando el sistema, es posible que encuentre los casos más avanzados que nunca consideró.

Lectura relacionada: ¿Es posible ¿Alcanza el estado de error absoluto a cero para software a gran escala?

    
respondido por el Ian Jacobs 13.07.2018 - 02:38
35

Normalmente tomo dos enfoques que encuentro que pueden ayudar.

Primero, busco los casos de borde. Estos son lugares donde el comportamiento cambia. En su caso, el comportamiento cambia en varios puntos a lo largo de la secuencia de días enteros positivos. Hay un caso de borde en cero, en uno, en siete, etc. Luego escribiría casos de prueba en y alrededor de los casos de borde. Tendría casos de prueba en -1 días, 0 días, 1 horas, 23 horas, 24 horas, 25 horas, 6 días, 7 días, 8 días, etc.

Lo segundo que buscaría es patrones de comportamiento. En su lógica durante semanas, tiene un manejo especial durante una semana. Probablemente tengas una lógica similar en cada uno de tus otros intervalos que no se muestran. Sin embargo, esta lógica está no presente durante días. Lo miraría con sospecha hasta que pudiera explicar de manera verificable por qué ese caso es diferente, o agrego la lógica.

    
respondido por el cbojar 13.07.2018 - 05:36
14

Usted no puede detectar errores lógicos que están presentes en sus requisitos con TDD. Pero aún así, TDD ayuda. Después de todo, encontró el error y agregó un caso de prueba. Pero fundamentalmente, TDD only garantiza que el código se ajuste a su modelo mental. Si su modelo mental es defectuoso, los casos de prueba no los detectarán.

Pero tenga en cuenta que, al corregir el error, los casos de prueba que ya tenía se aseguraron de que no se rompiera el comportamiento de funcionamiento existente. Eso es bastante importante, es fácil corregir un error pero introducir otro.

Para encontrar esos errores de antemano, usualmente intentas usar casos de prueba basados en clase de equivalencia. utilizando ese principio, elegiría un caso de cada clase de equivalencia y luego todos los casos de borde.

Elegiría una fecha de hoy, ayer, hace unos días, hace exactamente una semana y hace varias semanas como los ejemplos de cada clase de equivalencia. Al realizar pruebas de fechas, también se aseguraría de que sus pruebas no no usaron la fecha del sistema, sino que utilizaron una fecha predeterminada para la comparación. Esto también destacaría algunos casos de ventaja: se aseguraría de realizar las pruebas en algún momento arbitrario del día, las ejecutaría directamente después de la medianoche, justo antes de la medianoche e incluso directamente a la medianoche. Esto significa que para cada prueba, habría cuatro veces base contra las que se probó.

Luego, agregaría sistemáticamente casos de borde a todas las demás clases. Tienes la prueba para hoy. Así que agregue un tiempo justo antes y después de que el comportamiento cambie. Lo mismo para ayer. Lo mismo hace una semana, etc.

Lo más probable es que al enumerar todos los casos de borde de manera sistemática y al escribirlos para los casos de prueba, descubra que a su especificación le faltan algunos detalles y la agregue. Tenga en cuenta que el manejo de las fechas es algo que las personas a menudo se equivocan, ya que las personas a menudo se olvidan de escribir sus pruebas para que puedan ejecutarse en diferentes momentos.

Sin embargo, tenga en cuenta que la mayor parte de lo que he escrito tiene poco que ver con TDD. Se trata de anotar las clases de equivalencia y asegurarse de que sus propias especificaciones sean lo suficientemente detalladas sobre ellas. Eso es el proceso con el que minimiza los errores lógicos. TDD simplemente se asegura de que su código se ajuste a su modelo mental.

Llegar a los casos de prueba es difícil . La prueba basada en la clase de equivalencia no es el final de todo, y en algunos casos puede aumentar significativamente el número de casos de prueba. En el mundo real, agregar todas a esas pruebas a menudo no es económicamente viable (aunque en teoría, debería hacerse).

    
respondido por el Polygnome 13.07.2018 - 09:17
12
  

La única forma en que puedo pensar es agregar muchas afirmaciones para los casos que creo que nunca ocurrirían (como creía que hace un día son necesariamente ayer), y luego recorrer cada segundo durante los últimos diez años. , comprobando si hay alguna violación de aserción, lo que parece demasiado complejo.

¿Por qué no? ¡Esto suena como una buena idea!

Agregar contratos (aserciones) al código es una forma bastante sólida de mejorar su corrección. En general, los agregamos como condiciones previas en la entrada de la función y postcondiciones en el retorno de la función. Por ejemplo, podríamos agregar una condición posterior en la que todos los valores devueltos sean cualquiera de la forma "hace [una unidad] anterior" o "hace [número] [unidad] anterior". Cuando se realiza de manera disciplinada, esto lleva a diseño por contrato , y es una de las formas más comunes de escribir un código de alta seguridad.

Críticamente, los contratos no están destinados a ser probados; son tanto especificaciones de su código como sus pruebas. Sin embargo, puede probar a través de los contratos: llame al código en su prueba y, si ninguno de los contratos genera errores, la prueba pasa. Pasar por cada segundo de los últimos diez años es demasiado. Pero podemos aprovechar otro estilo de prueba llamado pruebas basadas en propiedades .

En PBT, en lugar de probar salidas específicas del código, prueba que la salida obedece a alguna propiedad. Por ejemplo, una propiedad de una función reverse() es aquella para cualquier lista l , reverse(reverse(l)) = l . La ventaja de escribir pruebas como esta es que puede hacer que el motor PBT genere unos cientos de listas arbitrarias (y unas pocas patológicas) y compruebe que todas tienen esta propiedad. Si alguno no , el motor "reduce" el caso que falla para encontrar una lista mínima que rompa su código. Parece que estás escribiendo Python, que tiene Hipótesis como el marco principal de PBT.

Por lo tanto, si desea encontrar una buena manera de encontrar casos más complicados que no pueda imaginar, el uso conjunto de contratos y pruebas basadas en propiedades ayudará mucho. Por supuesto, esto no reemplaza las pruebas de unidad de escritura, pero sí lo aumenta, lo que realmente es lo mejor que podemos hacer como ingenieros.

    
respondido por el Hovercouch 13.07.2018 - 07:20
5

Este es un ejemplo en el que agregar un poco de modularidad hubiera sido útil. Si un segmento de código propenso a errores se usa varias veces, es una buena práctica envolverlo en una función si es posible.

def time_ago(delta, unit):
    delta_str = _number_to_text(delta) + " " + unit;
    if delta == 1:
        return delta_str + " ago"
    else:
        return delta_str = "s ago"

now = datetime.datetime.utcnow()
today = now.date()
if event_date.date() == today:
    return "Today"

yesterday = today - datetime.timedelta(1)
if event_date.date() == yesterday:
    return "Yesterday"

delta = (now - event_date).days

if delta < 7:
    return time_ago(delta, "day")

if delta < 30:
    weeks = math.floor(delta / 7)
    return time_ago(weeks, "week")

if delta < 365:
    months = math.floor(delta / 31)
    return time_ago(months, "month")
    
respondido por el Antonio Perez 13.07.2018 - 00:05
5
  

El desarrollo basado en pruebas no ayudó.

TDD funciona mejor como técnica si la persona que escribe las pruebas es adversa. Esto es difícil si no está programado en pares, por lo que otra forma de pensar sobre esto es:

  • No escriba pruebas para confirmar que la función bajo prueba funciona como la realizó. Escribe pruebas que deliberadamente lo rompan.

Este es un arte diferente, que se aplica a escribir el código correcto con o sin TDD, y quizás sea tan complejo (si no más) que escribir código realmente. Es algo que necesita practicar, y es algo para lo que no hay una respuesta única, fácil y simple.

La técnica principal para escribir software robusto es también la técnica básica para comprender cómo escribir pruebas efectivas:

Comprenda las condiciones previas de una función: los estados válidos (es decir, qué suposiciones está haciendo sobre el estado de la clase de la que es función) y los rangos de parámetros de entrada válidos: cada tipo de datos tiene un rango de valores posibles: a un subconjunto del cual será manejado por su función.

Si no hace nada más que probar explícitamente estas suposiciones en la entrada de la función y asegurarse de que se haya registrado o desencadenar una infracción y / o que la función falle sin más manejo, puede saber rápidamente si su software está fallando en la producción. hazlo sólido y tolerante a los errores, y desarrolla tus habilidades de redacción de pruebas adversas.

NB. Hay toda una literatura sobre Condiciones previas y posteriores, Invariantes, etc., junto con bibliotecas que pueden aplicarlas utilizando atributos. Personalmente, no soy un fanático de ir tan formal, pero vale la pena investigarlo.

    
respondido por el Chris Becke 14.07.2018 - 16:51
1

Este es uno de los hechos más importantes sobre el desarrollo de software: es absolutamente imposible escribir código libre de errores.

TDD no te salvará de introducir errores correspondientes a casos de prueba en los que no pensaste. Tampoco le evitará escribir una prueba incorrecta sin darse cuenta, y luego escribir el código incorrecto que pasa para pasar la prueba de errores. Y cada otra técnica de desarrollo de software que se haya creado tiene agujeros similares. Como desarrolladores, somos humanos imperfectos. Al final del día, no hay manera de escribir código 100% libre de errores. Nunca ha sucedido y nunca sucederá.

Esto no quiere decir que debas perder la esperanza. Si bien es imposible escribir código completamente perfecto, es muy posible escribir código que tenga tan pocos errores que aparecen en casos tan raros que el software es extremadamente práctico de usar. Es muy posible escribir software que no muestre un comportamiento de buggy en la práctica .

Pero escribirlo requiere que aceptemos el hecho de que produciremos software con errores. Casi todas las prácticas modernas de desarrollo de software se construyen en algún nivel para evitar que aparezcan errores en primer lugar o para protegernos de las consecuencias de los errores que inevitablemente producimos:

  • La recopilación de requisitos completos nos permite saber cómo se ve un comportamiento incorrecto en nuestro código.
  • Escribir código limpio y cuidadosamente diseñado hace que sea más fácil evitar la introducción de errores en primer lugar y más fácil de corregirlos cuando los identificamos.
  • Las pruebas de escritura nos permiten producir un registro de lo que creemos que serían muchos de los peores errores posibles en nuestro software y probar que evitamos al menos esos errores. TDD produce esas pruebas antes del código, BDD deriva esas pruebas de los requisitos y las pruebas unitarias pasadas de moda producen pruebas después de que se escribe el código, pero todas evitan las peores regresiones en el futuro.
  • Las revisiones por pares significan que cada vez que se cambia el código, al menos dos pares de ojos han visto el código, disminuyendo la frecuencia con la que los errores se deslizan en el maestro.
  • El uso de un rastreador de errores o un rastreador de historias de usuario que trata los errores como historias de usuario significa que cuando aparecen errores, se les realiza un seguimiento y, en última instancia, se resuelven, no se olvidan y se dejan para que los usuarios se pongan constantemente en contacto. li>
  • El uso de un servidor de prueba significa que antes de un lanzamiento importante, cualquier error de "show-stopper" tendrá la oportunidad de aparecer y ser resuelto.
  • El uso del control de versiones significa que en el peor de los casos, donde el código con los principales errores se envía a los clientes, puede realizar una reversión de emergencia y obtener un producto confiable en las manos de sus clientes mientras resuelve las cosas.

La solución definitiva al problema que has identificado no es luchar contra el hecho de que no puedes garantizar que escribirás un código sin errores, sino más bien abrazarlo. Adopte las mejores prácticas de la industria en todas las áreas de su proceso de desarrollo, y entregará constantemente a sus usuarios códigos que, aunque no son del todo perfectos, son lo suficientemente sólidos para el trabajo.

    
respondido por el Kevin 13.07.2018 - 23:20
1

Simplemente no habías pensado en este caso antes y, por lo tanto, no tenías un caso de prueba para ello.

Esto sucede todo el tiempo y es normal. Siempre es una compensación la cantidad de esfuerzo que pone en crear todos los casos de prueba posibles. Puede pasar un tiempo infinito para considerar todos los casos de prueba.

Para un piloto automático de avión, pasaría mucho más tiempo que para una herramienta simple.

A menudo ayuda pensar en los rangos válidos de sus variables de entrada y probar estos límites.

Además, si el evaluador es una persona diferente a la del desarrollador, a menudo se encuentran casos más significativos.

    
respondido por el Simon 14.07.2018 - 08:35
1
  

(y creer que tiene que ver con zonas horarias, a pesar del uso uniforme de UTC en el código)

Ese es otro error lógico en su código para el que aún no tiene una prueba de unidad :): su método devolverá resultados incorrectos para usuarios en zonas horarias no UTC. Debe convertir tanto "ahora" como la fecha del evento a la zona horaria local del usuario antes de calcular.

Ejemplo: En Australia, un evento ocurre a las 9 am hora local. A las 11 am se mostrará como "ayer" porque la fecha UTC ha cambiado.

    
respondido por el Sergey 16.07.2018 - 05:34
0
  • Deja que alguien más escriba las pruebas. De esta manera, alguien que no esté familiarizado con su implementación puede buscar situaciones raras que no haya pensado.

  • Si es posible, inyecte casos de prueba como colecciones. Esto hace que agregar otra prueba sea tan fácil como agregar otra línea como yield return new TestCase(...) . Esto puede ir en la dirección de pruebas exploratorias , automatizando la creación de casos de prueba: "Veamos qué devuelve el código para todos los segundos de hace una semana ".

respondido por el null 14.07.2018 - 12:54
0

Pareces estar bajo la idea errónea de que si todas tus pruebas pasan, no tienes errores. En realidad, si todas sus pruebas pasan, todo el comportamiento conocido es correcto. Aún no sabes si el comportamiento desconocido es correcto o no.

Con suerte, está utilizando la cobertura de código con su TDD. Agregar una nueva prueba para el comportamiento inesperado. Luego puede ejecutar solo la prueba del comportamiento inesperado para ver qué ruta toma realmente a través del código. Una vez que conozca el comportamiento actual, puede hacer un cambio para corregirlo, y cuando todas las pruebas pasen nuevamente, sabrá que lo hizo correctamente.

Esto todavía no significa que su código esté libre de errores, solo que es mejor que antes, y una vez más, todo el comportamiento conocido es correcto.

Usar TDD correctamente no significa que escribirá un código libre de errores, significa que escribirá menos errores. Usted dice:

  

Los requisitos eran relativamente claros

¿Esto significa que el comportamiento de más de un día, pero no de ayer, se especificó en los requisitos? Si no cumplió con un requisito escrito, es su culpa. Si se dio cuenta de que los requisitos estaban incompletos como lo estaba codificando, ¡bueno para usted! Si todos los que trabajaron en los requisitos pasaron por alto ese caso, usted no es peor que los demás. Todo el mundo comete errores, y cuanto más sutiles son, más fácil es perderse. ¡Lo importante es que TDD no evita todos errores!

    
respondido por el CJ Dennis 16.07.2018 - 04:56
0
  

Es muy fácil cometer un error lógico incluso en un código fuente tan simple.

Sí. El desarrollo guiado por pruebas no cambia eso. Aún puede crear errores en el código real, y también en el código de prueba.

  

El desarrollo basado en pruebas no ayudó.

Oh, pero lo hizo! En primer lugar, cuando notó el error, ya tenía instalado el marco de prueba completo y simplemente tuvo que corregir el error en la prueba (y el código real). En segundo lugar, no sabes cuántos errores más habrías tenido si no hubieras hecho TDD al principio.

  

También me preocupa que no pueda ver cómo podrían evitarse estos errores.

No puedes. Ni siquiera la NASA ha encontrado una manera de evitar los errores; Nosotros, los humanos más pequeños, ciertamente tampoco.

  

Aparte de pensar más antes de escribir código,

Eso es una falacia. Uno de los mayores beneficios de TDD es que puede codificar con el pensamiento de menos , porque todas esas pruebas al menos detectan las regresiones bastante bien. Además, incluso, o especialmente con TDD, no se espera que no entregue código libre de errores (o su velocidad de desarrollo simplemente se detendrá).

  

la única forma en que puedo pensar es agregar muchas afirmaciones para los casos que creo que nunca ocurrirían (como creía que hace un día son necesariamente ayer), y luego recorrer cada segundo durante los últimos diez años , comprobando si hay alguna violación de aserción, lo que parece demasiado complejo.

Esto claramente entraría en conflicto con el principio de solo codificar lo que realmente necesita ahora. Pensaste que necesitabas esos casos, y así fue. Era un código no crítico; Como dijiste, no hubo daños, excepto que te lo preguntaste durante 30 minutos.

Para el código de misión crítica, en realidad podría hacer lo que dijo, pero no para su código estándar de todos los días.

  

¿Cómo podría evitar crear este error en primer lugar?

No lo haces. Confías en tus pruebas para encontrar la mayoría de las regresiones; sigue el ciclo de refactor rojo-verde, escribe pruebas antes / durante la codificación real, y (¡importante!) implementa la cantidad mínima necesaria para hacer el cambio rojo-verde (ni más, ni menos). Esto terminará con una gran cobertura de prueba, al menos una positiva.

Cuando, no si, encuentra un error, escribe una prueba para reproducir ese error, y corrija el error con la menor cantidad de trabajo para que dicha prueba pase de rojo a verde.

    
respondido por el AnoE 16.07.2018 - 18:04
-2

Acabas de descubrir que no importa cuánto lo intentes, nunca podrás detectar todos los errores posibles en tu código.

Entonces, lo que esto significa es que incluso intentar detectar todos los errores es un ejercicio inútil, por lo que solo debes usar técnicas como TDD como una forma de escribir mejor código, código que tiene menos errores, no 0 errores.

Eso, a su vez, significa que deberías pasar menos tiempo usando estas técnicas, y gastar ese tiempo ahorrado trabajando en formas alternativas para encontrar los errores que se filtran a través de la red de desarrollo.

alternativas como las pruebas de integración, o un equipo de pruebas, pruebas del sistema, y registro y análisis de esos registros.

Si no puede detectar todos los errores, debe tener una estrategia para mitigar los efectos de los errores que se le escapan. Si tienes que hacer esto de todos modos, poner más esfuerzo en esto tiene más sentido que intentar (en vano) detenerlos en primer lugar.

Después de todo, es inútil gastar una fortuna en las pruebas de escritura y el primer día que le entrega su producto a un cliente se cae, especialmente si no tiene idea de cómo encontrar y resolver ese error. La resolución de errores post-mortem y post-delivery es tan importante y necesita más atención que la que la mayoría de la gente dedica a escribir pruebas unitarias. Guarde las pruebas de unidad para los bits complicados y no intente la perfección por adelantado.

    
respondido por el gbjbaanb 15.07.2018 - 14:54

Lea otras preguntas en las etiquetas