24 diciembre 2024

Testeo automatizado (TDD) en Godot - GdUnit4

El TDD es una metodología de desarrollo de software donde primero escribes las pruebas y luego el código necesario para que las pruebas pasen. Al tener que definir las pruebas primero, el TDD te obliga a plantearte qué funcionalidad quieres conseguir, antes de ponerte a tirar líneas de código. Tienes que diseñar qué entradas va a recibir tu prueba y qué salidas considerará como correctas. Esas entradas y salidas serán las de tu funcionalidad, y haberlas definido con antelación te permitirás implementar dicha funcionalidad de manera más limpia y encapsulada.

A muchos no les gusta esta metodología porque les resulta aburrido empezar por las pruebas y prefieren zambullirse en tirar líneas de código y probar luego manualmente. Suelen disculparse con que no pueden perder tiempo programando pruebas. El problema se lo encuentran cuando su proyecto empieza a ganar tamaño y complejidad e introducir nuevas funcionalidades se convierte en un infierno porque se rompen las funcionalidades anteriores. A menudo, esto pasa de manera inadvertida, porque resulta imposible probar manualmente toda la aplicación en cada cambio. Así que van acumulando errores ocultos en cada actualización. Para cuando descubren alguno de esos errores ya resulta muy complicado averiguar cuál de las actualizaciones lo provocó, lo que convierte en un calvario resolverlo.

Con TDD no pasa esto, porque las pruebas que diseñas para cada funcionalidad se añaden a la base de pruebas y pueden ejecutarse de nuevo automáticamente al incorporar nuevas funcionalidades. Cuando una nueva funcionalidad rompe algo de las anteriores, sus correspondientes pruebas fallarán, lo que servirá de piloto de alarma para señalar dónde está el problema e ir a tiro hecho a solucionarlo.

Los juegos son una aplicación más. TDD se le puede aplicar igual y por eso, todos los engines cuentan con frameworks de pruebas automatizadas. En este artículo nos centraremos en uno de los más populares en Godot Engine: GdUnit4.

GdUnit4 permite la automatización de pruebas usando tanto GdScript, como C#. Dado que yo uso este último para desarrollar en Godot, nos centraremos en él a lo largo de este artículo. Aun así, si eres usuario de GdScript te recomiendo que sigas leyendo porque muchos conceptos son similares en ese lenguaje. 

Instalación del plugin

Para instalarlo, tienes que ir a la pestaña AssetLib, de la parte superior del editor de Godot. Allí, debes introducir "gdunit4" en el buscador. Pincha en el resultado que te salga.

Búsqueda de GdUnit4 en el AssetLib

En la ventana emergente que te sale, pincha en "Download" y acepta la carpeta de instalación por defecto.

Hecho lo anterior, el plugin habrá quedado instalado en la carpeta de tu proyecto, pero estará deshabilitado. Para activarlo, tienes que ir a Project --> Project Settings... --> Plugins y activar la casilla Enabled.

Activación del plugin de GdUnit4
Activación del plugin de GdUnit4

Te recomiendo que reinicies Godot después de la activación del plugin, de otra manera puede que te salgan errores.

Configuración del entorno C#

La siguiente fase es configurar tu entorno C# para usar GdUnit4 sin problemas.

Para empezar, tienes que asegurarte de tener la versión 8.0 del .NET.

Luego, tienes que abrir tu archivo .csproj y asegurarte de hacer los siguientes cambios en la sección <PropertyGroup>:

  • Cambiar el TargetFramework a net8.0.
  • Añadir la etiqueta <LangVersion>11.0</LangVersion>
  • Añadir la etiqueta <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
Además, tienes que crear una sección <ItemGroup>, en caso de que no la tengas, y añadirle el siguiente contenido:

<ItemGroup>
        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
        <PackageReference Include="gdUnit4.api" Version="4.3.*" />
        <PackageReference Include="gdUnit4.test.adapter" Version="2.*" />
</ItemGroup>


Para que te hagas una idea del resultado, este es el contenido de mi fichero .csproj:

Contenido del fichero csproj adaptado a GdUnit4
Contenido del fichero csproj adaptado a GdUnit4

Llegados a este punto, si haces Rebuild de tu proyecto (bien desde el IDE o bien desde esquina superior derecha la pestaña MSBuild) y no te sale ningún error, significará que la configuración es correcta.

