Pruebas unitarias, parte 2 - Pruebas unitarias MonoBehaviour

TOMEK PASZEK Anonymous
Jun 3, 2014|10 minutos
Pruebas unitarias, parte 2 - Pruebas unitarias MonoBehaviour
Para tu comodidad, tradujimos esta página mediante traducción automática. No podemos garantizar la precisión ni la confiabilidad del contenido traducido. Si tienes alguna duda sobre la precisión del contenido traducido, consulta la versión oficial en inglés de la página web.

Tal y como prometí en mi anterior entrada del blog Unit testing part 1 - Unit tests by the book, ésta está dedicada al diseño de MonoBehaviour teniendo en cuenta la testabilidad. MonoBehaviour es una clase especial que es manejada por Unity de una manera especial. Cada vez que intentes instanciar un derivado MonoBehaviour recibirás una advertencia diciendo que no está permitido. Siendo un buen boy-scout y no ignorando la advertencia (¡ignorar una advertencia es malo a largo plazo!) puede que te hayas hecho la pregunta, ¿cómo puedo burlarme de MonoBehaviour entonces? La buena noticia es que no tiene por qué hacerlo. Permítame presentarle a...

El Humilde Patrón de Objetos

Si ya ha intentado escribir pruebas, probablemente se haya topado con algunos de los enemigos naturales de las pruebas unitarias, como la interfaz de usuario, el código heredado, el mal diseño sin acceso al código fuente o las áreas con un alto grado de concurrencia. ¿Qué hace que estas piezas sean difíciles de probar? Lograr el aislamiento: separar lo que se está probando del contexto. Existen herramientas que pueden ayudar con el código heredado, pero para el código nuevo se puede utilizar un patrón muy sencillo: El Humilde Patrón de Objetos.

La idea de este patrón es muy sencilla. Siempre que quieras probar un componente que tenga dependencias difíciles de probar, extrae toda la lógica del componente a una clase separada y desacoplada (por lo tanto, comprobable) y luego haz referencia a ella. En otras palabras, el componente problemático (con una dependencia que hace miserable la vida de los autores de las pruebas) se convierte en una capa muy fina de código que tiene tan poco código lógico como sea posible con todas las operaciones lógicas delegadas a la clase recién creada.

De un estado en el que la prueba tiene una dependencia indirecta del componente no comprobable...

ejemplo-dependencia1

...llegamos a un estado en el que la prueba ni siquiera es consciente del código malo (bueno, simplemente no comprobable):

Eso es todo. Es una obviedad, la verdad.

Juegos frente a comprobabilidad

¿Qué hace que los juegos sean tan especiales en términos de código y comprobabilidad? ¿En qué se diferencia probar juegos de probar otro tipo de software? Personalmente, considero que los juegos son piezas de software bastante sofisticadas. Sería ingenuo decir que los juegos no son muy diferentes del software que utilizas a diario. En los juegos (con excepciones, claro) encontrarás gráficos brillantes y pulidos, música de fondo y otras muestras de sonido bien elaboradas. A menudo, los juegos necesitan manejar entradas en tiempo real, potencialmente procedentes de diversas fuentes, así como una serie de dispositivos de salida (resolución de lectura). Los requisitos no funcionales también pueden ser más estrictos en el caso de los juegos. Los juegos Multiplayer te exigirán una conexión de red fiable y sincronizada y, al mismo tiempo, el rendimiento necesario para mantener una velocidad de fotogramas constante.

Esto puede hacer que el sistema sea complejo y abarque muchos tipos de medios y tecnologías. Para mí, los juegos siempre fueron obras maestras del producto final de software, y algunos de ellos aspiraban a ser reconocidos como obras de arte (tanto en el aspecto clásico y visual como en el técnico, entre bastidores).

Unity frente a comprobabilidad

