¿TDD hace redundante la programación defensiva?


104

Hoy tuve una discusión interesante con un colega.

Soy un programador defensivo. Creo que siempre debe respetarse la regla " una clase debe garantizar que sus objetos tengan un estado válido cuando interactúan desde fuera de la clase ". La razón de esta regla es que la clase no sabe quiénes son sus usuarios y que previsiblemente debería fallar cuando interactúa de manera ilegal. En mi opinión, esa regla se aplica a todas las clases.

En la situación específica en la que tuve una discusión hoy, escribí un código que valida que los argumentos para mi constructor son correctos (por ejemplo, un parámetro entero debe ser> 0) y si no se cumple la condición previa, se genera una excepción. Mi colega, por otro lado, cree que dicha verificación es redundante, porque las pruebas unitarias deben detectar cualquier uso incorrecto de la clase. Además, él cree que las validaciones de programación defensiva también deben ser probadas por unidad, por lo que la programación defensiva agrega mucho trabajo y, por lo tanto, no es óptima para TDD.

¿Es cierto que TDD puede reemplazar la programación defensiva? ¿Es la validación de parámetros (y no me refiero a la entrada del usuario) innecesaria como consecuencia? ¿O las dos técnicas se complementan entre sí?


120
Usted entrega su biblioteca completamente probada sin controles de constructor a un cliente para que la use, y rompen el contrato de la clase. ¿De qué sirven esas pruebas unitarias ahora?
Robert Harvey

42
OMI es al revés. La programación defensiva, las condiciones previas y favorables adecuadas y un sistema de tipo rico hacen que las pruebas sean redundantes.
cabeza de jardín

37
¿Puedo publicar una respuesta que solo diga "Dios mío"? La programación defensiva protege el sistema en tiempo de ejecución. Las pruebas verifican todas las posibles condiciones de tiempo de ejecución en las que el probador puede pensar, incluidos los argumentos no válidos pasados ​​a los constructores y otros métodos. Las pruebas, si se completan, confirmarán que el comportamiento de tiempo de ejecución será el esperado, incluidas las excepciones apropiadas lanzadas o otro comportamiento intencional que tiene lugar cuando se pasan argumentos no válidos. Pero las pruebas no hacen nada para proteger el sistema en tiempo de ejecución.
Craig

16
"las pruebas unitarias deberían detectar cualquier uso incorrecto de la clase" - uh, ¿cómo? Las pruebas unitarias le mostrarán el comportamiento dado los argumentos correctos, y cuando se dan argumentos incorrectos; no pueden mostrarle todos los argumentos que se le darán alguna vez.
OJFord

34
No creo haber visto un mejor ejemplo de cómo el pensamiento dogmático sobre el desarrollo de software puede llevar a conclusiones perjudiciales.
sdenham

Respuestas:


196

Eso es ridículo. TDD obliga al código a pasar las pruebas y obliga a todo el código a tener algunas pruebas a su alrededor. No evita que sus consumidores llamen incorrectamente al código, ni mágicamente evita que los programadores pierdan casos de prueba.

Ninguna metodología puede obligar a los usuarios a usar el código correctamente.

No es un ligero argumento para afirmar que si lo hizo perfectamente TDD que habría cogido su cheque> 0 en un caso de prueba, antes de la aplicación, y se dirigió a esto - probablemente mediante la adición de que el cheque. Pero si hiciste TDD, tu requisito (> 0 en el constructor) aparecería primero como un caso de prueba que falla. Por lo tanto, le da la prueba después de agregar su cheque.

También es razonable probar algunas de las condiciones defensivas (agregó lógica, ¿por qué no querría probar algo tan fácilmente comprobable?). No estoy seguro de por qué parece estar en desacuerdo con esto.

¿O las dos técnicas se complementan entre sí?

TDD desarrollará las pruebas. La implementación de la validación de parámetros los hará pasar.


77
No estoy en desacuerdo con la creencia de que la validación de la condición previa debe probarse, pero no estoy de acuerdo con la opinión de mi colega de que el trabajo adicional causado por la necesidad de probar la validación de la condición previa es un argumento para no crear la validación de la condición previa en primer lugar sitio. He editado mi publicación para aclarar.
user2180613

20
@ user2180613 Cree una prueba que pruebe que una falla de la condición previa se maneja adecuadamente: ahora agregar la verificación no es un trabajo "extra", es un trabajo que TDD requiere para que la prueba sea verde. Si la opinión de su colega es que debe hacer la prueba, observar que falla y luego y solo luego implementar la verificación de precondición, entonces podría tener un punto desde el punto de vista purista de TDD. Si está diciendo que ignore completamente el cheque, entonces está siendo tonto. No hay nada en TDD que diga que no puede ser proactivo al escribir pruebas para posibles modos de falla.
RM