Configuración del IDE

Oficialmente, GdUnit4 puede ser usado desde Visual Studio, Visual Studio Code y Rider. La configuración necesaria varía en cada caso.

Uso Rider, así que centraré el ejemplo en ese IDE.

GdUnit4 espera encontrar una variable de entorno denominada GODOT_BIN con la ruta al ejecutable de Godot. Yo uso la herramienta GodotEnv de ChickenSoft para instalar y desinstalar las sucesivas versiones de Godot. Esto tiene la ventaja de que GodotEnv se encarga de mantener una variable de entorono llamada GODOT, con la ruta al ejecutable del editor. Así que lo que yo he hecho es crear una variable de entorno (de usuario, para no enredar con las de sistema) GODOT_BIN, que lo que hace es apuntar a la de GODOT. Se hace abriendo la ventana de Sistema del Windows, pulsando "Configuración avanzada del Sistema", pestaña "Opciones avanzadas", botón "Variables de entorno" y, en el apartado de "Variables de usuario" , botón "Nueva":

Configuración de la variable de entorno
Configuración de la variable de entorno

Si tu no usas GodotEnv, y careces de una variable previa con la ruta al ejecutable del editor, en el valor de la variable tendrás que poner esa ruta y acordarte de cambiarla cada vez que instales una nueva versión del editor.

Luego, en Rider, tendrás que asegurarte de tener el plugin de soporte a Godot activado (lo normal es que si está leyendo este artículo ya lo tengas) y de tener activado el soporte a los adaptadores de VSTest:

Configuración al soporte de los adaptadores de VSTest


En la captura anterior, he activado la casilla "Enable VSTest adapters support" y he incluido una línea con un asterisco en la lista "Projects with unit tests". Ojo a esto último que a mí se me pasó y el IDE no me identificaba los test como tales hasta que metí esta línea en la lista.

Para configurar la ejecución de los tests, debes crear un fichero .runsettings en la raíz de tu proyecto. A modo de ejemplo, el mío es el siguiente (copiado de la página de GdUnit4):

<?xml version="1.0" encoding="utf-8"?>

<RunSettings>

    <RunConfiguration>

        <MaxCpuCount>1</MaxCpuCount>

        <ResultsDirectory>./TestResults</ResultsDirectory>

        <TargetFrameworks>net7.0;net8.0</TargetFrameworks>

        <TestSessionTimeout>180000</TestSessionTimeout>

        <TreatNoTestsAsError>true</TreatNoTestsAsError>

    </RunConfiguration>


    <LoggerRunSettings>

        <Loggers>

            <Logger friendlyName="console" enabled="True">

                <Configuration>

                    <Verbosity>detailed</Verbosity>

                </Configuration>

            </Logger>

            <Logger friendlyName="html" enabled="True">

                <Configuration>

                    <LogFileName>test-result.html</LogFileName>

                </Configuration>

            </Logger>

            <Logger friendlyName="trx" enabled="True">

                <Configuration>

                    <LogFileName>test-result.trx</LogFileName>

                </Configuration>

            </Logger>

        </Loggers>

    </LoggerRunSettings>


    <GdUnit4>

        <!-- Additional Godot runtime parameters-->

        <Parameters></Parameters>

        <!-- Controls the Display name attribute of the TestCase. Allowed values are SimpleName and FullyQualifiedName.

             This likely determines how the test names are displayed in the test results.-->

        <DisplayName>FullyQualifiedName</DisplayName>

    </GdUnit4>

</RunSettings>

 

Para que Rider lea esa configuración, se lo tienes que decir en la configuración de su Test Runner:

Configuración de la ruta al .runsettings
Configuración de la ruta al .runsettings

Acabado todo lo anterior, tienes que reiniciar el Rider para que active la configuración.

Si quieres probar que la configuración es correcta, puedes crear una carpeta Tests en tu proyecto y, dentro de ella, un fichero de C# (lo puedes llamar ExampleTest.cs) con el siguiente contenido:

Contenido de Tests/ExampleTest.cs
Contenido de Tests/ExampleTest.cs

El ejemplo está pensado para que Success() sea una prueba exitosa y Failed() fracase.

Puedes ejecutar este test desde Rider o bien desde el editor de Godot.

Desde Rider, primero tienes que habilitar la pestaña de los tests pulsando View --> Tool Windows --> Tests. Desde la pestaña de tests puedes ejecutar un test en concreto o todos seguidos.

