software - ¿Cómo se introducen las pruebas unitarias en una base de código heredada(C/C++) grande?
pruebas unitarias e integrales (12)
Tenemos una aplicación grande y multiplataforma escrita en C. (con una cantidad pequeña pero creciente de C ++). Ha evolucionado a lo largo de los años con muchas características que cabría esperar en una gran aplicación C / C ++:
-
#ifdef
infierno - Archivos grandes que dificultan aislar el código comprobable
- Funciones que son demasiado complejas para ser fácilmente comprobables
Debido a que este código está dirigido a dispositivos integrados, la ejecución del objetivo real implica una gran cantidad de gastos indirectos. Entonces nos gustaría hacer más de nuestro desarrollo y prueba en ciclos rápidos, en un sistema local. Pero nos gustaría evitar la clásica estrategia de "copiar / pegar en un archivo .c en su sistema, corregir errores, copiar / pegar de nuevo". Si los desarrolladores van a tomarse la molestia de hacer eso, nos gustaría poder volver a crear las mismas pruebas más adelante y ejecutarlas de manera automatizada.
Aquí está nuestro problema: para refactorizar el código para que sea más modular, necesitamos que sea más comprobable. Pero para introducir pruebas unitarias automatizadas, necesitamos que sea más modular.
Un problema es que, dado que nuestros archivos son tan grandes, podríamos tener una función dentro de un archivo que llame a una función en el mismo archivo que necesitamos anular para hacer una buena prueba de unidad. Parece que esto sería un problema menor ya que nuestro código se vuelve más modular, pero eso está muy lejos.
Una cosa que pensamos hacer fue etiquetar el código fuente "conocido para ser comprobado" con comentarios. Luego podríamos escribir una secuencia de comandos para escanear los archivos fuente para el código comprobable, compilarlo en un archivo separado y vincularlo con las pruebas unitarias. Podríamos introducir lentamente las pruebas unitarias a medida que solucionamos los defectos y agregamos más funcionalidad.
Sin embargo, existe la preocupación de que el mantenimiento de este esquema (junto con todas las funciones de stub necesarias) se convierta en una molestia, y los desarrolladores dejarán de mantener las pruebas unitarias. Entonces, otro enfoque es usar una herramienta que genere automáticamente stubs para todo el código y vincule el archivo con eso. (La única herramienta que hemos encontrado que hará esto es un producto comercial costoso). Pero este enfoque parece requerir que todo nuestro código sea más modular antes de que podamos comenzar, ya que solo las llamadas externas pueden ser anuladas.
Personalmente, prefiero que los desarrolladores piensen en sus dependencias externas y escriban de manera inteligente sus propios stubs. Pero esto podría ser abrumador para eliminar todas las dependencias de un archivo terriblemente cubierto de 10.000 líneas. Puede ser difícil convencer a los desarrolladores de que necesitan mantener talones para todas sus dependencias externas, pero ¿es esa la forma correcta de hacerlo? (Otro argumento que he escuchado es que el mantenedor de un subsistema debería mantener los stubs para su subsistema. Pero me pregunto si "forzar" a los desarrolladores a escribir sus propios stubs conduciría a mejores pruebas unitarias?)
Los #ifdefs
, por supuesto, agregan otra dimensión completa al problema.
Hemos analizado varios frameworks de pruebas unitarias basados en C / C ++, y hay muchas opciones que se ven bien. Pero no hemos encontrado nada para facilitar la transición de "bola de pelo de código sin pruebas de unidad" a "código de prueba de unidad".
Así que aquí están mis preguntas a cualquier persona que haya pasado por esto:
- ¿Cuál es un buen punto de partida? ¿Vamos en la dirección correcta o nos falta algo obvio?
- ¿Qué herramientas podrían ser útiles para ayudar con la transición? (preferiblemente fuente gratuita / abierta, ya que nuestro presupuesto en este momento es aproximadamente "cero")
Tenga en cuenta que nuestro entorno de compilación está basado en Linux / UNIX, por lo que no podemos usar ninguna herramienta de solo Windows.
no hemos encontrado nada para facilitar la transición de "bola de pelo de código sin pruebas de unidad" a "código de prueba de unidad".
Qué triste, ninguna solución milagrosa, solo un montón de trabajo arduo corrigiendo años de deuda técnica acumulada.
No hay una transición fácil. Usted tiene un problema grande, complejo y serio.
Solo puedes resolverlo en pequeños pasos. Cada pequeño paso implica lo siguiente.
Elija una pieza discreta de código que es absolutamente esencial. (No mordisquee los bordes en la basura.) Elija un componente que sea importante y, de alguna manera, se puede tallar del resto. Si bien una sola función es ideal, puede ser un conjunto de funciones enredado o tal vez un archivo completo de funciones. Está bien comenzar con algo menos que perfecto para sus componentes comprobables.
Averigua qué se supone que debe hacer. Averigua qué se supone que es su interfaz. Para hacer esto, puede que tenga que hacer una refactorización inicial para hacer que su pieza objetivo sea realmente discreta.
Escriba una prueba de integración "general" que, por ahora, pruebe su código discreto más o menos tal como se encontró. Haz que pase esto antes de intentar cambiar algo significativo.
Refactorice el código en unidades ordenadas y comprobables que tengan más sentido que su bola de pelo actual. Tendrá que mantener cierta compatibilidad con versiones anteriores (por ahora) con su prueba de integración general.
Escribir pruebas unitarias para las nuevas unidades.
Una vez que todo pase, retire la antigua API y arregle lo que se romperá con el cambio. Si es necesario, vuelva a trabajar la prueba de integración original; prueba la antigua API, quieres probar la nueva API.
Iterar.
Como dijo George, Trabajar eficazmente con Legacy Code es la biblia para este tipo de cosas.
Sin embargo, la única forma en que los demás en su equipo comprarán es si ven personalmente el beneficio de mantener las pruebas funcionando.
Para lograr esto, necesita un marco de prueba con el que sea lo más fácil de usar posible. Planifique para otros desarrolladores que tome sus pruebas como ejemplos para escribir las suyas. Si no tienen experiencia en pruebas unitarias, no esperen que pierdan tiempo aprendiendo un marco, probablemente verán que las pruebas de unidad de escritura ralentizan su desarrollo, por lo que no conocer el marco es una excusa para omitir las pruebas.
Dedique algo de tiempo a la integración continua usando cruise control, luntbuild, cdash, etc. Si su código se compila automáticamente todas las noches y las pruebas se ejecutan, los desarrolladores comenzarán a ver los beneficios si las pruebas unitarias detectan errores antes qa.
Una cosa para alentar es la propiedad del código compartido. Si un desarrollador cambia su código y rompe la prueba de otra persona, no debe esperar que esa persona corrija la prueba, debe investigar por qué la prueba no está funcionando y solucionarlo por sí mismo. En mi experiencia, esta es una de las cosas más difíciles de lograr.
La mayoría de los desarrolladores escriben alguna forma de prueba unitaria, algunas veces una pequeña porción de código descartable que no registran o integran la compilación. Haga que la integración de estos en la construcción sea fácil y los desarrolladores comenzarán a comprar.
Mi enfoque es agregar pruebas para nuevas y, como se modifica el código, a veces no se pueden agregar tantas pruebas detalladas como se desee sin desacoplar demasiado el código existente, yerse de la parte práctica.
El único lugar donde insisto en las pruebas unitarias es en el código específico de la plataforma. Donde #ifdefs son reemplazados por funciones / clases de nivel superior específicas de la plataforma, estos deben probarse en todas las plataformas con las mismas pruebas. Esto ahorra mucho tiempo agregando nuevas plataformas.
Usamos boost :: test para estructurar nuestra prueba, las simples funciones de autoregistro facilitan las pruebas de escritura.
Estos se envuelven en CTest (parte de CMake) esto ejecuta un grupo de ejecutables de pruebas unitarias a la vez y genera un informe simple.
Nuestra construcción nocturna está automatizada con hormiga y luntbuild (ant glues construcciones c ++, .net y java)
Pronto espero agregar implementación funcional y pruebas funcionales a la compilación.
Creo que básicamente tienes dos problemas separados:
- Gran código base para refactorizar
- Trabaja con un equipo
Modularización, refactorización, inserción de pruebas de unidades y similares es una tarea difícil, y dudo que cualquier herramienta pueda hacerse cargo de partes más grandes de ese trabajo. Es una habilidad rara. Algunos programadores pueden hacer eso muy bien. La mayoría lo odia.
Hacer tal tarea con un equipo es tedioso. Dudo mucho que ''forzar'' a los desarrolladores alguna vez funcione. Los pensamientos de Iains están muy bien, pero consideraría encontrar uno o dos programadores que puedan y quieran "limpiar" las fuentes: Refactorizar, Modualrizar, presentar pruebas unitarias, etc. Permita que estas personas hagan el trabajo y las otras presenten nuevas errores, funciones aehm. Solo las personas que les gusta ese tipo de trabajo tendrán éxito con ese trabajo.
Es mucho más fácil hacerlo más modular primero. No se puede probar realmente algo con muchas dependencias. Cuándo refactorizar es un cálculo complicado. Realmente debe sopesar los costos y los riesgos frente a los beneficios. ¿Este código es algo que se reutilizará ampliamente? O este código realmente no va a cambiar. Si planea seguir utilizándolo, es probable que desee refactorizar.
Aunque suena como que quieres refactorizar. Debe comenzar por dividir las utilidades más simples y construir sobre ellas. Usted tiene su módulo C que hace un montón de cosas. Quizás, por ejemplo, hay algún código que siempre está formateando cadenas de cierta manera. Tal vez esto se pueda resaltar como un módulo de utilidad independiente. Tienes el nuevo módulo de formato de cadenas, has hecho que el código sea más legible. Ya es una mejora. Estás afirmando que estás en una situación de captura 22. Realmente no lo eres. Simplemente moviendo las cosas, ha hecho que el código sea más legible y mantenible.
Ahora puede crear una prueba unitaria para este módulo descompuesto. Puedes hacerlo de varias maneras. Puede crear una aplicación separada que solo incluya su código y ejecute un montón de casos en una rutina principal en su PC o quizás defina una función estática llamada "UnitTest" que ejecutará todos los casos de prueba y devolverá "1" si pasan. Esto podría ejecutarse en el objetivo.
Tal vez no puedas avanzar al 100% con este enfoque, pero es un comienzo, y puede hacerte ver otras cosas que se pueden dividir fácilmente en utilidades comprobables.
Estamos en el proceso de hacer exactamente esto. Hace tres años me uní al equipo de desarrollo en un proyecto sin pruebas de unidad, casi sin revisiones de código y con un proceso de compilación bastante ad-hoc.
La base de código consiste en un conjunto de componentes COM (ATL / MFC), un cartucho de datos Oracle C ++ multiplataforma y algunos componentes de Java, todos utilizando una biblioteca de núcleo C ++ multiplataforma. Parte del código tiene casi una década de antigüedad.
El primer paso fue agregar algunas pruebas unitarias. Desafortunadamente, el comportamiento está muy orientado a los datos, por lo que hubo un esfuerzo inicial para generar un marco de pruebas unitarias (inicialmente CppUnit, ahora extendido a otros módulos con JUnit y NUnit), que usa datos de prueba de una base de datos. La mayoría de las pruebas iniciales fueron pruebas funcionales que ejercieron las capas más externas y no pruebas unitarias. Probablemente tendrá que gastar un poco de esfuerzo (para lo cual deberá presupuestar) para implementar un arnés de prueba.
Encuentro que ayuda mucho si usted hace que el costo de agregar pruebas unitarias sea lo más bajo posible. El marco de prueba hizo que sea relativamente fácil agregar pruebas al corregir errores en la funcionalidad existente, el nuevo código puede tener pruebas unitarias adecuadas. A medida que refactoriza e implementa nuevas áreas de código, puede agregar pruebas unitarias adecuadas que prueban áreas de código mucho más pequeñas.
En el último año, hemos agregado la integración continua con CruiseControl y automatizamos nuestro proceso de construcción. Esto agrega mucho más incentivo para mantener las pruebas actualizadas y aprobatorias, lo que fue un gran problema en los primeros días. Por lo tanto, le recomiendo que incluya ejecuciones de pruebas de unidades regulares (al menos todas las noches) como parte de su proceso de desarrollo.
Recientemente nos hemos centrado en mejorar nuestro proceso de revisión de códigos, que fue bastante poco frecuente e ineficaz. La intención es hacer que sea mucho más económico iniciar y realizar una revisión del código, de modo que se anime a los desarrolladores a que lo hagan con más frecuencia. También como parte de la mejora de nuestro proceso estoy tratando de tener tiempo para revisiones de códigos y pruebas unitarias incluidas en la planificación del proyecto en un nivel mucho más bajo de manera que asegure que los desarrolladores individuales tengan que pensar más sobre ellos, mientras que anteriormente solo había una proporción fija de tiempo dedicado a ellos que era mucho más fácil perderse en el calendario.
Gday,
Comenzaría por echar un vistazo a cualquier punto obvio, por ejemplo, usar dec en los archivos de encabezado para uno.
Luego comience a ver cómo se ha establecido el código. ¿Es lógico? Tal vez comience a dividir archivos grandes en archivos más pequeños.
Tal vez tome una copia del excelente libro de Jon Lakos "Diseño de software de C ++ a gran escala" ( enlace de Amazon desinfectado ) para obtener algunas ideas sobre cómo se debe diseñar.
Una vez que empiezas a confiar un poco más en la base de código, es decir, en el diseño del código y en el diseño de archivos, y has aclarado algunos malos olores, por ejemplo, usando dec en los archivos de encabezado, puedes comenzar a seleccionar alguna funcionalidad que puedas use para comenzar a escribir sus pruebas unitarias.
Elija una buena plataforma, me gusta CUnit y CPPUnit, y vaya desde allí.
Aunque va a ser un viaje largo y lento.
HTH
aclamaciones,
Hay un aspecto filosófico en todo.
¿Realmente quieres un código ordenado, completamente funcional y probado? ¿Es TU objetivo? ¿TÚ obtienes algún beneficio de eso?
sí, al principio esto suena totalmente estúpido. Pero, sinceramente, a menos que sea el propietario real del sistema, y no solo un empleado, entonces los errores simplemente significan más trabajo, más trabajo significa más dinero. Puedes ser totalmente feliz mientras trabajas en una bola de pelo.
Solo estoy adivinando aquí, pero, el riesgo que está asumiendo al asumir esta gran pelea es probablemente mucho más alto que la posible retribución que obtiene al ordenar el código. Si careces de las habilidades sociales para hacer esto, serás visto como un alborotador. He visto a estos muchachos, y también he sido uno. Pero, por supuesto, es genial si logras esto. Estaría impresionado.
Pero, si sientes que te obligan a pasar horas extra para mantener un sistema desordenado funcionando, ¿de verdad crees que eso cambiará una vez que el código se ponga ordenado y agradable? No ... una vez que el código se pone ordenado y ordenado, la gente tendrá todo este tiempo libre para destruirlo por completo en la primera fecha límite disponible.
al final, es la gerencia la que crea el lugar de trabajo agradable, no el código.
He trabajado en proyectos de campo verde con bases de código totalmente probadas y grandes aplicaciones de C ++ que han crecido durante muchos años y con muchos desarrolladores diferentes en ellas.
Honestamente, no me molestaría en intentar obtener una base de código heredado para el estado donde las unidades prueban y prueban el primer desarrollo pueden agregar mucho valor.
Una vez que una base de código heredada alcanza cierto tamaño y complejidad, llegar al punto donde la cobertura de prueba de la unidad le proporciona muchos beneficios se convierte en una tarea equivalente a una reescritura completa.
El principal problema es que, tan pronto como comience a refactorizar para probar, comenzará a introducir errores. Y solo una vez que obtenga una cobertura de prueba alta, puede esperar que se encuentren y solucionen todos esos errores nuevos.
Eso significa que o vas muy despacio y con cuidado y no obtienes los beneficios de una base de código bien probada por unidades hasta dentro de unos años. (probablemente nunca, ya que ocurren fusiones, etc.). Mientras tanto, probablemente esté introduciendo algunos errores nuevos sin valor aparente para el usuario final del software.
O vas rápido pero tienes una base de código inestable hasta que hayas alcanzado una cobertura de prueba alta de todo tu código. (Así que terminas con 2 sucursales, una en producción, una para la versión probada por unidad).
Debido a que todo esto es una cuestión de escala para algunos proyectos, una reescritura podría tomar solo unas pocas semanas y ciertamente puede valer la pena.
Mi poca experiencia con el código heredado y la introducción de pruebas sería crear las " Pruebas de caracterización ". Comienza a crear pruebas con información conocida y luego obtiene la salida. Estas pruebas son útiles para los métodos / clases que no sabes lo que realmente hacen, pero sabes que están funcionando.
Sin embargo, a veces es casi imposible crear pruebas unitarias (incluso pruebas de caracterización). En ese caso, ataco el problema a través de pruebas de aceptación ( Fitnesse en este caso).
Usted crea todo el conjunto de clases necesarias para probar una función y verificarla en fitnesse. Es similar a las "pruebas de caracterización", pero es un nivel más alto.
Michael Feathers escribió la biblia sobre esto, trabajando eficazmente con el código heredado
Un enfoque a considerar es establecer primero un marco de simulación para todo el sistema que pueda usar para desarrollar pruebas de integración. Comenzar con las pruebas de integración puede parecer contra-intuitivo, pero los problemas para hacer verdaderas pruebas unitarias en el entorno que describes son bastante formidables. Probablemente más que simplemente simular todo el tiempo de ejecución en el software ...
Este enfoque simplemente pasaría por alto sus problemas enumerados, aunque le daría muchos diferentes. Sin embargo, en la práctica, he descubierto que con un sólido marco de prueba de integración puede desarrollar pruebas que ejerzan la funcionalidad a nivel de unidad, aunque sin aislamiento de unidad.
PD: Considere escribir un marco de simulación basado en comandos, tal vez basado en Python o Tcl. Esto le permitirá realizar pruebas de guiones con bastante facilidad ...
Haga que usar pruebas sea fácil.
Comenzaría por colocar las "carreras automáticamente" en su lugar. Si desea que los desarrolladores (incluido usted) escriban pruebas, facilite su ejecución y vea los resultados.
Escribir una prueba de tres líneas, ejecutarla contra la última compilación y ver los resultados debe estar a solo un clic de distancia , y no enviar al desarrollador a la máquina de café.
Esto significa que necesita una compilación más reciente, puede que necesite cambiar las políticas de cómo las personas trabajan en el código, etc. Sé que dicho proceso puede ser un PITA con dispositivos integrados, y no puedo dar ningún consejo al respecto. Pero sé que si ejecutar las pruebas es difícil, nadie las escribirá.
Prueba lo que se puede probar
Sé que estoy en contra de la filosofía común de Unit Test aquí, pero eso es lo que hago: escribir pruebas para las cosas que son fáciles de probar. No me molesto en burlarme, no me refactoré para que sea comprobable, y si hay una IU involucrada, no tengo una prueba de unidad. Pero cada vez más de mis rutinas de biblioteca tienen una.
Estoy bastante sorprendido de lo que las pruebas simples tienden a encontrar. Escoger las frutas que cuelgan poco es de ninguna manera inútil.
Mirándolo de otra manera: no planearías mantener ese desastre gigante si no fuera un producto exitoso. Su control de calidad actual no es una falla total que necesita ser reemplazada. Más bien, use pruebas unitarias donde sean fáciles de hacer.
(Sin embargo, es necesario que lo haga. No se quede atrapado en "arreglar todo" en su proceso de compilación).
Enséñale a mejorar tu código base
Cualquier base de código con esa historia grita por mejoras, eso es seguro. Sin embargo, nunca se refactorizará todo.
En cuanto a dos piezas de código con la misma funcionalidad, la mayoría de las personas pueden acordar cuál es "mejor" bajo un aspecto determinado (rendimiento, legibilidad, mantenimiento, capacidad de prueba, ...). Las partes duras son tres:
- cómo equilibrar los diferentes aspectos
- cómo aceptar que este código es lo suficientemente bueno
- cómo convertir un código incorrecto en un código lo suficientemente bueno sin romper nada.
El primer punto es probablemente el más difícil, y tanto social como una cuestión de ingeniería. Pero los otros puntos se pueden aprender. No conozco ningún curso formal que tenga este enfoque, pero tal vez puedas organizar algo en la empresa: cualquier cosa, desde dos tipos que se juntan a "talleres" donde tomas un pedazo de código desagradable y discuten cómo mejorarlo.