44
@RM No está escribiendo una prueba para probar la verificación de precondición. Está escribiendo una prueba para probar el comportamiento correcto esperado del código llamado. Las comprobaciones previas son, desde el punto de vista de la prueba, un detalle de implementación opaco que garantiza el comportamiento correcto. Si piensa en una mejor manera de garantizar el estado correcto en el código llamado, hágalo de esa manera en lugar de utilizar una verificación de condición previa tradicional. La prueba confirmará si tuvo éxito o no, y aún no sabrá ni le importará cómo lo hizo.
Craig

@ user2180613 Esa es una justificación asombrosa: D si su objetivo al escribir software es reducir la cantidad de pruebas que necesita para crear y ejecutar, no escriba ningún software: ¡cero pruebas!
Gusdor

3
Esa última oración de esta respuesta lo clava.
Robert Grant

32

La programación defensiva y las pruebas unitarias son dos formas diferentes de detectar errores y cada una tiene diferentes puntos fuertes. Usar solo una forma de detectar errores hace que sus mecanismos de detección de errores sean frágiles. El uso de ambos detectará errores que uno u otro podrían haber pasado por alto, incluso en el código que no es una API pública; por ejemplo, alguien puede haber olvidado agregar una prueba unitaria para datos no válidos pasados ​​a la API pública. Verificar todo en los lugares apropiados significa más posibilidades de detectar el error.

En seguridad de la información, esto se llama Defensa en profundidad. Tener múltiples capas de defensa asegura que si una falla, todavía hay otras para atraparla.

Su colega tiene razón en una cosa: debe probar sus validaciones, pero esto no es "trabajo innecesario". Es lo mismo que probar cualquier otro código, desea asegurarse de que todos los usos, incluso los inválidos, tengan un resultado esperado.


¿Es correcto decir que la validación de parámetros es una forma de validación previa y las pruebas unitarias son validaciones posteriores a la condición, por lo que se complementan entre sí?
user2180613

1
"Es lo mismo que probar cualquier otro código, desea asegurarse de que todos los usos, incluso los inválidos, tengan un resultado esperado". Esta. Ningún código debería pasar cuando su entrada pasada no fue diseñada para manejar. Esto viola el principio de "falla rápida" y puede hacer que la depuración sea una pesadilla.
jpmc26

@ user2180613: en realidad no, pero más que las pruebas unitarias verifican las condiciones de falla que el desarrollador espera, mientras que las técnicas de programación defensiva verifican las condiciones que el desarrollador no espera. Las pruebas unitarias se pueden usar para validar las condiciones previas (mediante el uso de un objeto simulado inyectado a la persona que llama que verifica la condición previa).
Periata Breatta

1
@ jpmc26 Sí, el fallo es el "resultado esperado" para la prueba. Usted prueba para mostrar que falla, en lugar de exhibir en silencio un comportamiento indefinido (inesperado).
KRyan

66
TDD detecta errores en su propio código, la programación defensiva detecta errores en el código de otras personas. TDD, por lo tanto, puede ayudar a garantizar que esté lo suficientemente a la defensiva :)
jwenting

30

TDD no reemplaza absolutamente la programación defensiva. En cambio, puede usar TDD para asegurarse de que todas las defensas estén en su lugar y funcionen como se espera.

En TDD, se supone que no debe escribir código sin escribir primero una prueba: siga religiosamente el ciclo rojo-verde-refactor. Eso significa que si desea agregar validación, primero escriba una prueba que requiera esta validación. Llame al método en cuestión con números negativos y con cero, y espere que arroje una excepción.

Además, no olvide el paso de "refactorización". Si bien el TDD está basado en pruebas , esto no significa solo pruebas . Aún debe aplicar un diseño adecuado y escribir código sensible. Escribir código defensivo es un código sensible, porque hace que las expectativas sean más explícitas y su código en general sea más robusto: detectar posibles errores temprano los hace más fáciles de depurar.

¿Pero no se supone que debemos usar pruebas para localizar errores? Las afirmaciones y las pruebas son complementarias. Una buena estrategia de prueba combinará varios enfoques para asegurarse de que el software sea robusto. Solo las pruebas unitarias o solo las pruebas de integración o solo las afirmaciones en el código son insatisfactorias, necesita una buena combinación para alcanzar un grado suficiente de confianza en su software con un esfuerzo aceptable.

Luego hay un gran malentendido conceptual de su compañero de trabajo: las pruebas unitarias nunca pueden evaluar los usos de su clase, solo que la clase en sí misma funciona como se espera de forma aislada. Usaría pruebas de integración para verificar que la interacción entre varios componentes funciona, pero la explosión combinatoria de posibles casos de prueba hace que sea imposible probar todo. Por lo tanto, las pruebas de integración deberían restringirse a un par de casos importantes. Las pruebas más detalladas que también cubren casos extremos y casos de error son más adecuadas para pruebas unitarias.


16

Las pruebas están ahí para apoyar y garantizar la programación defensiva.

La programación defensiva protege la integridad del sistema en tiempo de ejecución.