Pestaña para ejecutar tests en Rider
Pestaña para ejecutar tests en Rider

Para ejecutarlos desde Godot, tienes que ir a la pestaña de GdUnit.

Pestaña de ejecución de tests en Godot
Pestaña de ejecución de tests en Godot

Si la pestaña de Godot no mostrase los tests, podría ser que no estuviese buscando en la carpeta correcta. Para comprobarlo, debes pulsar en el botón de la herramientas de la esquina superior izquierda de la pestaña y asegurarte de que el parámetro "Test Lookup Folder" apunta a la carpeta donde tengas los tests.

Ruta a la carpeta con los tests
Ruta a la carpeta con los tests

Si aún así no te mostrase los test, te recomiendo que pruebes a reiniciar. Tanto Godot, como Rider, tienden a no detectar a veces los cambios y los nuevos tests. Cuando me pasa reinicio Godot o Rider (lo que me esté fallando) y entonces ya se detectan los cambios. Supongo que con el tiempo irán resolviendo esa problemática. En todo caso, en las pruebas que he ido haciendo, las que he hecho a través de Rider han funcionado muchísimo mejor que las que he realizado desde el editor de Godot mismo.

Prueba de un juego

Toda la configuración anterior ha sido ardua, pero lo bueno es que sólo hay que hacerla una vez. A partir de ahí se trata de ir creando tests y probándolos.

Hay múltiples cosas que probar en un juego. Las pruebas unitarias se centran en probar métodos y funciones concretas, yo voy a explicarte algo más amplio: las pruebas de integración. Estas prueban el juego al mismo nivel que lo haría un jugador, actuando sobre sus objetos y evaluando si la reacción del juego es la esperada. Para ello, es habitual crear niveles especiales de test en los que se concentran todas las funcionalidades para poder probarlas de una manera rápida.

Para entendernos, vamos a poner como ejemplo una prueba sencilla. Supongamos que tenemos un juego en el que hay un elemento (un agente inteligente) que tiene que desplazarse hasta la posición de un determinado marcador. Para comprobar el correcto funcionamiento del agente, lo colocaríamos en un extremo del escenario, al marcador en otro y esperaríamos uno segundos antes de evaluar la posición del agente. Si este se hubiera acercado lo suficiente al marcador, podríamos concluir que funciona correctamente.

El código Godot de este ejemplo lo puedes bajar de este commit concreto de uno de mis repositorios. Si lo abres en el editor de Godot, cargas la escena Tests/TestLevels/SimpleBehaviorTestLevel.tscn, la conviertes en la principal del juego (Project --> Project Settings...--> General --> Application - Run --> Main Scene) y ejecutas el juego, verás que el luego se comporta tal y como decíamos en el párrafo anterior: la mirilla roja se situará allá donde pinches el ratón y la bola verde se dirigirá a la posición de la mirilla. Por tanto, manualmente podemos comprobar que el juego se comporta como debe. Ahora vamos a comprobarlo de manera automatizada.

El juego que estamos probando
El juego que estamos probando

Lo primero es configurar el nivel con los elementos necesarios para facilitar la prueba. Estos elementos auxiliares no harán sino estorbar en los niveles a los que accedan los jugadores, y esa es la razón por la que generalmente se crean niveles específicos para los tests (por eso, este está en la carpeta Tests/TestLevels).

Nuestro nivel de pruebas tiene la siguiente estructura:

Estructura del nivel de pruebas
Estructura del nivel de pruebas

 Los elementos son los siguientes:

  • ClearCourtyard: Es la caja por la que se mueve nuestro agente. Un simple tilemap con el que he dibujado en gris oscuro las paredes, que no se pueden traspasar, y en gris claro el suelo, por el que nos desplazamos.
  • Target: Es una escena cuyo nodo principal es un Marker2D y por debajo tiene un Sprite2D con la imagen de la mirilla. El nodo principal tiene un script que escucha los eventos de Input y reacciona a la pulsación del botón izquierdo del ratón, situándose en la posición de la pantalla donde se haya hecho click.
  • SeekMovingAgent: Es el agente cuyo comportamiento queremos comprobar.
  • StartPosition1: Es la posición en la que queremos que se sitúe el agente al comenzar la prueba.
  • TargetPosition1: Es la posición en la que queremos que se sitúe el Target al comienzo de la prueba.
