13

Estoy usando funciones en lugar de clases, y me parece que no puedo decir cuándo otra función de la que depende es una dependencia que debe ser probada individualmente o una interna detalle de implementación que no debería. ¿Cómo puedes decir cuál es?Cómo escribir bien Pruebas Unitarias en Programación Funcional

Un pequeño contexto: estoy escribiendo un intérprete Lisp muy simple que tiene una función eval(). Tendrá muchas responsabilidades, demasiadas en realidad, como evaluar los símbolos de forma diferente a las listas (todo lo demás se evalúa a sí mismo). Al evaluar símbolos, tiene su propio flujo de trabajo complejo (búsqueda de entorno), y al evaluar listas, es aún más complicado, ya que la lista puede ser una macro, función o forma especial, cada una de las cuales tiene su propio flujo de trabajo complejo y conjunto de responsabilidades.

No puedo decir si mis eval_symbol() y eval_list() funciones se deben considerar los detalles internos de implementación de eval() que deben ser probadas a través propias pruebas unitarias eval() 's, o dependencias genuinos en su propio derecho que debe ser probado independientemente unidad de eval()'s unit tests.

+1

Parece, en este caso de todos modos, que debe seguir adelante y probar la unidad eval_symbol() y eval_list(). – Patrick87

Respuesta

14

Una motivación importante para el concepto de "prueba de unidad" es controlar la explosión combinatoria de los casos de prueba requeridos. Veamos los ejemplos de eval, eval_symbol y eval_list.

En el caso de eval_symbol, nos queremos probar contingencias, donde es el símbolo que está vinculante:

  • falta (es decir, el símbolo no está consolidado)

  • en el entorno global

  • se encuentra directamente en el entorno actual

  • heredado de un conta ambiente caniza

  • sombreado otra unión

  • ...y así sucesivamente

En el caso de eval_list, vamos a querer poner a prueba (entre otras cosas) lo que ocurre cuando la posición de la función de la lista contiene un símbolo con:

  • ninguna función o macro vinculante

  • una función de unión

  • una macro de unión

eval_list invocará eval_symbol siempre que necesite un enlace de símbolo (suponiendo un LISP-1, eso es). Digamos que hay S casos de prueba para eval_symbol y L casos de prueba relacionados con símbolos para eval_list. Si probamos cada una de estas funciones por separado, podríamos saltear aproximadamente S + L casos de prueba relacionados con símbolos. Sin embargo, si deseamos tratar eval_list como una caja negra y probarla exhaustivamente sin ningún conocimiento de que usa eval_symbol internamente, entonces nos enfrentamos con S x L casos de prueba relacionados con símbolos (por ejemplo, enlace de función global, macroenlace global) , enlace de función local, enlace de macro local, enlace de función heredado, enlace de macro heredado, etc. Eso es mucho más casos. eval es aún peor: como una caja negra, el número de combinaciones puede llegar a ser increíblemente grande, de ahí el término explosión combinatoria.

Por lo tanto, nos enfrentamos a una elección de pureza teórica versus practicidad real. No hay duda de que un conjunto completo de casos de prueba que solo ejerce la "API pública" (en este caso, eval) da la mayor seguridad de que no hay errores. Después de todo, al ejercitar todas las combinaciones posibles, podemos encontrar sutiles errores de integración. Sin embargo, el número de tales combinaciones puede ser tan prohibitivamente grande que impida tal prueba. Sin mencionar que el programador probablemente cometerá errores (o se volverá loco) al revisar un gran número de casos de prueba que solo difieren en formas sutiles. Al realizar pruebas unitarias de los componentes internos más pequeños, se puede reducir enormemente el número de casos de prueba requeridos, manteniendo un alto nivel de confianza en los resultados, una solución práctica.

Por lo tanto, creo que la guía para identificar la granularidad de las pruebas unitarias es la siguiente: si el número de casos de prueba es incómodamente grande, comience a buscar unidades más pequeñas para probar.

En el caso que nos ocupa, recomendaría absolutamente probar eval, eval-list y eval-symbol como unidades separadas precisamente debido a la explosión combinatoria. Al escribir las pruebas para eval-list, puede confiar en que eval-symbol es sólido y limitar su atención a la funcionalidad que eval-list agrega por derecho propio. Es probable que existan otras unidades comprobables dentro de eval-list, como eval-function, eval-macro, eval-lambda, eval-arglist y así sucesivamente.

1

No estoy realmente al tanto de ninguna regla general específica para esto. Pero parece que se debería hacer dos preguntas:

  1. Se puede definir el propósito de eval_symbol y eval_list sin necesidad de decir "parte de la implementación de eval
  2. Si ve una prueba de falla para eval? , ¿sería útil para ver si alguna de las pruebas eval_symbol y eval_list también fallan?

Si la respuesta a cualquiera de los que se Sí, me gustaría probar por separado.

0

Hace algunos meses escribí un simple intérprete "casi Lisp" en Python para una tarea. Lo diseñé usando un patrón de diseño de Intérprete, la unidad probó el código de evaluación. Luego agregué el código de impresión y análisis y transformé los dispositivos de prueba de la representación de sintaxis abstracta (objetos) en cadenas de sintaxis concretas. Parte de la tarea consistía en programar funciones simples de procesamiento de listas recursivas, así que las agregué como pruebas funcionales.

Para responder a su pregunta en general, las reglas son muy similares a las de OO. Debería tener todas sus funciones públicas cubiertas. En OO los métodos públicos son parte de una clase o una interfaz, en la programación funcional, con frecuencia tiene control de visibilidad basado en módulos (similar a las interfaces). Idealmente, tendría cobertura total para todas las funciones, pero si esto no es posible, considere el enfoque TDD: comience por escribir pruebas para lo que sabe que necesita e impleméntelas. Las funciones auxiliares serán el resultado de la refactorización y, como usted escribió pruebas para todo lo importante antes, si las pruebas funcionan después de la refactorización, habrá terminado y podrá escribir otra prueba (iterar).