Toda esta complejidad tiene consecuencias para la arquitectura del código. Para nuestra desgracia, las arquitecturas de alto rendimiento suelen ir en contra de un buen diseño de código, una restricción que también puedes encontrar en Unity. Uno de los mecanismos principales que ha tenido que diseñarse de forma especial es el de MonoBehaviours. Si alguna vez te has preguntado por qué los callbacks en MonoBehaviour no se implementan con interfaces o herencia (como quizás sugiere el sentido común), es por razones de rendimiento(Ver la aclaración de Lucas Meijer en los comentarios). Sin entrar en detalles, esto también va en contra de la comprobabilidad de MonoBehaviour. El hecho de que no puedas instanciar un MonoBehaviour con el operador new prácticamente te prohíbe utilizar cualquier framework de mocking. Probablemente no sería una buena idea de todos modos con todas las cosas que están pasando detrás de la escena cada vez que se utiliza un MonoBehaviour. Interceptar este comportamiento generaría muchos problemas.

Usted frente a la comprobabilidad

Al final, todo depende de ti y de lo motivado que estés para escribir código comprobable. Muchos enfoques pueden resolver el mismo problema, pero sólo unos pocos funcionarán bien para la automatización de pruebas. Si quieres escribir código comprobable, a veces tendrás que escribir más código del que crees necesario. Si todavía está aprendiendo (¿no deberíamos estar aprendiendo toda nuestra vida, de todos modos?) o acaba de entrar en el camino de la aventura de la automatización de pruebas, puede encontrar algunas de las piezas de código o supuestos de diseño como una sobrecarga innecesaria. Sin embargo, se convierten en un hábito tan rápidamente que ni siquiera se dará cuenta cuando empiece a utilizar los diseños pro-automatización sin ni siquiera pensar en ello.

En esta entrada del blog, prometí mostrarte una forma de diseñar MonoBehaviour para poder probarlos después. No era del todo cierto, porque no vamos a probar los propios MonoBehaviour. Probablemente ya tengas una idea de cómo implementar el Humble Object Pattern a tu diseño para hacerlo más testeable pero, no obstante, déjame mostrarte la idea implementada en un proyecto real.

El ejemplo

Vamos a crear un caso de uso para el propósito de este ejemplo. Imagina un simple mando de jugador que se encarga de dirigir una nave espacial. Para simplificar el ejemplo, pongámoslo en un espacio-mundo 2D. Queremos que la nave espacial pueda volar en todas direcciones. Tiene un cañón que puede disparar directamente con balas (¿cohetes espaciales?) pero no con más frecuencia que una cadencia de tiro determinada. El número de balas también está limitado por la capacidad del portabalas, así que una vez que dispares todas tendrás que recargar. Para hacerlo más interesante, hagamos que la velocidad de movimiento dependa de la salud de la nave espacial.

Un MonoBehaviour que servirá como controlador para nuestra nave espacial podría tener este aspecto:

Imagen

En el callback FixedUpdate leemos la entrada y realizamos la acción dependiendo de los botones que haya pulsado el usuario. Para movernos alrededor de la nave necesitamos trasladar la posición de la nave con la velocidad constante según la dirección de los ejes. Como puedes ver en el código, las variables deltaX y deltaY son multiplicaciones de: Time.fixedDeltaTime, el valor del eje de entrada y la constante de velocidad que a su vez depende del nivel de salud.

En el evento Disparar1 (por ejemplo, clic con el botón izquierdo del ratón) queremos comprobar si es posible disparar la bala. En primer lugar, necesitamos que quede al menos una bala en el portabalas. En segundo lugar, queremos que la nave espacial sólo pueda disparar a cierta velocidad (en este caso, una vez cada medio segundo). Por lo tanto, comprobamos cuánto tiempo ha pasado desde que se disparó la última bala. Si estamos listos, engendramos la bala.

El evento Fire2 simplemente recargará el portabalas.

Para escribir pruebas unitarias para esta lógica, tenemos que superar dos problemas. La primera, como ya se ha dicho, es la clase MonoBehaviour no imitable de la que dependemos por herencia. El segundo problema es más general para el software en tiempo real. Nuestra lógica depende del tiempo (tasa de disparo) lo que hace imposible realizar pruebas unitarias ya que no podemos interceptar la clase estática Time de Unity. La buena noticia es que todo esto tiene solución.

Vamos a refactorizar un poco nuestro código aplicando una simple refactorización de extracción de métodos y teniendo en cuenta que los métodos lógicos no deben hacer referencia a la API de Unity (manejo de Input e instanciación de bullet en este caso). La dependencia temporal en la sentencia if, debe ser extraída a un método separado también. El resultado final debería ser más o menos así:

ejemplo2

Como puede ver, el método FixedUpdate aquí no hace nada más que pasar la entrada de los usuarios al método que hace la parte lógica. La comprobación de la cadencia de disparo se ha extraído al método CanFire, que genera el resultado "true" si ha transcurrido un tiempo determinado. Esta extracción es importante ya que nos permitirá escribir pruebas unitarias más tarde. Si pudiéramos imitar la clase SpaceshipMotor ahora mismo, simplemente interceptaríamos el método CanFire y haríamos que devolviera true o false cuando quisiéramos. Esto haría que la prueba fuera independiente del tiempo. Pero como no podemos imitar SpaceshipMotor porque hereda de MonoBehaviour, necesitamos aplicar el Humble Object Pattern.

¿Cómo lo hacemos? Simplemente necesitamos extraer todo el código lógico que no usa el API de Unity a una clase separada e introducir una referencia a ella en el SpaceshipMotor. Veamos la clase de nuevo y veamos qué extraer. TranformPosition e InstanciateBullet utilizan la API de Unity, pero todo lo demás puede extraerse. Sé que también existe la clase estática Time, pero volveré a ella más tarde.

La última cosa que queda por explicar antes de hacer la extracción real es cómo la lógica extraída se comunica con la API de Unity sin depender de ella. Aquí es donde entran en juego las interfaces. La clase con lógica tendrá una referencia a una interfaz, y no me importará la implementación real. Para simplificar las cosas, podemos implementar esas interfaces directamente en el propio MonoBehaviour. Veamos las 2 clases siguientes:

Ejemplo3
Ejemplo4

Empecemos con la clase SpaceshipMotor. La clase implementa algunas interfaces que se encargan de transformar la posición de nuestra nave espacial y de instanciar la bala respectivamente. La clase en sí tiene un campo que se refiere a la SpaceshipController que implementa toda la lógica ahora. La clase SpaceshipController no sabe nada del SpaceshipMotor y lo único que puede hacer es invocar métodos de las interfaces a las que hace referencia.

Unity no serializará las referencias a las interfaces. Si no te importa la serialización, simplemente pasa las referencias a la interfaz mientras construyes la clase SpaceshipController. De lo contrario, puede establecer las referencias en OnEnable callback que se llama cada vez después de la serialización ocurre. Sólo para que conste, toda la clase SpaceshipMotor se serializará de la forma habitual, sólo se perderán las referencias a la interfaz.

Habrás notado la referencia a la clase Time en SpaceshipMotor. Sé que dije que no debería haber ninguna referencia a la API de Unity aquí, pero lo dejé allí para demostrar un enfoque diferente para el manejo de dependencias temporales. Idealmente, podríamos simplemente pasar el valor Time.time como argumento a los métodos.

Para los aficionados a UML, este es el resultado final en forma de diagrama UML (simplificado):

ejemplo-uml1
Pruebas unitarias

Con la clase SpaceshipMotor desacoplada no hay nada que nos impida escribir algunas pruebas unitarias. Echa un vistazo a una de las pruebas:

Ejemplo5

La prueba valida que no puedes disparar si no te quedan balas. La prueba en sí está estructurada según el modelo Arrange-Act-Assert. En la parte de arreglos creamos mocks de objetos con los métodos GetGunMock y GetControllerMock. El GetControllerMock, además de crear un mock, anula el comportamiento del método CanFire para que devuelva siempre true. Esto elimina la dependencia temporal del objeto controlador. A continuación, establecemos el número de viñeta actual en 0. Después de eso, aplicamos Fire a la clase del controlador y afirmamos si Fire no ha sido llamado en la interfaz del controlador de la pistola.

Hay algunas pruebas más en el proyecto. Puedes cogerlo de aquí y jugar un poco con él. He utilizado NSubstitute para el objeto mocking. También enviamos una versión con Unity Test Tools. Las tres versiones del controlador que hemos discutido aquí se adjuntan en el proyecto.

Eso es todo por mi parte hoy. Espero que haya disfrutado de la lectura, ¡y feliz prueba!

Tomek