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í?
fuente
Respuestas:
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.
TDD desarrollará las pruebas. La implementación de la validación de parámetros los hará pasar.
fuente
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.
fuente
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.
fuente
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.
fuente
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:
Las afirmaciones son opcionales, así que si no te gustan, solo ejecuta tu sistema con las afirmaciones deshabilitadas.
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).
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.
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?
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.
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.
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.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ónx == null
siempre 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"
fuente
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.
fuente
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 .
fuente
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:
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!
fuente
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.
fuente
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.
fuente
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
null
aFoo::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
null
aFoo::Frobnicate(Widget widget)
.Y documenta esa decisión al incluir un
fuente
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.
fuente
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.
fuente
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:
Ahora, si simplemente
*(sum) = a + b
lo hicieras, funcionaría, pero solo con algunas entradas.a = 1
yb = 2
haríasum = 3
; sin embargo, porque el tamaño de la suma es un byte,a = 100
y seb = 200
generaríasum = 44
debido 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.fuente
sum
es un puntero nulo?).