Hemos añadido un nuevo tipo de prueba de automatización a nuestro marco de prueba de automatización existente. Este nuevo tipo se conoce como Spec. «Spec» es un término que designa una prueba que se ha creado siguiendo la metodología de diseño guiado por comportamiento (DGC). Es una metodología muy común utilizada en las pruebas de desarrollo web, que adaptamos a nuestro marco C++.
Hay varios motivos para empezar a escribir Specs, como los siguientes:
- Se autodocumentan.
- Son fluidos y, a menudo, menos repetitivos, más DRY
DRY (no te repitas) - Son mucho más fáciles al escribir código de prueba latente o con subprocesos.
- Aislar expectativas (pruebas)
- Pueden utilizarse para casi todos los tipos de pruebas (funcionales, de integración y de unidad).
Cómo configurar una especificación
Hay dos métodos para definir la cabecera de tu spec, y ambos son similares al método existente que utilizamos para definir los tipos de prueba.
El método más sencillo es utilizar la macro DEFINE_SPEC, que toma exactamente los mismos parámetros que el resto de las macros de definición de prueba.
DEFINE_SPEC(MyCustomSpec, "MyGame.MyCustomSpec", EAutomationTestFlags::ProductFilter | EAutomationTestFlags::ApplicationContextMask)
void MyCustomSpec::Define()
{
//@todo escribir aquí mis expectativas
}
La única otra alternativa es utilizar las macros BEGIN_DEFINE_SPEC y END_DEFINE_SPEC. Estas macros te permiten definir tus propios miembros como parte de la prueba. Como verás en la siguiente sección, resulta valioso tener ciertas cosas relativas a este puntero.
BEGIN_DEFINE_SPEC(MyCustomSpec, "MyGame.MyCustomSpec", EAutomationTestFlags::ProductFilter | EAutomationTestFlags::ApplicationContextMask)
TSharedPtr<FMyAwesomeClass> AwesomeClass;
END_DEFINE_SPEC(MyCustomSpec)
void MyCustomSpec::Define()
{
//@todo escribir aquí mis expectativas
}
La única otra llamada es que tienes que escribir la implementación para el miembro Define() de tu clase Spec, en lugar del miembro RunTests(), como harías con cualquier otro tipo de prueba.
Las specs deben definirse en un archivo con la extensión .spec.cpp. y no tener la palabra «Test» en el nombre. Por ejemplo, la clase FItemCatalogService podría tener el archivo ItemCatalogService.h, ItemCatalogService.cpp e ItemCatalogService.spec.cpp.
Se trata de una directriz sugerida y no de una restricción técnica.
Cómo definir tus expectativas
Una gran parte de BDD es que, en lugar de probar una implementación específica, estás probando las expectativas de una API pública. Esto hace que tu prueba sea mucho menos frágil y, por tanto, más fácil de mantener, y es más probable que funcione si surgen varias implementaciones diferentes de la misma API.
En una Spec, defines tus expectativas mediante el uso de dos funciones primarias diferentes, Describe() e It().
Describe
Describe() se utiliza para delimitar el ámbito de expectativas complicadas, de modo que sean más legibles y con menos repeticiones. El uso de Describe() hace que tu código sea menos repetitivo en función de la interacción que tiene con otras funciones de apoyo como BeforeEach() y AfterEach(), lo cual se trata a continuación:
void Describe(const FString& Description, TFunction<void()> DoWork)
Describe() toma una cadena que describe el ámbito de las expectativas que contiene, y una lambda que define esas expectativas.
Puedes poner Describe() en cascada con un Describe() en otro Describe().
Ten en cuenta que Describe() no es una prueba y no se ejecuta durante una ejecución de prueba de verdad. Solo se ejecutan una vez, cuando se definen por primera vez las expectativas (o pruebas) dentro de la Spec.
Eso
It() es el bit de código que define una expectativa real de la Spec. Puedes llamar a It() desde el método raíz define() o desde cualquier lambda Describe(). Lo ideal es utilizar It() solo para hacer valer la expectativa, pero también puede utilizarse para establecer la configuración final del caso que se está probando.
Por lo general, lo mejor es comenzar una cadena de descripción de llamada It() con la palabra «should», lo cual significa «debería».
Cómo definir una expectativa básica
Aquí tienes un ejemplo de cómo ponerlo todo junto para definir una expectativa muy sencilla:
BEGIN_DEFINE_SPEC(MyCustomSpec, "MyGame.MyCustomClass", EAutomationTestFlags::ProductFilter | EAutomationTestFlags::ApplicationContextMask)
TSharedPtr<FMyCustomClass> CustomClass;
END_DEFINE_SPEC(MyCustomSpec)
void MyCustomSpec::Define()
{
Describe("Execute()", [this]()
{
It("should return true when successful", [this]()
{
TestTrue("Execute", CustomClass->Execute());
});
It("should return false when unsuccessful", [this]()
{
TestFalse("Execute", CustomClass->Execute());
});
});
}
Como puedes ver, esto hace que las pruebas se autodocumenten, sobre todo si el programador se toma el tiempo de describir la expectativa correctamente sin combinar expectativas dispares. La intención es que la combinación de todas las llamadas Describe() e It() genere una frase legible en su mayor parte, por ejemplo:
Execute() should return true when successful
Execute() should return false when unsuccessful
El siguiente es un ejemplo más complicado de cómo se ve actualmente una especificación madura en la IU de la prueba de automatización:
En este ejemplo, Driver, Element y Click son cada uno llamadas Describe(), con los distintos mensajes «should…» definidos por llamadas It().
Cada una de estas llamadas It() se convierte en una prueba individual que ejecutar y, por tanto, puede ejecutarse de forma aislada si una falla y las otras no. Esto facilita el mantenimiento de las pruebas porque es menos engorroso depurarlas. Además, como las pruebas se autodocumentan y están aisladas: cuando una falla, la persona que lee el informe de la prueba tiene una comprensión mucho más específica de lo que no funciona, en lugar de solo saber que un contenedor muy grande llamado Core ha fallado. Esto significa que los problemas se direccionan más rápido a la persona adecuada y se dedica menos tiempo a investigar problemas.
Por último, hacer clic en cualquiera de las pruebas anteriores te llevará directamente a la declaración It() que la define.
Cómo trasladar la expectativa de una Spec a una prueba
A continuación se ofrece una explicación detallada; sin embargo, comprender el comportamiento subyacente del tipo de prueba Spec hará que algunas de las siguientes funciones complejas sean más fáciles de entender.
La prueba de tipo Spec ejecuta la función raíz Define() una vez, pero no hasta que sea necesario. Mientras se ejecuta, recopila todas las lambdas que no sean Describe. Una vez finalizada la función Define(), vuelve a recorrer todas las lambdas o bloques de código que ha recopilado y genera una matriz de órdenes latentes para cada It().
Por lo tanto, cada bloque de código lambda BeforeEach(), It() y AfterEach() se junta en una cadena de ejecución para una única prueba. Cuando se le pide que ejecute una prueba específica, el tipo de prueba Spec pondrá en cola todas las órdenes de esa prueba en particular para su ejecución. Cuando esto ocurre, cada bloque no continúa hasta que el bloque anterior haya indicado que se ha terminado de ejecutar.
Otras funciones
El tipo de prueba Spec ofrece otras muchas funciones que facilitan la escritura de pruebas complicadas. En concreto, suele eliminar la necesidad de utilizar directamente el sistema de órdenes latentes del marco de pruebas de automatización, que es potente y engorroso.
Aquí tienes una lista de funciones compatibles con el tipo de prueba Spec que pueden ayudar en las situaciones más complicadas:
- BeforeEach y AfterEach
- AsyncExecution
- Latent Completion
- Pruebas parametrizadas
- Redefine
- Deshabilitación de pruebas
BeforeEach y AfterEach
BeforeEach() y AfterEach() son funciones básicas para escribir cualquier cosa más allá de la Spec más trivial. BeforeEach() te permite ejecutar el código antes de que se ejecute el siguiente código It(). AfterEach() hace lo mismo, pero ejecutará el código después de que se ejecute el código de It().
Recuerda que cada «prueba» solo se compone de una única llamada a It().
Por ejemplo:
BEGIN_DEFINE_SPEC(AutomationSpec, "System.Automation.Spec", EAutomationTestFlags::SmokeFilter | EAutomationTestFlags::ApplicationContextMask)
FString RunOrder;
END_DEFINE_SPEC(AutomationSpec)
void AutomationSpec::Define()
{
Describe("A spec using BeforeEach and AfterEach", [this]()
{
BeforeEach([this]()
{
RunOrder = TEXT("A");
});
It("ejecutará código antes de cada especificación en Describe y después de cada especificación en Describe", [this]()
{
TestEqual("RunOrder", RunOrder, TEXT("A"));
});
AfterEach([this]()
{
RunOrder += TEXT("Z");
TestEqual("RunOrder", RunOrder, TEXT("AZ"));
});
});
}
En nuestro ejemplo, los bloques de código se ejecutan de arriba abajo, debido a que se define BeforeEach(), a continuación, It() y, por último, AfterEach(). Aunque no es un requisito, te sugerimos que mantengas este orden lógico de la llamada. Pero podrías mezclar el orden de las tres llamadas anteriores y el resultado produciría siempre la misma prueba.
También en el ejemplo anterior, se está comprobando una expectativa en AfterEach(), lo cual resulta anómalo y es un efecto secundario de probar el propio tipo de prueba Spec. Como tal, no recomendamos utilizar AfterEach() para otra cosa que no sea una limpieza.
También puedes hacer varias llamadas BeforeEach() y AfterEach(), que se llamarán en el orden en que estén definidas. Al igual que la primera llamada BeforeEach() se ejecuta antes de la segunda llamada BeforeEach(), AfterEach() se comporta de forma muy parecida, es decir, la primera llamada se ejecuta antes de la siguiente.
BeforeEach([this]()
{
RunOrder = TEXT("A");
});
BeforeEach([this]()
{
RunOrder += TEXT("B");
});
It("will run code before each spec in the Describe and after each spec in the Describe", [this]()
{
TestEqual("RunOrder", RunOrder, TEXT("AB"));
});
AfterEach([this]()
{
RunOrder += TEXT("Y");
TestEqual("RunOrder", RunOrder, TEXT("ABY"));
});
AfterEach([this]()
{
RunOrder += TEXT("Z");
TestEqual("RunOrder", RunOrder, TEXT("ABYZ"));
});
Además, BeforeEach() y AfterEach() se ven afectados por el ámbito de Describe() en el que se llaman. Solo se ejecutarán para las llamada de It() que estén dentro del ámbito en el que también se llaman.
Aquí tienes un ejemplo complicado, con llamadas mal ordenadas, que funciona correctamente.
BEGIN_DEFINE_SPEC(AutomationSpec, "System.Automation.Spec", EAutomationTestFlags::SmokeFilter | EAutomationTestFlags::ApplicationContextMask)
FString RunOrder;
END_DEFINE_SPEC(AutomationSpec)
void AutomationSpec::Define()
{
Describe("A spec using BeforeEach and AfterEach", [this]()
{
BeforeEach([this]()
{
RunOrder = TEXT("A");
});
AfterEach([this]()
{
RunOrder += TEXT("Z");
// Puede resultar en
// TestEqual("RunOrder", RunOrder, TEXT("ABCYZ"));
// o lo siguiente, en función de qué It() se esté ejecutando
// TestEqual("RunOrder", RunOrder, TEXT("ABCDXYZ"));
});
BeforeEach([this]()
{
RunOrder += TEXT("B");
});
Describe("while nested inside another Describe", [this]()
{
AfterEach([this]()
{
RunOrder += TEXT("Y");
});
It("will run all BeforeEach blocks and all AfterEach blocks", [this]()
{
TestEqual("RunOrder", RunOrder, TEXT("ABC"));
});
BeforeEach([this]()
{
RunOrder += TEXT("C");
});
Describe("while nested inside yet another Describe", [this]()
{
It("ejecutará todos los bloques BeforeEach y todos los bloques AfterEach", [this]()
{
TestEqual("RunOrder", RunOrder, TEXT("ABCD"));
});
AfterEach([this]()
{
RunOrder += TEXT("X");
});
BeforeEach([this]()
{
RunOrder += TEXT("D");
});
});
});
});
}
AsyncExecution
El tipo de prueba Spec también te permite definir fácilmente cómo se debe ejecutar un único bloque de código. Esto se hace simplemente pasando el tipo EAsyncExecution apropiado a la versión sobrecargada de BeforeEach(), It() o AfterEach().
Por ejemplo:
BeforeEach(EAsyncExecution::TaskGraph, [this]()
{
// configurar algunas cosas
));
It("should do something awesome", EAsyncExecution::ThreadPool, [this]()
{
// hacer algunas cosas
});
AfterEach(EAsyncExecution::Thread, [this]()
{
// se destruyen algunas cosas
));
Cada uno de los bloques de código anteriores se ejecutará de forma diferente pero en un orden secuencial garantizado. El bloque BeforeEach() se ejecutará como una tarea en el TaskGraph, It() se ejecutará en un subproceso abierto en el grupo de subprocesos, y AfterEach() creará su propio subproceso dedicado solo para ejecutar un bloque de código.
Estas opciones son muy útiles a la hora de simular casos sensibles a subprocesos, como con el controlador de automatización.
La función AsyncExecution se puede combinar con la función Latent Completion.
Latent Completion
A veces, hay que escribir una prueba que necesite realizar una acción que dure varios fotogramas, como cuando se realiza una consulta. En estos casos, puedes utilizar los miembros sobrecargados LatentBeforeEach(), LatentIt() y LatentAfterEach(). Cada uno de estos miembros es idéntico a las variaciones no latentes, excepto que sus expresiones lambda toman un delegado simple denominado Done.
Cuando se utilizan variaciones latentes, el tipo de prueba Spec no continuará la ejecución al siguiente bloque de código de la secuencia de prueba hasta que el bloque de código latente en ejecución activa invoque el delegado Done.
LatentIt("should return available items", [this](const FDoneDelegate& Done)
{
BackendService->QueryItems(this, &FMyCustomSpec::HandleQueryItemComplete, Done);
});
void FMyCustomSpec::HandleQueryItemsComplete(const TArray<FItem>& Items, FDoneDelegate Done)
{
TestEqual("Items.Num() == 5", Items.Num(), 5);
Done.Execute();
}
Como puedes ver en el ejemplo, puedes pasar el delegado Done como carga a otras llamada de retorno para que el código latente pueda acceder. Así, cuando se ejecute la prueba anterior, no se seguirá ejecutando ningún bloque de código AfterEach() para la prueba It() hasta que se ejecute el delegado Done, aunque el bloque de código It() ya haya finalizado su ejecución.
La función Latent Completion se puede combinar con la función AsyncExecution.
Pruebas parametrizadas
A veces, hay que crear pruebas basadas en datos. Y esto puede significar leer entradas de un archivo y generar pruebas a partir de esas entradas. Otras veces, puede ser simplemente ideal para reducir la duplicación de código. En cualquier caso, el tipo de prueba Spec permite realizar pruebas parametrizadas de una forma muy natural.
Describe("Basic Math", [this]()
{
for (int32 Index = 0; Index < 5; Index++)
{
It(FString::Printf(TEXT("should resolve %d + %d = %d"), índice, 2, índice + 2), [this, índice]()
{
TestEqual(FString::Printf(TEXT("%d + %d = %d"), índice, 2, índice + 2), índice + 2, índice + 2);
});
}
});
Como puedes ver en el ejemplo anterior, todo lo que tienes que hacer para crear pruebas parametrizadas es llamar dinámicamente a la otra función de Spec. Para ello, debes pasar los datos parametrizados como parte de la carga lambda mientras se genera una descripción única.
En algunos casos, el uso de pruebas parametrizadas podría crear simplemente un sobredimensionamiento de las pruebas. Puede ser una buena idea simplemente ejecutar todos los casos de la entrada como parte de una única prueba. Debes tener en cuenta el número de entradas y las pruebas resultantes que se producen. La principal ventaja de crear tus pruebas basadas en datos de forma parametrizada es que cada prueba se ejecuta de forma aislada, lo que facilita la reproducción.
Redefine
Cuando se trabaja con pruebas parametrizadas, a veces puede resultar conveniente en tiempo de ejecución realizar un cambio en un archivo externo que controle la entrada y hacer que las pruebas se actualicen automáticamente. Redefine() es un miembro del tipo de prueba Spec que, cuando se llama, vuelve a realizar el proceso Define(). Esto hace que todo el bloque de código para las pruebas se vuelva a recopilar y cotejar.
El método más cómodo para hacer lo anterior sería crear un bit de código que escuche los cambios del archivo de entrada y llamar a Redefine() en la prueba según sea necesario.
Deshabilitación de pruebas
Cada miembro Describe(), BeforeEach(), It() y AfterEach() del tipo de prueba Spec tiene una variación con una «x» antes. Por ejemplo, xDescribe(), xBeforeEach(), xIt() y xAfterEach(). Estas variaciones son una forma más sencilla de deshabilitar un bloque de código o Describe(). Si se utiliza xDescribe(), todo el código dentro de xDescribe() también está deshabilitado.
Esto puede ser más fácil que comentar las expectativas que necesitan iteración.
Ejemplos avanzados
Puedes encontrar ejemplos avanzados del tipo de prueba Spec en Engine/Plugins/Tests/AutomationDriverTests/Source/AutomationDriverTests/Private/AutomationDriver.spec.cpp. Esta especificación incluye actualmente más de ciento veinte expectativas y utiliza la mayoría de las funciones avanzadas en algún momento.
Nuestro equipo del iniciador también tiene numerosos usos avanzados del marco Spec, y uno de los usos más avanzados son las especificaciones escritas en torno a BuildPatchServices.