¿Depende implícitamente de las funciones puras mal (en particular, para las pruebas)?

8

Para extender un poco el título, estoy tratando de llegar a una conclusión sobre si es necesario o no declarar explícitamente (es decir, inyectar) funciones puras de las que depende otra función o clase.

¿Hay algún código menos comprobable o peor diseñado si utiliza funciones puras sin pedirlas? Me gustaría llegar a una conclusión al respecto, para cualquier tipo de función pura, desde funciones simples y nativas (por ejemplo max(), min()independientemente del idioma) hasta personalizadas y más complicadas que a su vez pueden depender implícitamente de otras funciones puras.

La preocupación habitual es que si algún código solo usa una dependencia directamente, no podrá probarlo de forma aislada, es decir, probará al mismo tiempo todas las cosas que trajo en silencio. Pero esto agrega un poco de repetitivo si tiene que hacerlo para cada pequeña función, por lo que me pregunto si esto aún es válido para funciones puras, y por qué o por qué no.

DanielM
fuente
2
Por mi parte, no entiendo esta pregunta. ¿Qué importa si una función es pura para propósitos DI?
Telastyn
Por lo tanto, propone que se inyecten funciones puras, funciones impuras y clases de todos modos, sin importar su tamaño, dependencias transitivas ni nada. ¿Podrías quizás dar más detalles sobre eso?
DanielM
No, simplemente no entiendo lo que estás preguntando. Las funciones rara vez se inyectan en algo, y cuando lo están, el consumidor no puede garantizar su pureza.
Telastyn
1
Puede inyectar todas las funcionalidades, pero a menudo no tiene ningún valor burlarse de estas funcionalidades en una prueba, lo que significa que inyectarlas no tiene ningún valor. Por ejemplo, dado que los operadores son básicamente funciones estáticas, uno podría decidir inyectar el operador + y simularlo en una prueba. A menos que eso tenga algún valor, no lo harías.
user2180613
1
Los operadores también se me habían pasado por la cabeza. Ciertamente sería un inconveniente, pero el punto que estaba tratando de encontrar es cómo tomar la decisión de qué inyectar y qué no. En otras palabras, cómo decidir cuándo hay valor y cuándo no. Inspirado en la respuesta de @ Goyo, creo que ahora un buen criterio es inyectar todo lo que no es una parte intrínseca del sistema, mantener el sistema y sus dependencias ligeramente acoplados; y en contraste, solo use (por lo tanto, no inyecte) cosas que realmente son parte de la identidad del sistema, con las cuales el sistema es altamente cohesivo.
DanielM

Respuestas:

10

No es malo

Las pruebas que escriba no deberían importarle cómo se implementa una determinada clase o función. Más bien, debe garantizar que produzcan los resultados que desea, independientemente de cómo se implementen exactamente.

Como ejemplo, considere la siguiente clase:

Coord2d{
    float x, y;

    ///Will round to the nearest whole number
    Coord2d Round();
}

Debería probar la función 'Redondear' para asegurarse de que devuelve lo que espera, independientemente de cómo se implemente realmente la función. Probablemente escribirías una prueba similar a la siguiente;

Coord2d testValue(0.6, 0.1)
testValueRounded = testValue.Round()
CHECK( testValueRounded.x == 1.0 )
CHECK( testValueRounded.y == 0.0 )

Desde el punto de vista de la prueba, siempre que su función Coord2d :: Round () devuelva lo que espera, no le importa cómo se implemente.

En algunos casos, inyectar una función pura podría ser una forma realmente brillante de hacer que una clase sea más comprobable o extensible.

Sin embargo, en la mayoría de los casos, como la función Coord2d :: Round () anterior, no hay necesidad real de inyectar una función min / max / floor / ceil / trunc. La función está diseñada para redondear sus valores al número entero más cercano. Las pruebas que escriba deben verificar que la función haga esto.

Por último, si desea probar el código del que depende la implementación de una clase / función, puede hacerlo simplemente escribiendo pruebas para la dependencia.

Por ejemplo, si la función Coord2d :: Round () se implementó así ...

Coord2d Round(){
    return Coord2d( floor(x + 0.5f),  floor(y + 0.5f))
}

Si desea probar la función 'piso', puede hacerlo en una prueba de unidad separada.

CHECK( floor (1.436543) == 1.0)
CHECK( floor (134.936) == 134.0)
Ryan
fuente
Todo lo que dijiste se refiere a funciones puras, ¿verdad? Si es así, ¿qué diferencia hay con los no puros o las clases? Quiero decir, podrías codificar todo y aplicar la misma lógica ("todo de lo que dependo ya está probado"). Y una pregunta diferente: ¿podría ampliar cuándo sería brillante inyectar funciones puras y por qué? ¡Gracias!
DanielM
3
@DanielM Absolutamente: las pruebas unitarias deben cubrir alguna unidad aislada de comportamiento significativo ; si la validez del comportamiento de la "unidad" depende estrictamente de devolver el máximo de algo, entonces abstraer "max" no es útil y en realidad reduce la utilidad de la prueba, también. Por otro lado, si estoy devolviendo el "máximo" de lo que sea que provenga de algún proveedor, entonces el apriete del proveedor es útil, porque lo que hace no es directamente pertinente al comportamiento previsto de esta unidad y se puede verificar su corrección. en otra parte. Recuerde: "unit"! = "Class".
Ant P
2
Sin embargo, en última instancia, pensar demasiado en este tipo de cosas suele ser contraproducente. Aquí es donde la prueba primero tiende a ser la única. Defina el comportamiento aislado que desea probar, luego escriba la prueba y la decisión sobre qué inyectar y qué no inyectar está hecho para usted.
Ant P
4