El código de nuestra prueba será uno o varios ficheros de C#, situados en la carpeta de tests. En este caso sólo tendremos un fichero, pero podemos tener varios si estamos probando diferentes funcionalidades. Cada fichero puede tener múltiples pruebas. Una prueba no es más que un método marcado con el atributo [TestCase]. La clase a la que pertenezca el método anterior debe estar marcada con el atributo [TestSuite] para que el test runner la tenga en cuenta al ejecutar la prueba. Nuestra prueba de ejemplo está en el fichero Tests/SimpleBehaviorTests.cs.

La primera mitad del fichero se dedica a los preparativos:


Como puedes ver en la línea 9, he guardado en una constante la ruta al nivel en el que queremos desarrollar la prueba. 

Dicho nivel lo arrancamos en la línea 17. Esa línea pertenece al método LoadScene(), el cual podemos llamar como queramos siempre y cuando lo marquemos con el atributo [BeforeTest] para señalar que queremos que LoadScene() se ejecute justo antes de cada prueba. De esta manera cargaremos el nivel desde el principio en cada prueba, con lo que nos aseguraremos de que sus elementos estén en el punto de partida cada vez y de que las pruebas anteriores no interfieren. Hay otros tributos que pueden resultarte útiles, como [Before], que ejecuta el método que decora una sola vez justo antes de que se ejecuten todas las pruebas de la clase. Puede ser útil para inicializar un recurso común a todas las pruebas y que no necesite ser reseteado entre una y otra. Por otro lado, para hacer limpia, existen las contrapartes, los atributos [AfterTest] y [After].

Una vez que tenemos una referencia al nivel recién arrancado podemos dar comienzo a la prueba:

El código de nuestra prueba
El código de nuestra prueba

Entre las líneas 24 y 30 recabamos referencias a los elementos de la prueba usando el método FindChild(). En cada una de sus llamadas pasamos el nombre que tenga el nodo que queremos obtener. Como FindChild() devuelve un tipo Node, tendremos que hacer cast al tipo real que sabemos que tiene.

Una vez que tenemos las referencias a los distintos elementos, colocaremos cada uno en su posición inicial correcta: _target en la posición marcada por _targetPosition (línea 33) y _movingAgent en la posición marcada por _agentStartPosition (línea 24). Podríamos haber situado a los elementos fijando su posición por código, pero en caso de tener que recolocarlos de nuevo siempre es más cómodo hacerlo de manera visual, ajustando la posición de los marcadores en el editor, que teniendo que modificar las posiciones en el código.

En la línea 37 activamos al agente para que empiece a moverse. En general, te recomiendo que mantengas desactivados tus agentes y sólo actives los imprescindibles para la prueba. Así, si estás probando varios en el mismo nivel, evitarás que unos entorpezcan a otros.

En la línea 39, nos sentamos a esperar (durante 2,5 segundos) para darle al agente el tiempo que hemos estimado que tardará en desplazarse a la posición del objetivo.

Finalizada esa espera, llegamos al momento de la verdad. Hay que evaluar si el agente ha llegado hasta la posición objetivo. Para eso, en la línea 41, medimos la distancia entre el agente y su objetivo. Si la distancia es menor que un umbral muy pequeñito (recuerda que en matemáticas de punto flotante no podemos hacer comparaciones por igualdad) entonces podemos suponer que el agente se encuentra correctamente situado en la posiión del objetivo (línea 43).

Conclusión


Como la anterior, podemos hacer multitud de pruebas. Lo normal es meter en una misma clase (fichero .cs) todas las que correspondan a las múltiples facetas de una misma funcionalidad.

La prueba de este ejemplo ha sido muy visual, porque evaluaba el movimiento, a través del cambio de posición. Pero habrá pruebas en las que quieras comprobar que un valor interno del agente resulte ser el correcto. En ese caso, lo que evaluaremos en nuestros Assert serán los valores de las propiedades del agente.

No quiero acabar sin enlazarte la página de documentación de GdUnit4. Parte del uso de GdScript pero ha empezado a adaptarse a C# desde el momento que GdScript empezó a ofrecer soporte en ese lenguaje. Hay secciones específicas con las peculiaridades de instalación de GdUnit4 para C# y los códigos de ejemplo tienen pestañas con ambos lenguajes. Se trata de una documentación muy completa y útil.