Las pruebas son (en su mayoría estáticas) herramientas de diagnóstico. En tiempo de ejecución, sus pruebas no están a la vista. Son como andamios utilizados para levantar una pared de ladrillo o una cúpula de roca. No deja partes importantes fuera de la estructura porque tiene un andamio que lo sostiene durante la construcción. Tiene un andamio sosteniéndolo durante la construcción para facilitar la colocación de todas las piezas importantes.

EDITAR: una analogía

¿Qué pasa con una analogía con los comentarios en código?

Los comentarios tienen su propósito, pero pueden ser redundantes o incluso perjudiciales. Por ejemplo, si pone conocimiento intrínseco sobre el código en los comentarios , luego cambia el código, los comentarios se vuelven irrelevantes en el mejor de los casos y dañinos en el peor.

Supongamos que pone mucho conocimiento intrínseco de su base de código en las pruebas, como el Método A no puede ser nulo y el argumento del Método B debe serlo > 0. Entonces el código cambia. Nulo está bien para A ahora, y B puede tomar valores tan pequeños como -10. Las pruebas existentes ahora son funcionalmente incorrectas, pero continuarán pasando.

Sí, debe actualizar las pruebas al mismo tiempo que actualiza el código. También debe actualizar (o eliminar) los comentarios al mismo tiempo que actualiza el código. Pero todos sabemos que estas cosas no siempre suceden y que se cometen errores.

Las pruebas verifican el comportamiento del sistema. Ese comportamiento real es intrínseco al sistema en sí, no intrínseco a las pruebas.

¿Qué podría salir mal?

El objetivo con respecto a las pruebas es pensar en todo lo que podría salir mal, escribir una prueba que verifique el comportamiento correcto, luego elaborar el código de tiempo de ejecución para que pase todas las pruebas.

Lo que significa que la programación defensiva es el punto .

TDD impulsa la programación defensiva, si las pruebas son exhaustivas.

Más pruebas, más programación defensiva

Cuando inevitablemente se encuentran errores, se escriben más pruebas para modelar las condiciones que manifiestan el error. Luego, el código se corrige, con código para hacer que esas pruebas pasen, y las nuevas pruebas permanecen en el conjunto de pruebas.

Un buen conjunto de pruebas va a pasar argumentos buenos y malos a una función / método, y esperar resultados consistentes. Esto, a su vez, significa que el componente probado usará verificaciones de precondición (programación defensiva) para confirmar los argumentos que se le pasan.

Hablando genéricamente ...

Por ejemplo, si un argumento nulo para un procedimiento en particular es inválido, entonces al menos una prueba pasará un nulo, y esperará una excepción / error de "argumento nulo inválido" de algún tipo.

Al menos otra prueba va a pasar un argumento válido , por supuesto, o pasar por una gran matriz y pasar innumerables argumentos válidos, y confirmar que el estado resultante es apropiado.

Si una prueba no pasa ese argumento nulo y recibe una bofetada con la excepción esperada (y esa excepción se lanzó porque el código verificó defensivamente el estado que se le pasó), entonces el nulo puede terminar asignado a una propiedad de una clase o enterrado en una colección de algún tipo donde no debería estar.

Esto podría causar un comportamiento inesperado en una parte completamente diferente del sistema a la que se pasa la instancia de clase, en una ubicación geográfica distante después de que se haya enviado el software . Y ese es el tipo de cosas que realmente estamos tratando de evitar, ¿verdad?

Incluso podría ser peor. La instancia de clase con el estado no válido podría serializarse y almacenarse, solo para causar un error cuando se reconstituya para usarse más adelante. Dios, no lo sé, tal vez es un sistema de control mecánico de algún tipo que no puede reiniciarse después de un apagado porque no puede deserializar su propio estado de configuración persistente. O la instancia de clase se puede serializar y pasar a un sistema completamente diferente creado por otra entidad, y ese sistema podría fallar.

Especialmente si los programadores de ese otro sistema no codificaron defensivamente.


2
Eso es divertido, el voto negativo llegó tan rápido que es absolutamente posible que el votante negativo haya leído más allá del primer párrafo.
Craig

1
:-) Acabo de votar sin leer más allá del primer párrafo, así que espero que eso lo equilibre ...
SusanW

1
Parecía lo menos que podía hacer :-) (En realidad, yo he leído el resto sólo para asegurarse de que no debe ser descuidado -.! Especialmente en un tema como este)
SusanW

1
Supuse que probablemente lo tenías. :)
Craig

Las comprobaciones defensivas se pueden realizar en tiempo de compilación con herramientas como los contratos de código.
Matthew Whited

9

En lugar de TDD hablemos de "pruebas de software" en general, y en lugar de "programación defensiva" en general, hablemos de mi forma favorita de hacer programación defensiva, que es mediante el uso de aserciones.


