Esteban Manchado Velázquez
La mayoría de los profesionales de la informática coincidirán en que probar es una de las tareas fundamentales del desarrollo, pero si ya es difícil aprender técnicas de programación, mucho más difícil es aprender técnicas de pruebas, tanto manuales como automáticas. Primero, porque desgraciadamente es un conocimiento menos extendido. Segundo, porque es aún más abstracto que la programación.
Por ello, todos los consejos y la experiencia compartida de los que nos podamos aprovechar son especialmente importantes. Los siguientes problemas están centrados en las pruebas automáticas, pero muchos de ellos ocurren también al hacer pruebas manuales. Están ordenados por experiencia: el primer problema aparece principalmente en equipos que nunca han escrito pruebas, y el último lo tienen incluso desarrolladores con años de experiencia.
Como todo, hay que aplicar las soluciones propuestas entendiendo por qué, cómo y cuándo son aplicables y útiles, nunca siguiéndolas ciegamente en todas las situaciones.
Aunque es normal que al acercarse el final de cada ciclo de desarrollo se intensifique el esfuerzo de pruebas (especialmente las manuales), es un error mayúsculo no haber probado desde el principio del desarrollo. Esto no es un tópico o una consideración teórica o académica: no probar desde el principio del ciclo de desarrollo tiene muchas desventajas. Por ejemplo:
Como tarde o temprano se tendrá que probar el resultado del trabajo, mejor empezar temprano porque es menos costoso (en tiempo y esfuerzo mental) y los resultados son mejores. Escribir código sin probar es simplemente irresponsable y una falta de respeto con respecto a los usuarios del producto y al resto de los miembros del equipo, especialmente los que tengan que mantener el código después y cualquier miembro del equipo que use directa o indirectamente el código sin probar.
Esto es un problema bastante común, sobre todo cuando se empiezan a hacer pruebas. Las pruebas automáticas son, de alguna manera, una descripción de lo que se espera que el programa haga. Una especificación en código, por así decirlo. Como tal, sólo debe describir el comportamiento que esperamos que no cambie. Si somos demasiado específicos o exigentes en las comprobaciones de nuestras pruebas, éstas no sólo evitarán que introduzcamos fallos en el código, sino también que podamos hacer otro tipo de cambios.
Por ejemplo, digamos que estamos escribiendo una aplicación de tareas pendientes muy sencilla. La clase que representa una lista de tareas se llama BrownList, y podría tener una pinta así en Ruby:
class BrownList def initialize(db) # ... end def add_brown(title) # Insertamos en una base de datos, devolvemos un id end def mark_brown_done(id) # Marcamos el id dado como hecho end def pending_browns # Devolvemos una lista de las tareas pendientes end end # Se usa así bl = BrownList.new(db_handle) id = bl.add_brown("Tarea pendiente") bl.mark_brown_done(id) lista_tareas_pendientes = bl.pending_browns
Ejemplo de implementación de la clase BrownList
Ahora, para probar que el método add_brown
funciona, puede que se nos ocurra
conectarnos a la base de datos y comprobar si tiene la fila correcta. En la
gran mayoría de los casos esto es un error. Para entender por qué, hay que
darse cuenta de que las pruebas definen qué significa que el código funcione.
Así, si las pruebas usan detalles de la implementación, se estará definiendo de
manera implícita que el programa sólo «funciona bien» si mantiene los mismos
detalles de implementación. Lo cual es, naturalmente, un error, porque no
permite que el código evolucione.
class TestBrownList < Test::Unit::TestCase def setup # Recreamos la base de datos de pruebas para que esté vacía end def test_add_brown_simple_____MAL db_handle = ... bl = BrownList.new(db_handle) bl.add_brown("Tarea de prueba") count = db_handle.execute("SELECT COUNT(*) FROM browns") assert_equal 1, count end end
Mal ejemplo de prueba del método add_brown
de BrownList
En este ejemplo concreto, hay muchos casos en los que esta prueba fallaría, a pesar de que el código podría estar funcionando perfectamente:
Las pruebas no deben limitarnos cuando reorganizamos código o cambiamos detalles de implementación. De hecho, una de las ventajas de tener pruebas automáticas es que cuando reorganicemos código, sabremos si estamos haciendo algo mal porque las pruebas fallarán. Si no estamos seguros de que cuando una prueba falla es porque hay un problema en el código, nuestras pruebas no nos están ayudando. Al menos, no todo lo que deberían.
Lo que queremos comprobar en la prueba es, realmente, si hay una nueva tarea
añadida. Una manera de probarlo es usar el método pending_browns
. Uno podría
pensar que no es una buena idea porque, si hay un error en add_brown
y otro
en pending_browns
que se cancelen mutuamente, las pruebas pasarán igualmente.
Eso es verdad, pero en la mayoría de los casos no importa, porque desde el
punto de vista del usuario de la clase, ésta se comporta como debería. Cuando
descubramos el fallo, lo podremos arreglar no sólo sin tener que cambiar las
pruebas o el código que llama a BrownList
, sino sin que haya habido ningún
cambio en el comportamiento de BrownList
desde el punto de vista de los
usuarios.
class TestBrownList < Test::Unit::TestCase def setup # Recreamos la base de datos de pruebas para que esté vacía end def test_add_brown_simple db_handle = ... bl = BrownList.new(db_handle) bl.add_brown("Tarea de prueba") assert_equal 1, bl.pending_browns.length end end
Mejor ejemplo de prueba del método add_brown
de BrownList
Para terminar de ilustrar este consejo, imaginemos ahora que escribimos una interfaz web para nuestra aplicación de tareas pendientes. Si queremos comprobar que la interfaz web funciona correctamente, una (mala) idea que puede pasarnos por la cabeza es comparar el HTML de la página con el HTML que esperamos. Si comparamos el HTML completo (o una captura de pantalla), nuestras pruebas serán muy, muy frágiles. Por ejemplo, nuestras pruebas fallarán cuando hagamos cualquiera de estos cambios:
Si nuestras pruebas comparan la salida HTML exacta, implícitamente estamos definiendo nuestra aplicación no como una aplicación web con ciertas características, sino como una aplicación que genera ciertas cadenas de HTML. Ya que al usuario no le importa el HTML generado, sino que la aplicación funcione, podemos ver que este enfoque no es el más apropiado.
Una forma mucho mejor de probar una aplicación web es buscar las partes interesantes. Por ejemplo, comprobar que el título de la nueva tarea aparece en el contenido de la página justo después de crearla. O comprobar que ya no está ahí después de borrarla. O comprobar que, al renombrar una tarea, el título antiguo ya no aparece, pero sí el nuevo. Sin embargo, hacer esas comprobaciones directamente puede ser tedioso y puede añadir algo de fragilidad a nuestras pruebas, por lo que lo mejor es desacoplar los detalles del HTML generado de las comprobaciones que queremos hacer. Una de las técnicas para conseguir esto se conoce como PageObjects, pero explorar PageObjects va mucho más allá del objetivo de este artículo.
Como resumen de este consejo, podemos decir que las pruebas no sólo deben fallar cuando hay algún problema, sino que también deben pasar mientras no haya ninguno.
Las pruebas no son un añadido al código, son parte integrante de éste. Asimismo, ejecutarlas es parte del ciclo normal de desarrollo. Si no las ejecutamos con frecuencia, no van a ser tan efectivas. Primero, porque cuando haya fallos, es probable que sea más de uno. En ese caso, será más difícil encontrar el origen de éstos. ¿Es un solo error el que provoca todos los fallos en las pruebas, uno por cada prueba? Segundo, porque si hemos hecho muchos cambios desde la última vez que ejecutamos las pruebas, tendremos más código que revisar en busca del problema.
Ejecutar las pruebas con frecuencia (idealmente, después de cada cambio que
hacemos) hace que sea muy fácil encontrar la causa del error, porque lo único
que puede haber sido la causa de los fallos son los cambios desde la última vez
que las ejecutamos. Si ejecutamos las pruebas antes de mandar nuestros cambios
al control de versiones, y vemos que una de las pruebas falla, será suficiente
ejecutar git diff
(o svn diff
o similar) para ver qué cambios deben de
haber producido el problema. Además, cuanto más alta sea la frecuencia con la
que ejecutemos las pruebas, más seguros estaremos de que el código funciona
correctamente. En la medida de lo posible, en el mundo de la programación es
mejor evitar la fé: trabajaremos más tranquilos y con más confianza si podemos
demostrar que el código funciona en los casos cubiertos por las pruebas.
El último punto importante de este consejo es tener una máquina «neutral» que ejecute las pruebas automáticas que tengamos, cada vez que alguien manda un cambio al control de versiones. Las ventajas son muchas:
Véase el artículo de Martin Fowler [fowlerci] sobre integración continua para más información.
Otro problema bastante común es escribir pruebas sin controlar el entorno en el que se ejecutan. En parte esta (mala) costumbre viene de la creencia de que las pruebas tienen que adaptarse a diferentes circunstancias y ser robustas como los programas que escribimos. Esto es un malentendido.
Volvamos al ejemplo anterior de la aplicación de tareas pendientes. Cuando escribimos las pruebas, los pasos no fueron:
Los pasos fueron:
Esta diferencia es fundamental. Uno podría pensar que la primera prueba es mejor porque «funciona en más casos». Sin embargo, esto es un error por las siguientes razones:
add_brown
que sólo aparece
cuando hay más de 20 tareas. En ese caso, si nunca borramos la base de datos,
nuestras pruebas fallarán… cuando las hayamos ejecutado suficientes veces.
Y si las dejamos así, y hay otro fallo que sólo aparece cuando no haya
ninguna tarea, las pruebas nunca nos avisarán del segundo fallo.
Si queremos probar ciertos casos de datos iniciales, es más claro y más fiable probar esos casos expresamente y por separado. Tendremos la ventaja de que estará claro al leer las pruebas qué casos cubrimos, y ejecutar las pruebas una sola vez nos hará estar seguros de que todos los casos que nos interesan funcionan perfectamente. Como regla general, cualquier incertidumbre o indeterminismo sobre la ejecución o resultados de las pruebas que podamos eliminar, debe ser eliminado.
Podemos terminar este consejo con una reflexión: las pruebas no son mejores porque pasen con más frecuencia, sino porque demuestren que un mayor número de casos interesantes funcionan exactamente como queremos.
Cuando empezamos a escribir pruebas, algo que necesitamos con frecuencia son datos iniciales o de prueba (en inglés, fixtures). Si no tenemos una forma fácil de crear esos bancos de datos para cada prueba, tendremos la tentación de tener un solo conjunto de datos iniciales que usaremos en todas las pruebas de nuestro proyecto. Aunque en algunos casos pueda resultar práctico compartir datos de prueba entre algunas pruebas, esta costumbre puede traer un problema añadido.
A medida que escribimos nuevas pruebas, éstas necesitarán más datos de contraste. Si añadimos estos datos a nuestro único conjunto de datos iniciales, cabe la posibilidad de que algunas de las pruebas antiguas empiece a fallar (p.ej. una prueba que cuente el número de tareas en el sistema). Si ante este problema reescribimos la prueba antigua para que pase con el nuevo conjunto de datos, estaremos haciendo más complejas nuestras pruebas, y además corremos el riesgo de cometer un fallo al reescribir la prueba antigua. Por no mencionar que si seguimos por este camino, puede que en la siguiente ocasión tengamos que reescribir dos pruebas. O cinco. O veinte.
Todo esto está relacionado, en cierta manera, con el problema descrito en el anterior apartado: pensar en las pruebas como pensamos en el resto del código. En este caso, pensar que tener más datos de prueba es mejor, porque se parecerá más al caso real en el que se ejecutará el programa. Sin embargo, en la mayoría de los casos esto no representa ninguna ventaja, pero sí que tiene al menos una desventaja: cuando alguna prueba falle y tengamos que investigar por qué, será más difícil encontrar el problema real cuantos más datos haya. Si podemos escribir nuestras pruebas teniendo un solo objeto de prueba, o incluso ninguno, mejor que mejor.
El apartado sobre ejecutar las pruebas con frecuencia ya mencionaba que las pruebas son parte integrante del código. Aunque no funciona exactamente de la misma manera ni tienen las mismas propiedades, sí que se tienen que mantener con el mismo cuidado y esfuerzo con el que mantenemos el resto del código.
Este apartado hace hincapié en que tenemos que hacer lo posible para facilitar la escritura de pruebas. Éstas no son una necesidad molesta a la que tenemos que dedicar el menor tiempo posible: como parte integrante de nuestro código se merece la misma dedicación que el resto. Así, nuestro código de prueba debe ser legible, conciso y fácil de escribir. Si cuesta escribir pruebas, ya sea en tiempo, esfuerzo mental o líneas de código, tenemos un problema que debemos resolver, ya sea reorganizando código, escribiendo métodos de conveniencia o usando cualquier otra técnica que nos ayude. Desgraciadamente, muchos desarrolladores piensan que es normal que sea costoso escribir pruebas y no hacen nada por mejorar la situación. En última instancia, esto hace que el equipo escriba menos pruebas, y de peor calidad.
Veamos un caso concreto. Digamos que queremos probar la interfaz web de nuestra aplicación de tareas pendientes. Una de las primeras pruebas que escribiríamos aseguraría que crear una tarea simple funciona. Una primera implementación podría quedar así:
class TestBrownListDashboard_______MAL(unittest.TestCase): def setUp(self): # Rehacemos la base de datos y creamos el navegador en self.driver def testAddBrownSimple______MAL(self): self.driver.get("/") self.driver.findElementById("username").send_keys("usuario") self.driver.findElementById("password").send_keys("contraseña") self.driver.findElementById("login").click() new_brown_title = "My title" self.driver.findElementById("new_brown").send_keys(new_brown_title) self.driver.findElementById("create_brown").click() title_tag = self.driver.findElementByTagName("task-1-title") self.assertEqual(title_tag.text, new_brown_title)
Ejemplo de prueba funcional difícil de escribir
Aunque aisladamente, este código es relativamente fácil de leer y entender, tiene varios problemas:
Una alternativa mucho mejor sería la siguiente:
class TestBrownListDashboard(BrownFunctionalTestCase): def testAddBrownSimple(self): self.assertLogin("usuario", "contraseña") new_brown_title = "My title" self.createBrown(new_brown_title) self.assertBrownExists(new_brown_title)
Ejemplo de prueba funcional más fácil de escribir
Las mejoras de la segunda versión son las siguientes:
TestBrownListDashboard
ahora hereda de una nueva clase,
BrownFunctionalTestCase
, que será una clase base para todas nuestras clases
de prueba. Aquí añadiremos todo el código común a diferentes pruebas de
nuestra aplicación.
setUp
porque ésta ya crea la base de datos e inicializa el navegador de prueba por
nosotros.
assertLogin
. No
sólo es mucho más compacto y legible, sino que si alguna vez cambian los
detalles de cómo iniciamos sesión, podemos simplemente cambiar la
implementación de este método.
createBrown
, y comprobar que se ha creado correctamente se lleva a cabo llamando al método
assertBrownExists
. Dependiendo del caso, podríamos incluso haber creado un
método assertCreateBrown
, pero por ahora parece mejor dejar ambas
operaciones separadas.
Como se puede ver, una simple reorganización del código (del mismo tipo que haríamos con el código principal del programa) puede tener un impacto muy grande en la facilidad de mantenimiento de nuestras pruebas.
La necesidad de facilitar la escritura de pruebas se extiende a todas las tareas relacionadas con probar nuestro código, no sólo mantener el código de pruebas automáticas. Digamos que escribimos un programa cliente-servidor. Si cada vez que encontramos un problema no somos capaces de depurarlo, o de asegurar si está de verdad arreglado o no porque no tenemos una forma fácil de probar el cliente o el servidor por separado, tenemos un problema. Una de las varias soluciones posibles es tener un cliente de prueba con el que podamos enviar al servidor cualquier petición que se nos ocurra, y un servidor de prueba con el que podamos enviar al cliente cualquier respuesta que se nos ocurra. Herramientas para capturar fácilmente el tráfico entre cliente y servidor también pueden ahorrarnos mucho tiempo a la larga.
Al fin y al cabo, estamos hablando de la calidad de nuestro trabajo, no en un sentido abstracto o teórico, sino en el sentido más pragmático desde el punto de vista del usuario. Si no podemos comprobar que nuestro programa se comporta debidamente, nos quedarán muchos fallos por descubrir, y por tanto que arreglar, que llegarán a los usuarios finales.
El último consejo es el más avanzado, y es el consejo con el que hay que tener más cuidado al aplicarlo. La tendencia natural al crear entornos de prueba es replicar algo lo más parecido posible al entorno real, usando las mismas bases de datos, los mismos servidores y la misma configuración. Aunque esto tiene sentido y es necesario en pruebas de aceptación y pruebas de integración [1], puede ser bastante contraproducente en pruebas unitarias y similares, en las que sólo queremos probar componentes relativamente pequeños.
Depender de servicios externos como una base de datos, un servidor web, una cola de tareas, etc. hace que las pruebas sean más frágiles, porque aumentan las posibilidades de que fallen por una mala configuración, en vez de porque hemos encontrado un problema en el código. Como en la mayoría de los tipos de pruebas, como las unitarias, sólo queremos probar que cierto componente concreto funciona correctamente, no hace falta que lo integremos con el resto de los componentes. En muchos casos, podemos reemplazar esos componentes con versiones «de pega» que se comporten como nos haga falta para cada prueba.
Un ejemplo claro de las ventajas de usar componentes «de pega» es el desarrollo y prueba de una aplicación que usa una API, tanto en el caso de que escribimos sólo el cliente como en el caso de que escribimos tanto el cliente como el servidor. Aunque para hacer pruebas de integración deberíamos usar el servidor real, la mayoría de las pruebas tendrán un ámbito más limitado (bien sean pruebas unitarias, o pruebas funcionales que sólo cubran el comportamiento del cliente). Para éstas sabemos, al tener la documentación de dicha API:
Armados con este conocimiento, podemos diseñar pruebas que no dependen del servidor. No depender del servidor tiene varias ventajas, entre otras:
No todo son ventajas, claro. Si el servidor introduce cambios que rompen la compatibilidad con la documentación que tenemos, esos fallos no se descubrirán hasta las pruebas de integración. De manera similar, si nuestras pruebas dependen de un comportamiento concreto, documentado o no, y este comportamiento cambia, de nuevo no detectaremos estos fallos hasta las pruebas de integración.
Veamos un ejemplo más concreto. Digamos que escribimos un programa que utiliza
la API pública de Kiva, una organización que permite, mediante microcréditos,
prestar dinero a personas que lo necesitan. Nuestra aplicación mostrará los
últimos préstamos listados en la web de Kiva (digamos, 50), información que
obtenemos usando la llamada /loans/newest
. Sin embargo, hay varios casos que
son muy difíciles de probar con un servidor real:
Probar todos esos casos a mano con el servidor real de Kiva es prácticamente imposible, principalmente porque no podemos hacer que el servidor devuelva las respuestas necesarias para reproducir cada caso. Si todas nuestras pruebas dependen del servidor no podremos estar seguros de si el código funciona bien. Sin embargo, todos estos casos son muy fáciles de probar si evitamos conectarnos al servidor real. Los casos mencionados arriba se podrían escribir de la siguiente manera en Javascript, usando Jasmine:
it("should correctly get items from several pages", function() { var fakeServer = new FakeServer(fixtureWith100Loans); var kivaLoanLoader = new KivaLoanLoader(fakeServer); kivaLoanLoader.fetchLoans(50); expect(kivaLoanLoader.loans.length).toEqual(50); expect(kivaLoanLoader.loans[0].id).toEqual("loan1"); expect(kivaLoanLoader.loans[50].id).toEqual("loan50"); }); it("should correctly skip items duplicated in different pages", function() { var fakeServer = new FakeServer(fixtureWith100LoansSomeRepeated); var kivaLoanLoader = new KivaLoanLoader(fakeServer); kivaLoanLoader.fetchLoans(25); expect(kivaLoanLoader.loans.length).toEqual(25); expect(kivaLoanLoader.loans[19].id).toEqual("loan20"); // El siguiente caso será loan20 repetido, si el código no funciona bien expect(kivaLoanLoader.loans[20].id).toEqual("loan21"); expect(kivaLoanLoader.loans[24].id).toEqual("loan25"); }); it("should stop when there's no more data", function() { var fakeServer = new FakeServer(fixtureWith30Loans); var kivaLoanLoader = new KivaLoanLoader(fakeServer); // La línea siguiente será un bucle infinito si el código no es correcto kivaLoanLoader.fetchLoans(40); expect(kivaLoanLoader.loans.length).toEqual(30); expect(kivaLoanLoader.loans[0].id).toEqual("loan1"); expect(kivaLoanLoader.loans[29].id).toEqual("loan30"); }); it("should stop on server errors", function() { var fakeServer = new FakeServer(fixtureWithOnlyServerError); var kivaLoanLoader = new KivaLoanLoader(fakeServer); // La línea siguiente será un bucle infinito si el código no es correcto kivaLoanLoader.fetchLoans(20); expect(kivaLoanLoader.loans.length).toEqual(0); });
Ejemplo de cómo probar el cliente ficticio de la API de Kiva
Probar programas es una tarea importante pero bastante difícil de hacer correctamente. Sin embargo, si empezamos a hacer pruebas desde el comienzo del proyecto, las ejecutamos con frecuencia y nos preocupamos de que sean fáciles de mantener, nuestras probabilidades de producir programas robustos serán mucho más altas.
[1] Las pruebas de integración son las más completas que hacemos, que determinan si el proyecto, como un todo, funciona desde el punto de vista del usuario.