¿Cuáles son buenas pruebas unitarias para cubrir el caso de uso de lanzar un dado?

18

Estoy tratando de familiarizarme con las pruebas unitarias.

Digamos que tenemos un dado que puede tener un número predeterminado de lados igual a 6 (pero puede ser 4, 5 lados, etc.):

import random
class Die():
    def __init__(self, sides=6):
        self._sides = sides

    def roll(self):
        return random.randint(1, self._sides)

¿Serían las siguientes pruebas unitarias válidas / útiles?

  • prueba un lanzamiento en el rango 1-6 para un dado de 6 lados
  • prueba un resultado de 0 para un dado de 6 lados
  • prueba un resultado de 7 para un dado de 6 lados
  • prueba un lanzamiento en el rango 1-3 para un dado de 3 lados
  • prueba un resultado de 0 para un dado de 3 lados
  • prueba un resultado de 4 para un dado de 3 lados

Solo estoy pensando que esto es una pérdida de tiempo, ya que el módulo aleatorio ha existido durante el tiempo suficiente, pero luego creo que si el módulo aleatorio se actualiza (digamos que actualizo mi versión de Python), al menos estoy cubierto.

Además, ¿necesito probar otras variaciones de tiradas de dado, por ejemplo, las 3 en este caso, o es bueno cubrir otro estado de dado inicializado?

Cybran
fuente
1
¿Qué pasa con un dado de menos 5 lados o un dado de lado nulo?
JensG

Respuestas:

22

Tiene razón, sus pruebas no deben verificar que el randommódulo está haciendo su trabajo; una prueba de unidad solo debe probar la clase en sí, no cómo interactúa con otro código (que debe probarse por separado).

Por supuesto, es completamente posible que su código use random.randint()incorrectamente; o estás llamando random.randrange(1, self._sides)y tu dado nunca arroja el valor más alto, pero ese sería un tipo diferente de error, no uno que puedas atrapar con una prueba de unidad. En ese caso, su die unidad está funcionando según lo diseñado, pero el diseño en sí era defectuoso.

En este caso, usaría la burla para reemplazar la randint()función, y solo verificaría que se haya llamado correctamente. Python 3.3 y versiones posteriores vienen con el unittest.mockmódulo para manejar este tipo de pruebas, pero puede instalar el mockpaquete externo en versiones anteriores para obtener exactamente la misma funcionalidad

import unittest
try:
    from unittest.mock import patch
except ImportError:
    # < python 3.3
    from mock import patch


@patch('random.randint', return_value=3)
class TestDice(unittest.TestCase):
    def _make_one(self, *args, **kw):
        from die import Die
        return Die(*args, **kw)

    def test_standard_size(self, mocked_randint):
        die = self._make_one()
        result = die.roll()

        mocked_randint.assert_called_with(1, 6)
        self.assertEqual(result, 3)

    def test_custom_size(self, mocked_randint):
        die = self._make_one(sides=42)
        result = die.roll()

        mocked_randint.assert_called_with(1, 42)
        self.assertEqual(result, 3)


if __name__ == '__main__':
    unittest.main()

Con burlas, su prueba ahora es muy simple; solo hay 2 casos, de verdad. El caso predeterminado para un dado de 6 lados y el caso de lados personalizados.

Hay otras formas de reemplazar temporalmente la randint()función en el espacio de nombres global de Die, pero el mockmódulo lo hace más fácil. El @mock.patchdecorador aquí se aplica a todos los métodos de prueba en el caso de prueba; a cada método de prueba se le pasa un argumento adicional, la random.randint()función simulada, por lo que podemos probar el simulacro para ver si realmente se ha llamado correctamente. El return_valueargumento especifica lo que se devuelve del simulacro cuando se llama, por lo que podemos verificar que el die.roll()método nos haya devuelto el resultado 'aleatorio'.

He usado otra práctica recomendada de pruebas unitarias de Python aquí: importe la clase bajo prueba como parte de la prueba. El _make_onemétodo realiza la importación y la creación de instancias dentro de una prueba , de modo que el módulo de prueba aún se cargará incluso si cometió un error de sintaxis u otro error que evitará que el módulo original se importe.

De esta manera, si cometió un error en el código del módulo, las pruebas aún se ejecutarán; simplemente fallarán y le informarán sobre el error en su código.

Para ser claros, las pruebas anteriores son simplistas en extremo. El objetivo aquí no es probar que random.randint()se ha llamado con los argumentos correctos, por ejemplo. En cambio, el objetivo es probar que la unidad está produciendo los resultados correctos dadas ciertas entradas, donde esas entradas incluyen los resultados de otras unidades que no están bajo prueba. Al burlarse del random.randint()método, puede tomar el control de solo otra entrada a su código.