Entonces, dado que hacemos pruebas de software, deberíamos dejar de colocar declaraciones de afirmación en el código de producción, ¿verdad? Permítanme contar las formas en que esto está mal:

  1. Las afirmaciones son opcionales, así que si no te gustan, solo ejecuta tu sistema con las afirmaciones deshabilitadas.

  2. Las afirmaciones verifican cosas que las pruebas no pueden (y no deberían). Porque se supone que las pruebas tienen una vista de recuadro negro de su sistema, mientras que las afirmaciones tienen una vista de recuadro blanco. (Por supuesto, ya que viven en él).

  3. Las afirmaciones son una excelente herramienta de documentación. Ningún comentario fue, o será, tan inequívoco como un fragmento de código que afirma lo mismo. Además, la documentación tiende a quedar desactualizada a medida que el código evoluciona, y el compilador no puede hacerla cumplir de ninguna manera.

  4. Las aserciones pueden detectar errores en el código de prueba. ¿Alguna vez te has encontrado con una situación en la que una prueba falla y no sabes quién está equivocado: el código de producción o la prueba?

  5. Las afirmaciones pueden ser más pertinentes que las pruebas. Las pruebas verifican lo prescrito por los requisitos funcionales, pero el código a menudo tiene que hacer ciertas suposiciones que son mucho más técnicas que eso. Las personas que escriben documentos de requisitos funcionales rara vez piensan en la división por cero.

  6. Las afirmaciones identifican errores que las pruebas solo sugieren ampliamente. Por lo tanto, su prueba establece algunas condiciones previas extensas, invoca un fragmento de código extenso, reúne los resultados y descubre que no son los esperados. Dada la suficiente resolución de problemas, eventualmente encontrará exactamente dónde las cosas salieron mal, pero las aserciones generalmente lo encontrarán primero.

  7. Las afirmaciones reducen la complejidad del programa. Cada línea de código que escribe aumenta la complejidad del programa. Las afirmaciones y la palabra clave final( readonly) son las dos únicas construcciones que conozco que realmente reducen la complejidad del programa. Eso no tiene precio.

  8. Las afirmaciones ayudan al compilador a comprender mejor su código. Por favor, intente esto en casa: void foo( Object x ) { assert x != null; if( x == null ) { } }su compilador debe emitir una advertencia diciéndole que la condición x == nullsiempre es falsa. Eso puede ser muy útil.

Lo anterior fue un resumen de una publicación de mi blog, 2014-09-21 "Afirmaciones y pruebas"


Creo que no estoy de acuerdo con esta respuesta. (5) En TDD, el conjunto de pruebas es la especificación. Se supone que debes escribir el código más simple para que las pruebas pasen, nada más. (4) El flujo de trabajo rojo-verde asegura que la prueba falle cuando debería y pasa cuando la funcionalidad deseada está presente. Las afirmaciones no ayudan mucho aquí. (3,7) La documentación es documentación, las afirmaciones no lo son. Pero al hacer explícitos los supuestos, el código se vuelve más autodocumentado. Pensaría en ellos como comentarios ejecutables. (2) Las pruebas de caja blanca pueden ser parte de una estrategia de prueba válida.
amon

55
"En TDD, el conjunto de pruebas es la especificación. Se supone que debes escribir el código más simple para que las pruebas pasen, nada más": no creo que esto sea siempre una buena idea: como se señala en la respuesta, hay Suposición interna adicional en el código que uno podría querer verificar. ¿Qué pasa con los errores internos que se cancelan entre sí? Sus pruebas pasan pero algunas suposiciones dentro de su código son incorrectas, lo que puede conducir a errores insidiosos más adelante.
Giorgio

5

Creo que a la mayoría de las respuestas les falta una distinción crítica: depende de cómo se usará su código.

¿El módulo en cuestión será utilizado por otros clientes independientemente de la aplicación que está probando? Si proporciona una biblioteca o API para uso de terceros, no tiene forma de asegurarse de que solo llamen a su código con una entrada válida. Tienes que validar todas las entradas.

Pero si el módulo en cuestión solo lo usa el código que usted controla, entonces su amigo puede tener un punto. Puede usar pruebas unitarias para verificar que el módulo en cuestión solo se llame con una entrada válida. Las comprobaciones de condición previa aún podrían considerarse una buena práctica, pero es una compensación: si usted ensucia el código que verifica la condición que sabe que nunca puede surgir, solo oscurece la intención del código.

No estoy de acuerdo con que las verificaciones previas requieran más pruebas unitarias. Si decide que no necesita probar algunas formas de entrada inválida, entonces no debería importar si la función contiene verificaciones de precondición o no. Recuerde que las pruebas deben verificar el comportamiento, no los detalles de implementación.


44
Si el procedimiento llamado no verifica la validez de las entradas (que es el debate original), las pruebas de su unidad no pueden garantizar que el módulo en cuestión solo se llame con una entrada válida. En particular, podría llamarse con una entrada no válida, pero de todos modos devuelve un resultado correcto de todos modos en los casos probados: hay varios tipos de comportamiento indefinido, manejo de desbordamiento, etc. que podrían devolver el resultado esperado en un entorno de prueba con optimizaciones deshabilitadas, pero falla en la producción.
Peteris

