Pruebas en C ++ sin macros y memoria dinámica.

Muchas bibliotecas populares para pruebas, por ejemplo, Google Test, Catch2, Boost.Test, están fuertemente ligadas al uso de macros, por lo que, como ejemplo de pruebas en estas bibliotecas, generalmente ves una imagen como esta:


namespace { // Tests the default c'tor. TEST(MyString, DefaultConstructor) { const MyString s; EXPECT_STREQ(nullptr, s.c_string()); EXPECT_EQ(0u, s.Length()); } const char kHelloString[] = "Hello, world!"; // Tests the c'tor that accepts a C string. TEST(MyString, ConstructorFromCString) { const MyString s(kHelloString); EXPECT_EQ(0, strcmp(s.c_string(), kHelloString)); EXPECT_EQ(sizeof(kHelloString)/sizeof(kHelloString[0]) - 1, s.Length()); } // Tests the copy c'tor. TEST(MyString, CopyConstructor) { const MyString s1(kHelloString); const MyString s2 = s1; EXPECT_EQ(0, strcmp(s2.c_string(), kHelloString)); } } // namespace 

Las macros en C ++ son cautelosas, ¿por qué son tan prósperas en las bibliotecas para crear pruebas?


La biblioteca de pruebas unitarias debe proporcionar a sus usuarios una forma de escribir pruebas para que el tiempo de ejecución de la prueba pueda encontrarlas y ejecutarlas de alguna manera. Cuando piensa en cómo hacer esto, usar macros parece ser más fácil. La macro TEST () generalmente define de alguna manera una función (en el caso de Google Test, la macro también crea una clase) y asegura que la dirección de esta función ingrese en algún contenedor global.


La biblioteca bien conocida en la que se implementa el enfoque sin una sola macro es el tut-framework . Veamos su ejemplo del tutorial:


 #include <tut/tut.hpp> namespace tut { struct basic{}; typedef test_group<basic> factory; typedef factory::object object; } namespace { tut::factory tf("basic test"); } namespace tut { template<> template<> void object::test<1>() { ensure_equals("2+2=?", 2+2, 4); } } 

La idea que subyace es bastante interesante y funciona, no es muy difícil. En resumen, tiene una clase base que implementa una función de plantilla que implica la parametrización con un entero:


 template <class Data> class test_object : public Data { /** * Default do-nothing test. */ template <int n> void test() { called_method_was_a_dummy_test_ = true; } } 

Ahora cuando escribes tal prueba:


 template<> template<> void object::test<1>() { ensure_equals("2+2=?", 2+2, 4); } 

En realidad, crea una especialización de método de prueba para un número específico N = 1 (esto es exactamente lo que significa template<>template<> ). Al llamar a la test<N>() el tiempo de ejecución de la prueba puede comprender si se trata de una prueba real o si se trata de un código auxiliar que called_method_was_a_dummy_test_ el valor called_method_was_a_dummy_test_ después de called_method_was_a_dummy_test_ la prueba.


A continuación, cuando declara un grupo de prueba:


 tut::factory tf("basic test"); 

En primer lugar, enumera todas las test<N> a una constante constante conectada a la biblioteca y, en segundo lugar, agrega información de grupo al contenedor global (nombre de grupo y direcciones de todas las funciones de prueba).


Las excepciones se utilizan como condiciones de prueba en tut, por lo que la función tut::ensure_equals() simplemente arrojará una excepción si los dos valores que se le pasan no son iguales, y el entorno de ejecución de la prueba detectará una excepción y considerará que la prueba ha fallado. Me gusta este enfoque, queda inmediatamente claro para cualquier desarrollador de C ++ dónde se pueden usar tales afirmaciones. Por ejemplo, si mi prueba creó un hilo auxiliar, entonces es inútil colocar aserciones allí, nadie las atrapará. Además, tengo claro que mi prueba debería ser capaz de liberar recursos en caso de una excepción, como si fuera un código ordinario seguro para excepciones.


En principio, la biblioteca tut-framework se ve bastante bien, pero su implementación tiene algunos inconvenientes. Por ejemplo, para mi caso, me gustaría que la prueba tenga no solo un número, sino también otros atributos, en particular el nombre, así como el "tamaño" de la prueba (por ejemplo, ¿es una prueba de integración o es una prueba unitaria?). Esto puede resolverse dentro del marco de la API tut, e incluso algo ya existe, y algo puede implementarse si agrega un método a la API de la biblioteca y lo llama al cuerpo de la prueba para establecer cualquiera de sus parámetros:


 template<> template<> void object::test<1>() { set_name("2+2"); // Set test name to be shown in test report ensure_equals("2+2=?", 2+2, 4); } 

Otro problema es que el entorno de ejecución de prueba tut no sabe nada sobre un evento como el inicio de una prueba. El entorno ejecuta object::test<N>() y no sabe de antemano si la prueba se implementa para un N dado, o es solo un trozo. Solo called_method_was_a_dummy_test_ cuándo termina la prueba analizando el valor called_method_was_a_dummy_test_ . Esta característica no se muestra muy bien en los sistemas de CI, que pueden agrupar la salida que realizó el programa entre el comienzo y el final de la prueba.


Sin embargo, en mi opinión, lo principal que se puede mejorar (un "defecto fatal") es la presencia de un código auxiliar adicional requerido para escribir pruebas. Hay muchas cosas en el tutorial tut-framework: se propone crear primero una determinada struct basic{} clase struct basic{} , y describir las pruebas como métodos de objeto asociados con esto. En esta clase, puede definir los métodos y los datos que desea usar en el grupo de prueba, y el constructor y el destructor enmarcan la ejecución de la prueba, creando un elemento de jUnit. En mi práctica con tut, este objeto casi siempre está vacío, pero arrastra una cierta cantidad de líneas de código.


Entonces, vamos al taller de bicicletas y tratamos de organizar la idea en forma de una pequeña biblioteca.


Así es como se ve el archivo de prueba mínimo en la biblioteca probada:


 // Test group for std::vector (illustrative purposes) #include "tested.h" #include <vector> template<> void tested::Case<CASE_COUNTER>(tested::IRuntime* runtime) { runtime->StartCase("emptiness"); std::vector<int> vec; tested::Is(vec.empty(), "Vector must be empty by default"); } template<> void tested::Case<CASE_COUNTER>(tested::IRuntime* runtime) { runtime->StartCase("AddElement"); std::vector<int> vec; vec.push_back(1); tested::Is(vec.size() == 1); tested::Is(vec[0] == 1); tested::FailIf(vec.empty()); } void LinkVectorTests() { static tested::Group<CASE_COUNTER> x("std.vector", __FILE__); } 

Además de la falta de macros, la ventaja es la ausencia del uso de memoria dinámica dentro de la biblioteca.


Definición de casos de prueba


Para el registro de las pruebas, la magia elemental de nivel de entrada se utiliza según el mismo principio que tut. En algún lugar de testing.h hay una función repetitiva de este tipo:


 template <int N> static void Case(IRuntime* runtime) { throw TheCaseIsAStub(); } 

Los casos de prueba escritos por los usuarios de la biblioteca son simplemente especializaciones de este método. La función se declara estática, es decir En cada unidad de traducción, creamos especializaciones que no se cruzan por nombre entre sí durante el enlace.


Existe una regla que primero debe llamar a StartCase() , a la que puede pasar cosas como el nombre de la prueba y tal vez algunas otras cosas que aún están en desarrollo.


Cuando una prueba llama a tiempo de runtime->StartTest() , pueden suceder cosas interesantes. En primer lugar, si las pruebas están ahora en modo de ejecución, puede decir en algún lugar que la prueba ha comenzado a ejecutarse. En segundo lugar, si hay un modo de recopilar información sobre las pruebas disponibles, StartTest() lanzará un tipo especial de excepción que significará que la prueba es real y no un trozo.


Registro


En algún momento, debe recopilar las direcciones de todos los casos de prueba y colocarlas en algún lugar. En probado, esto se hace usando grupos. El constructor de la clase probada :: Grupo hace esto como un efecto secundario:


 static tested::Group<CASE_COUNTER> x("std.vector", __FILE__); 

El constructor crea un grupo con el nombre especificado y agrega todos los Case<N> que encuentra en la unidad de traducción actual. Resulta que en una unidad de traducción no puedes tener dos grupos. También significa que no puede dividir un grupo en varias unidades de traducción.


El parámetro de la plantilla es cuántos casos de prueba buscar en la unidad de traducción actual para el grupo creado.


Enlace


En el ejemplo anterior, la creación del objeto probado :: Grupo () ocurre dentro de la función que debemos llamar desde nuestra aplicación para registrar las pruebas:


 void LinkStdVectorTests() { static tested::Group<CASE_COUNTER> x("std.vector", __FILE__); } 

No siempre se requiere una función, a veces simplemente puede declarar un objeto de la clase tested::Group dentro de un archivo. Sin embargo, mi experiencia es que el enlazador a veces "optimiza" todo el archivo si está ensamblado dentro de la biblioteca, y ninguna de las aplicaciones principales utiliza ningún carácter de este archivo cpp:


 calc.lib <- calc_test.lib(calc_test.cpp) ^ ^ | | app.exe run_test.exe 

Cuando calc_test.cpp no ​​está vinculado desde la fuente run_test.exe, el vinculador simplemente elimina completamente este archivo, junto con la creación de un objeto estático, a pesar del hecho de que tiene los efectos secundarios que necesitamos.


Si la cadena resulta de run_test.exe, el objeto estático aparecerá en el archivo ejecutable. Y no importa exactamente cómo se hace esto, como en el ejemplo:


 void LinkStdVectorTests() { static tested::Group<CASE_COUNTER> x("std.vector", __FILE__); } 

más o menos:


 static tested::Group<CASE_COUNTER> x("std.vector", __FILE__); void LinkStdVectorTests() { } 

La primera opción, en mi opinión, es mejor porque se llama al constructor después del inicio de main (), y la aplicación tiene cierto control sobre este proceso.


Creo que esta configuración de muletas es necesaria para cualquier biblioteca de pruebas unitarias que use variables globales y efectos secundarios del constructor para crear una base de datos de prueba. Sin embargo, probablemente se pueda evitar vinculando la biblioteca de prueba con la clave --whole-archive (un análogo en MSVC apareció solo en Visual Studio 2015.3).


Macros


Prometí que no habrá macros, pero es - CASE_COUNTER . La opción de trabajo es que __COUNTER__ , una macro que el compilador incrementa en uno cada vez que se usa dentro de la unidad de traducción.
Compatible con GCC, CLANG, MSVC, pero no con el estándar. Si esto es frustrante, aquí hay algunas alternativas:


  • usa los números 0, 1, 2
  • use el estándar __LINE__ .
  • usa constexpr magia del nivel 80. Puede buscar el "contador constexpr" e intentar encontrar el compilador en el que funcionará.

El problema con __LINE__ es que el uso de números grandes en las opciones de plantilla crea un gran tamaño de archivo ejecutable. Es por eso que limité el tipo de patrón de caracteres con signo a 128 como el número máximo de pruebas en el grupo.


Fallo de la memoria dinámica.


Resultó que al registrar las pruebas, no puede usar la memoria dinámica, que yo usé. Es posible que su entorno no tenga memoria dinámica o use la búsqueda de pérdidas de memoria en casos de prueba, por lo que la intervención del entorno de ejecución de prueba no es lo que necesita. Google Test está luchando con esto, aquí hay un fragmento de allí:


 // Use the RAII idiom to flag mem allocs that are intentionally never // deallocated. The motivation is to silence the false positive mem leaks // that are reported by the debug version of MS's CRT which can only detect // if an alloc is missing a matching deallocation. // Example: // MemoryIsNotDeallocated memory_is_not_deallocated; // critical_section_ = new CRITICAL_SECTION; class MemoryIsNotDeallocated 

Y simplemente no podemos crear dificultades.


¿Cómo entonces obtenemos una lista de pruebas? Estas son partes internas más técnicas, que son más fáciles de ver en el código fuente, pero te lo diré de todos modos.


Al crear un grupo, su clase recibirá un puntero a la función tested::CaseCollector<CASE_COUNTER>::collect , que reunirá todas las pruebas de unidad de traducción en una lista. Así es como funciona:


 // Make the anonymouse namespace to have instances be hidden to specific translation unit namespace { template <Ordinal_t N> struct CaseCollector { // Test runtime that collects the test case struct CollectorRuntime final : IRuntime { void StartCase(const char* caseName, const char* description = nullptr) final { // the trick is exit from test case function into the collector via throw throw CaseIsReal(); } }; // Finds the Case<N> function in current translation unit and adds into the static list. It uses the // reverse order, so the case executed in order of appearance in C++ file. static CaseListEntry* collect(CaseListEntry* tail) { CaseListEntry* current = nullptr; CollectorRuntime collector; try { Case<N>(&collector); } catch (CaseIsStub) { current = tail; } catch (CaseIsReal) { s_caseListEntry.CaseProc = Case<N>; s_caseListEntry.Next = tail; s_caseListEntry.Ordinal = N; current = &s_caseListEntry; } return CaseCollector<N - 1>::collect(current); } private: static CaseListEntry s_caseListEntry; }; // This static storage will be instantiated in any cpp file template <Ordinal_t N> CaseListEntry CaseCollector<N>::s_caseListEntry; } 

Resulta que en cada unidad de traducción se crean muchas variables estáticas del tipo CaseListEntry CaseCollector \ :: s_caseListEntry, que son elementos de la lista de prueba, y el método collect () recopila estos elementos en una lista conectada individualmente. Aproximadamente de la misma manera, la lista forma grupos de pruebas, pero sin patrones ni recursividad.


Estructura


Las pruebas necesitan un enlace diferente, como la salida a la consola en letras rojas Falló, creando informes de prueba en un formato comprensible para CI o GUI en el que puede ver la lista de pruebas y ejecutar las seleccionadas, en general, muchas cosas. Tengo una visión de cómo se puede hacer esto, que es diferente de lo que vi anteriormente en la biblioteca de pruebas. El reclamo es principalmente para las bibliotecas que se autodenominan "solo encabezado", e incluyen una gran cantidad de código, que esencialmente no es para archivos de encabezado.


El enfoque que supongo es que dividimos la biblioteca en front-end: esto se prueba. Hy las bibliotecas de back-end. Para escribir pruebas, solo necesita testing.h, que ahora es C ++ 17 (debido a std :: std :: string_view) pero se supone que habrá C ++ 98. Tested.h realmente realiza el registro y la búsqueda de pruebas, una opción de inicio mínimamente conveniente, así como la capacidad de exportar pruebas (grupos, direcciones de funciones de casos de prueba). Las bibliotecas de back-end que aún no existen pueden hacer lo que necesiten en términos de generar resultados y lanzarse utilizando la funcionalidad de exportación. Del mismo modo, puede adaptar el lanzamiento a las necesidades de su proyecto.


Resumen


La biblioteca probada ( código github ) todavía necesita algo de estabilización. En un futuro cercano, agregue la capacidad de ejecutar pruebas asincrónicas (necesarias para las pruebas de integración en WebAssembly) e indique el tamaño de las pruebas. En mi opinión, la biblioteca aún no está lista para su uso en producción, pero de repente pasé mucho tiempo y el escenario se detuvo, respiró y solicitó comentarios de la comunidad. ¿Te interesaría usar este tipo de biblioteca? ¿Quizás haya otras ideas en el arsenal de C ++, ya que sería posible crear una biblioteca sin macros? ¿Es interesante tal afirmación del problema?

Source: https://habr.com/ru/post/es434906/


All Articles