¡Buena suerte!

3

Mi consejo es bastante simple: "¡Comience en algún lado!"

  • Si ve un nombre de alguna def (o deffun) que parece que podría ser frágil, bueno, es probable que quiera probarlo, ¿verdad?
  • Si tiene problemas para tratar de averiguar cómo su código de cliente puede interactuar con otra unidad de código, bueno, es probable que desee escribir algunas pruebas en algún lugar que le permitan crear ejemplos de cómo usar correctamente esa función.
  • Si alguna función parece sensible a los valores de los datos, es posible que desee escribir algunas pruebas que no solo verifiquen que pueda manejar adecuadamente las entradas razonables, sino que también ejerzan específicamente condiciones de frontera e información extraña o inusual.
  • Todo lo que parece propenso a errores debería tener pruebas.
  • Lo que no parece claro debería tener pruebas.
  • Lo que parece complicado debe tener pruebas.
  • Lo que parece importante debe tener pruebas.

Más adelante, puede aumentar su cobertura al 100%. Pero descubrirá que probablemente obtenga el 80% de los resultados reales del primer 20% de la codificación de prueba de su unidad (Invertida "Ley de los pocos críticos").

Por lo tanto, para revisar el punto principal de mi humilde enfoque, "¡Comience en algún lado!"

Respecto a la última parte de su pregunta, le recomiendo que piense en cualquier recursión posible o reutilización posible adicional por funciones de "cliente" que usted o futuros desarrolladores podrían crear en el futuro que también llamarían eval_symbol() o eval_list().

En cuanto a la recursividad, el estilo de programación funcional la usa mucho y puede ser difícil de corregir, especialmente para aquellos que proceden de programación procedural u orientada a objetos, donde la recursión parece raramente encontrada. La mejor forma de obtener la recursión correcta es apuntar con precisión a todas las funciones recursivas con pruebas unitarias para asegurarse de que se validan todos los casos de uso recursivo posibles.

En cuanto a la reutilización, si su función eval() puede invocar sus funciones simplemente por un único uso, probablemente se deberían tratar como dependencias genuinas que merecen pruebas independientes de unidades.

Como una última sugerencia, el término "unidad" tiene una definición técnica en el dominio de pruebas unitarias como "the smallest piece of code software that can be tested in isolation.". Esa es una definición fundamental muy antigua que puede aclarar rápidamente su situación para usted.

2

Esto es algo ortogonal al contenido de su pregunta, pero aborda directamente la pregunta planteada en el título.

La programación funcional idiomática implica en su mayoría piezas de código sin efectos secundarios, lo que hace que las pruebas unitarias sean más sencillas en general.Definir una prueba unitaria generalmente implica afirmar una propiedad lógica sobre la función bajo prueba, en lugar de construir grandes cantidades de andamios frágiles solo para establecer un entorno de prueba adecuado.

Como ejemplo, digamos que estamos probando las funciones extendEnv y lookupEnv como parte de un intérprete. Una buena prueba unitaria para estas funciones verificaría que si ampliamos un entorno dos veces con la misma variable vinculada a valores diferentes, solo el valor más reciente es devuelto por lookupEnv.

En Haskell, una prueba de esta propiedad podría ser:

test = 
    let env = extendEnv "x" 5 (extendEnv "x" 6 emptyEnv) 
    in lookupEnv env "x" == Just 5 

Esta prueba nos da cierta seguridad, y no requiere ninguna instalación o desmontaje que no sea la creación de valor env que nos interesa en la prueba Sin embargo, los valores bajo prueba son muy específicos. Esto solo prueba un entorno particular, por lo que un error sutil podría pasar fácilmente. Preferimos hacer una declaración más general: para todas las variables y valores xv y w, un entorno env extendió dos veces con x obligado a v después x está obligado a w, lookupEnv env x == Just w.

En general, necesitamos una prueba formal (tal vez mecanizada con un asistente de pruebas como Coq, Agda o Isabelle) para demostrar que una propiedad como esta es válida. Sin embargo, podemos conseguir mucho más cerca de especificar los valores de prueba mediante el uso de QuickCheck, una biblioteca disponible para los idiomas más funcionales que generan grandes cantidades de entrada de prueba arbitraria de propiedades que definen funciones como booleanos:

prop_test x v w env' = 
    let env = extendEnv x v (extendEnv x w env') 
    in lookupEnv env x == Just w 

En el indicador, nos puede tener QuickCheck generar entradas arbitrarias a esta función, y ver si sigue siendo cierto para todos ellos:

*Main> quickCheck prop_test 
+++ OK, passed 100 tests. 
*Main> quickCheckWith (stdArgs { maxSuccess = 1000 }) prop_test 
+++ OK, passed 1000 tests. 

QuickCheck utiliza un poco de magia muy agradable (y extensible) para producir estos valores arbitrarios, pero es la programación funcional que hace tener esos valores útiles. Al hacer que los efectos secundarios sean la excepción (perdón) en lugar de la regla, las pruebas unitarias se vuelven menos tarea de especificar manualmente casos de prueba, y más una cuestión de afirmar propiedades generalizadas sobre el comportamiento de sus funciones.

Este proceso te sorprenderá frecuentemente. El razonamiento a este nivel le brinda a su mente más oportunidades de detectar fallas en su diseño, por lo que es más probable que detecte errores incluso antes de ejecutar su código.