En las pruebas del mundo real , el código real en su unidad bajo prueba será más complejo; La relación con las entradas pasadas a la API y cómo se invocan otras unidades puede ser interesante aún, y la burla le dará acceso a resultados intermedios, así como también le permitirá establecer los valores de retorno para esas llamadas.

Por ejemplo, en el código que autentica a los usuarios contra un servicio OAuth2 de terceros (una interacción de varias etapas), desea probar que su código está pasando los datos correctos a ese servicio de terceros y le permite simular diferentes respuestas de error que El servicio de terceros volvería, permitiéndole simular diferentes escenarios sin tener que construir un servidor OAuth2 completo usted mismo. Aquí es importante probar que la información de una primera respuesta se ha manejado correctamente y se ha pasado a una llamada de segunda etapa, por lo que desea ver que el servicio simulado se llama correctamente.

Martijn Pieters
fuente
1
Tiene bastantes más de 2 casos de prueba ... los resultados verifican el valor predeterminado: inferior (1), superior (6), inferior a inferior (0), superior a superior (7) y resultados para números especificados por el usuario como max_int etc., la entrada tampoco está validada, lo que puede necesitar probarse en algún momento ...
James Snell
2
No, esas son pruebas randint(), no el código Die.roll().
Martijn Pieters
En realidad, hay una manera de garantizar que no solo se llame a randint correctamente, sino que su resultado también se use correctamente: simule para devolver un sentinel.diepor ejemplo (el objeto centinela también es de unittest.mock) y luego verifique que sea lo que fue devuelto por su método de rollo. En realidad, esto solo permite una forma de implementar el método probado.
aragaer
@aragaer: claro, si desea verificar que el valor se devuelva sin cambios, sentinel.diesería una excelente manera de garantizarlo.
Martijn Pieters
No entiendo por qué querría asegurarse de que se llame a mock_randint_con ciertos valores. Entiendo querer burlarme de randint para devolver valores predecibles, pero ¿no es la preocupación solo que devuelva valores predecibles y no con qué valores se llama? Me parece que verificar los valores llamados está vinculando innecesariamente la prueba con detalles finos de implementación. Además, ¿por qué nos importa que el dado devuelva el valor exacto de randint? ¿Realmente no nos importa que devuelva un valor> 1 y menos que igual al máximo?
bdrx
16

La respuesta de Martijn es cómo lo haría si realmente quisiera ejecutar una prueba que demuestre que está llamando al azar. Sin embargo, a riesgo de que me digan "eso no responde a la pregunta", creo que esto no debería probarse en absoluto. Burlarse de randint ya no es una prueba de recuadro negro: está mostrando específicamente que ciertas cosas están sucediendo en la implementación . La prueba de caja negra ni siquiera es una opción: no hay ninguna prueba que pueda ejecutar que demuestre que el resultado nunca será inferior a 1 o superior a 6.

¿Puedes burlarte randint? Sí tu puedes. Pero que estas probando? Que lo llamaste con argumentos 1 y lados. ¿Qué significa eso ? Estás de vuelta en la casilla uno: al final del día terminarás teniendo que demostrar, formal o informalmente, que llamar random.randint(1, sides)correctamente implementa una tirada de dados.

Estoy todo para pruebas unitarias. Son fantásticos controles de cordura y exponen la presencia de errores. Sin embargo, nunca pueden probar su ausencia, y hay cosas que no se pueden afirmar a través de las pruebas (por ejemplo, que una función en particular nunca arroja una excepción o siempre termina). En este caso particular, siento que hay muy poco a lo que te refieres ganancia. Para un comportamiento determinista, las pruebas unitarias tienen sentido porque realmente sabes cuál será la respuesta que esperas.