@Peteris: ¿Estás pensando en un comportamiento indefinido como en C? Invocar comportamientos indefinidos que tienen diferentes resultados en diferentes entornos es obviamente un error, pero tampoco puede prevenirse mediante comprobaciones previas. Por ejemplo, ¿cómo verifica que un argumento puntero apunta a una memoria válida?
JacquesB

3
Esto solo funcionará en las tiendas más pequeñas. Una vez que su equipo vaya más allá, digamos, seis personas, necesitará las verificaciones de validación de todos modos.
Robert Harvey

1
@RobertHarvey: en ese caso, el sistema debe dividirse en subsistemas con interfaces bien definidas y la validación de entrada debe realizarse en la interfaz.
JacquesB

esta. Depende del código, ¿debe ser usado por el equipo? ¿El equipo tiene acceso al código fuente? Si es un código puramente interno, entonces buscar argumentos podría ser una carga, por ejemplo, verifica 0 y luego lanza una excepción, y la persona que llama luego mira el código oh, esta clase puede lanzar una excepción, etc., y esperar ... en este caso, el objeto nunca recibirá 0 ya que se filtraron 2 niveles antes. Si ese es un código de biblioteca para ser utilizado por terceros, esa es otra historia. No todo el código está escrito para ser utilizado por todo el mundo.
Aleksander Fular

3

Este argumento me desconcierta, porque cuando comencé a practicar TDD, mis pruebas unitarias de la forma "objeto responde <de cierta manera> cuando <entrada no válida>" aumentó 2 o 3 veces. Me pregunto cómo se las arregla su colega para pasar con éxito ese tipo de pruebas unitarias sin que sus funciones hagan validación.

El caso inverso, que las pruebas unitarias muestran que nunca se producen resultados incorrectos que se pasarán a los argumentos de otras funciones, es mucho más difícil de probar. Al igual que el primer caso, depende en gran medida de una cobertura exhaustiva de los casos límite, pero tiene el requisito adicional de que todas sus entradas de funciones deben provenir de las salidas de otras funciones cuyas salidas ha probado la unidad y no de, digamos, la entrada del usuario o módulos de terceros.

En otras palabras, lo que hace TDD no le impide necesitar un código de validación, sino que le ayuda a evitar olvidarlo .


2

Creo que interpreto los comentarios de su colega de manera diferente a la mayoría del resto de las respuestas.

Me parece que el argumento es:

  • Todo nuestro código es probado en la unidad.
  • Todo el código que usa su componente es nuestro código, o si no lo hace, otra persona lo prueba (no se especifica explícitamente, pero es lo que entiendo de "las pruebas unitarias deberían detectar cualquier uso incorrecto de la clase").
  • Por lo tanto, para cada persona que llama de su función, hay una prueba unitaria en algún lugar que se burla de su componente, y la prueba falla si la persona que llama pasa un valor no válido a esa simulación.
  • Por lo tanto, no importa lo que haga su función cuando se pasa un valor no válido, porque nuestras pruebas dicen que eso no puede suceder.

Para mí, este argumento tiene cierta lógica, pero confía demasiado en las pruebas unitarias para cubrir todas las situaciones posibles. El hecho simple es que el 100% de cobertura de línea / rama / ruta no necesariamente ejerce todos los valores que la persona que llama puede pasar, mientras que el 100% de cobertura de todos los estados posibles de la persona que llama (es decir, todos los valores posibles de sus entradas y variables) es computacionalmente inviable.

Por lo tanto, preferiría hacer una prueba unitaria de las personas que llaman para garantizar que (en lo que respecta a las pruebas) nunca pasen valores incorrectos, y además exigir que su componente falle de alguna manera reconocible cuando se pasa un valor incorrecto ( al menos en la medida en que sea posible reconocer los malos valores en el idioma que elija). Esto ayudará a la depuración cuando ocurran problemas en las pruebas de integración, y también ayudará a cualquier usuario de su clase que sea menos riguroso en aislar su unidad de código de esa dependencia.

Sin embargo, tenga en cuenta que si documenta y prueba el comportamiento de su función cuando se pasa un valor <= 0, los valores negativos ya no son inválidos (al menos, no más inválidos de lo que es cualquier argumento throw, ya que ¡también está documentado para lanzar una excepción!). Las personas que llaman tienen derecho a confiar en ese comportamiento defensivo. Si el idioma lo permite, puede ser que, en cualquier caso, este sea el mejor escenario: la función no tiene "entradas no válidas", pero las personas que llaman que esperan no provocar que la función arroje una excepción deben probarse la unidad lo suficiente como para asegurarse de que no No pase ningún valor que cause eso.