Está implícitamente dependiendo de las funciones puras mal

Desde el punto de vista de la prueba: no, pero solo en el caso de funciones puras , cuando la función devuelve siempre la misma salida para la misma entrada.

¿Cómo prueba la unidad que usa la función inyectada explícitamente?
Inyectará la función simulada (falsa) y comprobará que se llamó a la función con los argumentos correctos.

Pero debido a que tenemos una función pura , que siempre devuelve el mismo resultado para los mismos argumentos, verificar los argumentos de entrada es igual para verificar el resultado de salida.

Con funciones puras, no necesita configurar dependencias / estado adicionales.

Como beneficio adicional obtendrá una mejor encapsulación, probará el comportamiento real de la unidad.
Y muy importante desde el punto de vista de la prueba: podrá refactorizar el código interno de la unidad sin cambiar las pruebas, por ejemplo, si decide agregar un argumento más para la función pura, con la función inyectada explícitamente (burlada en las pruebas) necesitará cambie la configuración de prueba, donde con la función implícitamente utilizada no hace nada.

Puedo imaginar una situación en la que necesita inyectar una función pura, es decir, cuando desea ofrecer a los consumidores que cambien el comportamiento de la unidad.

public decimal CalculateTotalPrice(Order order, Func<Customer, decimal> calculateDiscount)
{
    // Do some calculation based on order data
    var totalPrice = order.Lines.Sum(line => line.Price);

    // Then
    var discountAmount = calculateDiscount(order.Customer);

    return totalPrice - discountAmount;
}

Para el método anterior, espera que los consumidores puedan cambiar el cálculo del descuento, por lo que debe excluir la prueba de su lógica de las pruebas unitarias del CalcualteTotalPricemétodo.

Fabio
fuente
Inyectar la función no significa que deba probar las llamadas de función. En realidad, afirmaría que el SUT hace lo correcto (con respecto al estado observable) cuando se pasa el simulacro. OTOH burlarme de una dependencia que no es parte de la interfaz SUT sería extraño a mis ojos.
Deja de dañar a Mónica el
@Goyo, configurará simulacro para devolver el valor esperado solo para una entrada particular. Por lo tanto, su simulación "protegerá" que se proporcionaron los argumentos de entrada correctos.
Fabio
4

En un mundo ideal de programación SOLID OO, estaría inyectando cada comportamiento externo. En la práctica, siempre utilizará algunas funciones simples (o no tan simples) directamente.

Si está calculando el máximo de un conjunto de números, probablemente sería excesivo inyectar una maxfunción y simularla en pruebas unitarias. Por lo general, no le importa desacoplar su código de una implementación específica maxy probarlo de forma aislada.

Si está haciendo algo complejo como encontrar una ruta de costo mínimo en un gráfico, es mejor que inyecte la implementación concreta para obtener flexibilidad y simularla en pruebas unitarias para un mejor rendimiento. En este caso, podría valer la pena desacoplar el algoritmo de búsqueda de ruta del código que lo usa.

No puede haber una respuesta para "cualquier tipo de función pura", debe decidir dónde dibujar la línea. Hay demasiados factores involucrados en esta decisión. Al final, tiene que sopesar los beneficios contra los problemas que el desacoplamiento le brinda, y eso depende de usted y su contexto.

Veo en los comentarios que pregunta sobre la diferencia entre inyectar clases (en realidad inyecta objetos) y funciones. No hay diferencia, solo las características del lenguaje hacen que se vea diferente. Desde un punto de vista abstracto, llamar a una función no es diferente de llamar al método de algún objeto y usted inyecta (o no) la función por las mismas razones por las que inyecta (o no) el objeto: para desacoplar ese comportamiento del código de llamada y tener el capacidad de usar diferentes implementaciones del comportamiento y decidir en otro lugar qué implementación usar.

Entonces, básicamente, cualquier criterio que encuentre válido para la inyección de dependencia puede usarlo independientemente de que la dependencia sea un objeto o una función. Puede haber algunos matices dependiendo del idioma (es decir, si está utilizando Java, esto no es un problema).

Deja de dañar a Monica
fuente
3
Inyectar no tiene nada que ver con la programación orientada a objetos.
Frank Hileman
@FrankHileman ¿Mejor? Necesita una inyección de dependencia para depender de abstracciones porque no puede crearlas.
Deja de dañar a Monica el
Sería definitivamente beneficioso tener una lista aproximada de los factores más importantes involucrados en la práctica. En relación con esto, ¿por qué no te importaría acoplarte con max()(como opuesto a otra cosa)?
DanielM
SOLID no es "ideal" ni tiene nada que ver con la programación orientada a objetos.
Frank Hileman
@FrankHileman ¿Cómo SOLID no tiene nada que ver con OOP? Los cinco principios fueron propuestos por Robert Martin en su artículo del año 2000 en un capítulo llamado Principios del diseño de clase orientado a objetos.
NickL
3

Las funciones puras no afectan la capacidad de prueba de la clase debido a sus propiedades:

  • su salida (o error / excepción) solo depende de sus entradas;
  • su producción no depende del estado mundial;
  • su operación no modifica el estado mundial.

Esto significa que están aproximadamente en el mismo ámbito que los métodos privados, que están completamente bajo el control de la clase, con la ventaja adicional de no depender ni siquiera del estado actual de la instancia (es decir, "esto"). La clase de usuario "controla" la salida porque controla completamente las entradas.

Los ejemplos que dio, max () y min (), son deterministas. Sin embargo, random () y currentTime () son procedimientos, no funciones puras (dependen / modifican el estado mundial fuera de banda), por ejemplo. Estos dos últimos tendrían que inyectarse para que las pruebas sean posibles.

carlosdavidepto
fuente