Doval
fuente
Las pruebas unitarias no son pruebas de caja negra, realmente. Para eso están las pruebas de integración, para asegurarse de que las diversas partes interactúen según lo diseñado. Es una cuestión de opinión, por supuesto (la mayoría de las filosofías de pruebas lo son), vea ¿La "Prueba de Unidad" se encuentra dentro de las pruebas de caja blanca o caja negra? y Black Box Unit Testing para algunas perspectivas (desbordamiento de pila).
Martijn Pieters
@MartijnPieters No estoy de acuerdo con que "para eso están las pruebas de integración". Las pruebas de integración son para verificar que todos los componentes del sistema interactúen correctamente. No son el lugar para probar que un componente dado da la salida correcta para una entrada dada. En cuanto a las pruebas de unidad de caja negra versus caja blanca, las pruebas de unidad de caja blanca eventualmente se romperán con los cambios de implementación, y cualquier suposición que haya hecho en la implementación probablemente se trasladará a la prueba. La validación con la que random.randintse llama no 1, sidestiene valor si eso es algo incorrecto.
Doval
Sí, eso es una limitación de una prueba de unidad de caja blanca. Sin embargo, no tiene sentido que las pruebas random.randint()devuelvan correctamente los valores en el rango [1, lados] (inclusive), eso depende de los desarrolladores de Python para asegurarse de que la randomunidad funcione correctamente.
Martijn Pieters
Y como usted mismo dice, las pruebas unitarias no pueden garantizar que su código esté libre de errores; si su código está usando otras unidades de manera incorrecta (por ejemplo, esperaba random.randint()comportarse de random.randrange()esa manera y así llamarlo random.randint(1, sides + 1), entonces está hundido de todos modos.)
Martijn Pieters
2
@MartijnPieters Estoy de acuerdo contigo allí, pero eso no es a lo que me opongo. Me opongo a probar que random.randint se llama con argumentos (1, lados) . Has asumido en la implementación que esto es lo correcto, y ahora estás repitiendo esa suposición en la prueba. Si esa suposición es incorrecta, la prueba habrá pasado pero su implementación aún es incorrecta. Es una prueba a medias que es todo un dolor de cabeza para escribir y mantener.
Doval
6

Arreglar semilla aleatoria. Para los dados de 1, 2, 5 y 12 lados, confirme que unos pocos miles de tiradas arrojen resultados que incluyen 1 y N, y no incluyen 0 o N + 1. Si por casualidad parece que obtiene un conjunto de resultados aleatorios que no cubra el rango esperado, cambie a una semilla diferente.

Las herramientas de burla son geniales, pero el hecho de que te permitan hacer algo no significa que se deba hacer. YAGNI se aplica tanto a los accesorios de prueba como a las características.

Si puede probar fácilmente con dependencias no desmontadas, casi siempre debería hacerlo; de esa manera, sus pruebas se centrarán en reducir el recuento de defectos, no solo en aumentar el recuento de pruebas. El exceso de burlas corre el riesgo de crear cifras de cobertura engañosas, lo que a su vez puede llevar a posponer la prueba real a una fase posterior a la que quizás nunca tenga tiempo para llegar ...

soru
fuente
3

¿Qué es un Diesi lo piensas? - No más que una envoltura alrededor random. Se encapsula random.randinty etiqueta de nuevo en términos de un vocabulario propio de la aplicación: Die.Roll.

No me parece relevante insertar otra capa de abstracción entre Diey randomporque Dieya es esta capa de indirección entre su aplicación y la plataforma.

Si quieres resultados de dados enlatados, solo mofa Die, no te burlesrandom .

En general, no pruebo mis objetos de contenedor que se comunican con sistemas externos, escribo pruebas de integración para ellos. Podría escribir un par de esos, Diepero como señaló, debido a la naturaleza aleatoria del objeto subyacente, no serán significativos. Además, aquí no hay configuración o comunicación de red, por lo que no hay mucho que probar, excepto una llamada de plataforma.

=> Teniendo en cuenta que Diesolo se trata de unas pocas líneas de código triviales y agrega poca o ninguna lógica en comparación con randomsí mismo, me saltaría la prueba en ese ejemplo específico.

guillaume31
fuente
2

Sembrar el generador de números aleatorios y verificar los resultados esperados NO es, por lo que puedo ver, una prueba válida. Hace suposiciones sobre CÓMO funciona su dado internamente, lo cual es travieso-travieso. Los desarrolladores de python podrían cambiar el generador de números aleatorios, o el dado (NOTA: "dado" es plural, "dado" es singular. A menos que su clase implemente múltiples tiradas de dado en una llamada, probablemente debería llamarse "dado") podría use un generador de números aleatorios diferente.

Del mismo modo, burlarse de la función aleatoria supone que la implementación de la clase funciona exactamente como se esperaba. ¿Por qué podría no ser este el caso? Alguien podría tomar el control del generador de números aleatorios de python predeterminado, y para evitar eso, una versión futura de su dado puede obtener varios números aleatorios, o números aleatorios más grandes, para mezclar más datos aleatorios. Los fabricantes del sistema operativo FreeBSD utilizaron un esquema similar cuando sospecharon que la NSA estaba manipulando los generadores de números aleatorios de hardware integrados en las CPU.

Si fuera yo, correría, digamos, 6000 rollos, los contaría, y me aseguraría de que cada número del 1-6 salga entre 500 y 1500 veces. También verificaría que no se devuelvan números fuera de ese rango. También podría comprobar que, para un segundo conjunto de 6000 rollos, al ordenar el [1..6] en orden de frecuencia, el resultado es diferente (¡esto fallará una vez de 720 ejecuciones, si los números son aleatorios!). Si desea ser minucioso, puede encontrar la frecuencia de los números después de un 1, después de un 2, etc. pero asegúrese de que el tamaño de su muestra sea lo suficientemente grande y que tenga suficiente variación. Los humanos esperan que los números aleatorios tengan menos patrones de los que realmente tienen.