A pesar de pensar que su colega está algo menos equivocado que la mayoría de las respuestas, llego a la misma conclusión, que es que las dos técnicas se complementan entre sí. Programe defensivamente, documente sus controles defensivos y pruébelos. El trabajo solo es "innecesario" si los usuarios de su código no pueden beneficiarse de mensajes de error útiles cuando cometen errores. En teoría, si prueban completamente todo su código antes de integrarlo con el suyo, y nunca hay errores en sus pruebas, entonces nunca verán los mensajes de error. En la práctica, incluso si están haciendo TDD e inyección de dependencia total, aún pueden explorar durante el desarrollo o puede haber un lapso en sus pruebas. ¡El resultado es que llaman a su código antes de que su código sea perfecto!


Ese asunto de poner el énfasis en probar a las personas que llaman para asegurarse de que no pasan valores incorrectos parece prestarse a un código frágil con muchas dependencias de bajo, y sin una separación clara de las preocupaciones. Realmente no creo que me gustaría el código que resultaría del pensamiento detrás de ese enfoque.
Craig

@Craig: míralo de esta manera, si has aislado un componente para probar burlándote de sus dependencias, ¿por qué no probarías que solo pasa los valores correctos a esas dependencias? Y si no puede aislar el componente, ¿realmente ha separado las preocupaciones? No estoy en desacuerdo con la codificación defensiva, pero si las comprobaciones defensivas son el medio por el cual está probando la exactitud del código de llamada, entonces eso es un desastre. Así que creo que el colega del interrogador tiene razón en que los cheques son redundantes, pero está equivocado al ver esto como una razón para no escribirlos: --)
Steve Jessop

el único agujero evidente que veo es que todavía estoy probando que mis propios componentes no pueden pasar valores no válidos a esas dependencias, lo que estoy totalmente de acuerdo en que se debe hacer, pero ¿cuántas decisiones toma la cantidad de gerentes de negocios para hacer un privado? componente público para que los socios puedan llamarlo? Esto realmente me recuerda el diseño de la base de datos y toda la historia de amor actual con los ORM, lo que resulta en que muchas personas (en su mayoría más jóvenes) declaran que las bases de datos son simplemente almacenamiento de red tonto y no deberían protegerse con restricciones, claves externas y procedimientos almacenados.
Craig

La otra cosa que veo es que en ese escenario, por supuesto, es que solo está probando llamadas a simulacros, no a las dependencias reales. En última instancia, es el código en esas dependencias el que puede o no funcionar adecuadamente con un valor pasado particular, no el código en la persona que llama. Por lo tanto, la dependencia debe hacer lo correcto, y debe haber suficiente cobertura de prueba independiente de la dependencia para garantizar que lo haga. Recuerde, estas pruebas de las que estamos hablando se llaman pruebas "unitarias". Cada dependencia es una unidad. :)
Craig

1

Las interfaces públicas pueden y serán mal utilizadas

El reclamo de su compañero de trabajo "las pruebas unitarias deben detectar cualquier uso incorrecto de la clase" es estrictamente falso para cualquier interfaz que no sea privada. Si se puede llamar a una función pública con argumentos enteros, entonces se puede llamar y se llamará con cualquier argumento entero, y el código debe comportarse adecuadamente. Si una firma de función pública acepta, por ejemplo, Java Double type, entonces nulo, NaN, MAX_VALUE, -Inf son todos los valores posibles. Sus pruebas unitarias no pueden detectar usos incorrectos de la clase porque esas pruebas no pueden probar el código que usará esta clase, porque ese código aún no está escrito, puede que usted no lo haya escrito y definitivamente estará fuera del alcance de sus pruebas unitarias .

Por otro lado, este enfoque puede ser válido para las propiedades privadas (con suerte mucho más numerosas): si una clase puede garantizar que algún hecho sea siempre cierto (por ejemplo, la propiedad X no puede ser nula, la posición del entero no excede la longitud máxima , cuando se llama a la función A, todas las estructuras de datos de requisitos previos están bien formadas), entonces puede ser apropiado evitar verificar esto una y otra vez por razones de rendimiento, y confiar en las pruebas unitarias.


El encabezado y el primer párrafo de esto es cierto porque no son las pruebas unitarias las que ejercerán el código en tiempo de ejecución. Es cualquier otro código de tiempo de ejecución y las condiciones cambiantes del mundo real y las malas entradas del usuario y los intentos de piratería interactúan con el código.
Craig

1

La defensa contra el mal uso es una característica , desarrollada debido a un requisito para ello. (No todas las interfaces requieren verificaciones rigurosas contra el mal uso; por ejemplo, las internas de uso muy estricto).

La función requiere pruebas: ¿realmente funciona la defensa contra el mal uso? El objetivo de probar esta característica es tratar de demostrar que no es así: idear un mal uso del módulo que no es detectado por sus controles.