Repita para un dado de 12 lados y 2 lados (6 es el más utilizado, por lo que es el más esperado para cualquiera que escriba este código).

Finalmente, probaría para ver qué sucede con un dado de 1 cara, un dado de 0 caras, un dado de -1 cara, un dado de 2.3 caras, un dado de [1,2,3,4,5,6] y un dado de "bla". Por supuesto, todo esto debería fallar; ¿Fallan de una manera útil? Estos probablemente deberían fallar en la creación, no en el rodamiento.

O, tal vez, también desee manejarlos de manera diferente, tal vez crear un dado con [1,2,3,4,5,6] debería ser aceptable, y quizás también "bla"; Esto podría ser un dado con 4 caras, y cada cara con una letra. Me viene a la mente el juego "Boggle", al igual que una bola mágica de ocho.

Y finalmente, es posible que desee contemplar esto: http://lh6.ggpht.com/-fAGXwbJbYRM/UJA_31ACOLI/AAAAAAAAAPg/2FxOWzo96KE/s1600-h/random%25255B3%25255D.jpg

AMADANON Inc.
fuente
2

A riesgo de nadar contra la corriente, resolví este problema exacto hace varios años utilizando un método no mencionado hasta ahora.

Mi estrategia fue simplemente burlarme del RNG con uno que produzca una corriente predecible de valores que abarque todo el espacio. Si (por ejemplo) lado = 6 y el RNG produce valores de 0 a 5 en secuencia, puedo predecir cómo debería comportarse mi clase y la prueba unitaria en consecuencia.

La razón es que esto prueba la lógica solo en esta clase, bajo el supuesto de que el RNG finalmente producirá cada uno de esos valores y sin probar el RNG en sí.

Es simple, determinista, reproducible y atrapa errores. Usaría la misma estrategia nuevamente.


La pregunta no explica cuáles deberían ser las pruebas, solo qué datos podrían usarse para las pruebas, dada la presencia de un RNG. Mi sugerencia es simplemente probar exhaustivamente burlándose del RNG. La pregunta de qué vale la pena probar depende de la información no proporcionada en la pregunta.

david.pfx
fuente
Digamos que te burlas del RNG para que sea predecible. Bueno, ¿qué pruebas entonces? La pregunta es "¿Serían las siguientes pruebas unitarias válidas / útiles?" Burlarse de él para devolver 0-5 no es una prueba, sino una configuración de prueba. ¿Cómo "prueba de unidad en consecuencia"? No entiendo cómo "atrapa errores". Me está costando entender lo que necesito para la prueba de 'unidad'.
bdrx
@bdrx: Esto fue hace un tiempo: ahora respondería de manera diferente. Pero ver editar.
david.pfx
1

Las pruebas que sugiere en su pregunta no detectan un contador aritmético modular como implementación. Y no detectan errores de implementación comunes en el código relacionado con la distribución de probabilidad como return 1 + (random.randint(1,maxint) % sides). O un cambio en el generador que resulta en patrones bidimensionales.

Si realmente desea verificar que está generando números de distribución aleatoria uniformemente distribuidos, debe verificar una gran variedad de propiedades. Para hacer un trabajo razonablemente bueno, puede ejecutar http://www.phy.duke.edu/~rgb/General/dieharder.php en sus números generados. O escriba un conjunto de pruebas unitarias igualmente complejo.

Eso no es culpa de las pruebas unitarias o TDD, la aleatoriedad resulta ser una propiedad muy difícil de verificar. Y un tema popular para ejemplos.

Patricio
fuente
-1

La prueba más fácil de una tirada de dados es simplemente repetirlo cientos de miles de veces y validar que cada resultado posible se golpeó aproximadamente (1 / número de lados). En el caso de un dado de 6 lados, debería ver cada valor posible golpear alrededor del 16.6% del tiempo. Si alguno está apagado en más de un porcentaje, entonces tiene un problema.

Hacerlo de esta manera evita que le permita refactorizar la mecánica subyacente de generar un número aleatorio fácilmente, y lo más importante, sin cambiar la prueba.

ChristopherBrown
fuente
1
esta prueba pasaría por una implementación totalmente no aleatoria que simplemente recorre los lados uno por uno en un orden predefinido
mosquito
1
Si un codificador tiene la intención de implementar algo de mala fe (sin usar un agente aleatorio en un dado), y simplemente tratando de encontrar algo para 'hacer que las luces rojas se vuelvan verdes', tiene más problemas de los que las pruebas unitarias realmente pueden resolver.
ChristopherBrown