Si se requieren verificaciones específicas, no tiene sentido afirmar que la existencia de algunas pruebas las hace innecesarias. Si es una característica de alguna función que (por ejemplo) arroja una excepción cuando el parámetro tres es negativo, entonces eso no es negociable; Lo hará.

Sin embargo, sospecho que su colega realmente tiene sentido desde el punto de vista de una situación en la que no se requieren controles específicos en las entradas, con respuestas específicas a las entradas incorrectas: una situación en la que solo hay un requisito general entendido para robustez

Las verificaciones al ingresar a alguna función de nivel superior están allí, en parte, para proteger algunos códigos internos débiles o mal probados de combinaciones inesperadas de parámetros (de modo que si el código está bien probado, las verificaciones no son necesarias: el código puede simplemente " tiempo "los malos parámetros).

Hay verdad en la idea del colega, y lo que probablemente quiere decir es esto: si construimos una función a partir de piezas de nivel inferior muy robustas que están codificadas defensivamente y se prueban individualmente contra todo mal uso, entonces es posible que la función de nivel superior sea robusto sin tener sus propias autocomprobaciones extensas.

Si se viola su contrato, se traducirá en un mal uso de las funciones de nivel inferior, tal vez lanzando excepciones o lo que sea.

El único problema con eso es que las excepciones de nivel inferior no son específicas de la interfaz de nivel superior. Si eso es un problema depende de cuáles son los requisitos. Si el requisito es simplemente "la función debe ser robusta contra el mal uso y lanzar algún tipo de excepción en lugar de bloquearse, o continuar calculando con datos basura", de hecho, podría estar cubierta por toda la robustez de las piezas de nivel inferior en las que se encuentra construido.

Si la función tiene un requisito para informes de errores detallados y muy específicos relacionados con sus parámetros, entonces las comprobaciones de nivel inferior no satisfacen completamente esos requisitos. Solo se aseguran de que la función explote de alguna manera (no continúa con una mala combinación de parámetros, produciendo un resultado basura). Si el código del cliente se escribe para detectar específicamente ciertos errores y manejarlos, es posible que no funcione correctamente. El código del cliente podría estar obteniendo, como entrada, los datos en los que se basan los parámetros, y puede esperar que la función los verifique y traduzca los valores incorrectos a los errores específicos como se documenta (para que pueda manejar esos parámetros). errores correctamente) en lugar de algunos otros errores que no se manejan y tal vez detengan la imagen del software.

TL; DR: tu colega probablemente no sea un idiota; simplemente están hablando unos con otros con diferentes perspectivas sobre la misma cosa, porque los requisitos no están completamente definidos y cada uno de ustedes tiene una idea diferente de cuáles son los "requisitos no escritos". Cree que cuando no hay requisitos específicos para la verificación de parámetros, debe codificar la verificación detallada de todos modos; piensa el colega, solo deje que el código robusto de nivel inferior explote cuando los parámetros estén equivocados. Es poco productivo discutir sobre requisitos no escritos a través del código: reconozca que no está de acuerdo con los requisitos en lugar del código. Su forma de codificación refleja cuáles cree que son los requisitos; la manera del colega representa su punto de vista de los requisitos. Si lo ves así, está claro que lo que está bien o mal no está t en el código mismo; el código es solo un proxy para su opinión sobre cuál debería ser la especificación.


Esto se relaciona con una dificultad filosófica general de manejar lo que pueden ser requisitos sueltos. Si se permite que una función tenga un reinado libre significativo pero no total para que se comporte de manera arbitraria cuando se le dan entradas mal formadas (por ejemplo, si un decodificador de imagen puede cumplir con los requisitos si se puede garantizar que, a su gusto, produzca alguna combinación arbitraria de píxeles o termine de manera anormal , pero no si puede permitir que entradas creadas con fines malintencionados ejecuten código arbitrario), puede que no esté claro qué casos de prueba serían apropiados para garantizar que ninguna entrada produzca un comportamiento inaceptable.
supercat

1

Las pruebas definen el contrato de su clase.

Como corolario, la ausencia de una prueba define un contrato que incluye un comportamiento indefinido . Así que cuando se pasa nulla Foo::Frobnicate(Widget widget), y no contada estragos en tiempo de ejecución se produce, usted todavía está en el contrato de su clase.

Más tarde, usted decide, "no queremos la posibilidad de un comportamiento indefinido", que es una opción sensata. Eso significa que usted tiene que tener un comportamiento esperado para pasar nulla Foo::Frobnicate(Widget widget).

Y documenta esa decisión al incluir un

[Test]
void Foo_FrobnicatesANullWidget_ThrowsInvalidArgument() 
{
    Given(Foo foo);
    When(foo.Frobnicate(null));
    Then(Expect_Exception(InvalidArgument));
}

1

Un buen conjunto de pruebas ejercitará la interfaz externa de su clase y asegurará que tales abusos generen la respuesta correcta (una excepción, o lo que usted defina como "correcto"). De hecho, el primer caso de prueba que escribo para una clase es llamar a su constructor con argumentos fuera de rango.

El tipo de programación defensiva que tiende a ser eliminada por un enfoque totalmente probado por la unidad es la validación innecesaria de invariantes internos que no pueden ser violados por código externo.

Una idea útil que a veces utilizo es proporcionar un método que pruebe las invariantes del objeto; su método de derribo puede llamarlo para validar que sus acciones externas en el objeto nunca rompan los invariantes.


0

Las pruebas de TDD detectarán errores durante el desarrollo del código .

La comprobación de límites que describe como parte de la programación defensiva detectará errores durante el uso del código .

Si los dos dominios son iguales, es decir, el código que está escribiendo solo es utilizado internamente por este proyecto específico, entonces puede ser cierto que TDD excluirá la necesidad de la verificación de límites de programación defensiva que describa, pero solo si esos tipos La comprobación de límites se realiza específicamente en pruebas TDD .


Como ejemplo específico, suponga que se desarrolló una biblioteca de código financiero utilizando TDD. Una de las pruebas podría afirmar que un valor particular nunca puede ser negativo. Eso asegura que los desarrolladores de la biblioteca no usen accidentalmente las clases a medida que implementan las características.

Pero después de que se lanza la biblioteca y la estoy usando en mi propio programa, esas pruebas de TDD no me impiden asignar un valor negativo (suponiendo que esté expuesto). Verificación de límites lo haría.

Mi punto es que, si bien una afirmación de TDD podría abordar el problema del valor negativo si el código solo se usa internamente como parte del desarrollo de una aplicación más grande (bajo TDD), si va a ser una biblioteca utilizada por otros programadores sin TDD marco y pruebas , cuestiones de verificación de límites.


1
No voté negativamente, pero estoy de acuerdo con los votos negativos sobre la premisa de que agregar distinciones sutiles a este tipo de argumento enturbia el agua.
Craig

@ Craig Estaría interesado en sus comentarios sobre el ejemplo específico que agregué.
Blackhawk

Me gusta la especificidad del ejemplo. La única preocupación que aún tengo es general para todo el argumento. Por ejemplo; aparece un nuevo desarrollador en el equipo y escribe un nuevo componente que usa ese módulo financiero. El nuevo tipo no es consciente de todas las complejidades del sistema, y ​​mucho menos del hecho de que todo tipo de conocimiento experto sobre cómo se supone que debe funcionar el sistema está integrado en las pruebas en lugar del código que se está probando.
Craig

Entonces, el nuevo chico / chica pierde la creación de algunas pruebas vitales, Y terminas con redundancia en tus pruebas: las pruebas en diferentes partes del sistema verifican las mismas condiciones y se vuelven inconsistentes a medida que pasa el tiempo, en lugar de simplemente hacer las afirmaciones apropiadas y La condición previa verifica en el código donde está la acción.
Craig

1
Algo como eso. Excepto que muchos de los argumentos aquí han sido sobre hacer que las pruebas para el código de llamada hagan todas las verificaciones. Pero si tiene algún grado de fan-in, termina haciendo las mismas verificaciones desde diferentes lugares, y eso es un problema de mantenimiento en sí mismo. ¿Qué sucede si el rango de entradas válidas para un procedimiento cambia, pero usted tiene el conocimiento del dominio para ese rango integrado en las pruebas que ejercen diferentes componentes? Todavía estoy completamente a favor de la programación defensiva y del uso de perfiles para determinar si tiene problemas de rendimiento y cuándo abordarlos.
Craig

0

TDD y la programación defensiva van de la mano. Usar ambos no es redundante, sino de hecho complementario. Cuando tiene una función, desea asegurarse de que la función funcione como se describe y escribir pruebas para ella; Si no cubre lo que sucede cuando en el caso de una entrada incorrecta, mal retorno, mal estado, etc., no está escribiendo sus pruebas con la suficiente solidez, y su código será frágil incluso si todas sus pruebas están aprobadas.

Como ingeniero incorporado, me gusta usar el ejemplo de escribir una función para simplemente agregar dos bytes juntos y devolver el resultado de esta manera:

uint8_t AddTwoBytes(uint8_t a, uint8_t b, uint8_t *sum); 

Ahora, si simplemente *(sum) = a + blo hicieras, funcionaría, pero solo con algunas entradas. a = 1y b = 2haría sum = 3; sin embargo, porque el tamaño de la suma es un byte, a = 100y se b = 200generaría sum = 44debido al desbordamiento En C, devolvería un error en este caso para indicar que la función falló; lanzar una excepción es lo mismo en su código. No considerar las fallas o probar cómo manejarlas no funcionará a largo plazo, porque si se producen esas condiciones, no se manejarán y podrían causar una serie de problemas.


Eso parece un buen ejemplo de entrevista-pregunta (¿por qué tiene un valor de retorno y un parámetro "out", y qué sucede cuando sumes un puntero nulo?).
Toby Speight
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.