Siete problemas al probar programas

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.

1. Dejar las pruebas para el final

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:

  1. El esfuerzo mental de probar un programa grande es mucho mayor que el esfuerzo de probar un programa pequeño. No se sabe por dónde empezar, es difícil saber cuándo terminar, y es fácil quedarse con la sensación de que no todo está probado correctamente. Si probamos los componentes que vamos creando desde el principio, es mucho más fácil saber cómo atacar el problema y hacer pruebas más completas.
  2. Si probamos mientras escribimos código y sabemos el nivel de calidad de éste, será más fácil hacer estimaciones sobre los recursos necesarios para terminar el resto del trabajo. Esto nos dará mucha más flexibilidad para renegociar fechas o las características que se tendrán que quedar fuera, en vez de pasar al estado de alarma cuando nos demos cuenta, demasiado tarde, de que no tenemos tiempo de arreglar los fallos que encontremos los últimos días.
  3. Cuando hemos «terminado» un proyecto y sólo queda probar, es normal tener la tendencia a «no querer ver fallos» o restarles importancia, a que nos inunde un falso optimismo que confirme que de verdad hemos terminado y no queda nada por hacer. Sobre todo si quedan pocos días hasta la entrega y sentimos que no podemos hacer gran cosa, y sólo queremos entregar el resultado y olvidarnos del proyecto. Esto ocurre mucho menos si tenemos a profesionales exclusivamente dedicados a las pruebas, claro.
  4. Probar desde el principio significa que estaremos probando durante más tiempo, por lo que habremos encontrado más casos que puedan ser problemáticos, y por tanto más fallos (y las soluciones a éstos), antes de que sea demasiado tarde.
  5. Todas las pruebas que hagamos pronto ayudarán a aumentar la calidad del código, lo que no sólo afecta a la calidad final del producto, sino a lo fácil que es escribir y depurar cualquier otro código que dependa del primero. Los fallos de interacción entre las dos bases de código serán más fáciles de encontrar y, en particular, la fuente de los errores.
  6. Si desde el principio escribimos pruebas automáticas, nos obligaremos a nosotros mismos a escribir APIs más limpias. Generalmente, código más fácil de probar es código más desacoplado, más limpio y más estable. Indudablemente, una de las ventajas de escribir pruebas automáticas es que ayudan a mantener un diseño de mayor calidad. Pero si no probamos desde el principio, perdemos esta ventaja.
  7. Al no probar desde el principio, desarrollaremos código que no es especialmente fácil de probar, y cuanto más tarde empecemos a adaptar el código para que lo sea, más esfuerzo costará. Una vez hayamos escrito una gran cantidad de código sin tener en cuenta si es fácil de probar o no, la tentación de dejar muchas partes sin pruebas será irresistible. Y, por supuesto, si no hacemos el esfuerzo de adaptar el código en ese momento, entraremos en un círculo vicioso.

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.

2. Ser demasiado específico en las comprobaciones

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:

  • El nombre de la tabla donde guardamos las tareas cambia
  • Añadimos la característica multiusuario, por lo que podría haber más tareas en esa tabla de las que queremos contar
  • Decidimos que vamos a usar una base de datos no-SQL, por lo que la manera de contar cambiaría
  • Añadimos un paso intermedio de algún tipo, de tal manera que las tareas no se crearían inicialmente en la base de datos, sino en algo como memcached, y unos segundos después irían a la base de datos

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:

  • Cambiar el id de algún elemento o el nombre de alguna clase CSS
  • Cambiar un elemento de sitio o intercambiar la posición de dos opciones en un menú
  • Añadir una nueva opción o información extra
  • Corregir una falta de ortografía o redactar un texto de forma diferente

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.

3. No ejecutarlas con frecuencia

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:

  • Incluso si alguien se olvida de ejecutar las pruebas antes de enviar los cambios, tenemos garantizado que las pruebas se ejecutarán.
  • Si alguien se olvida de añadir un fichero al control de versiones, ese fichero no aparecerá en la máquina de integración continua, por lo que las pruebas fallarán y nos daremos cuenta del error.
  • Los resultados de las pruebas en la máquina de integración continua son más fiables, porque tiene la misma configuración que las máquinas de producción. Por ejemplo, si un programador escribe una nueva prueba que depende de un nuevo módulo o de un cambio de configuración que sólo existe en la máquina de ese programador, la prueba pasará en su máquina, pero fallará en integración continua. Este fallo nos avisará del problema antes de que el proyecto pase a producción.
  • Como tenemos los resultados de las pruebas para cada cambio que se haya hecho, y ejecutados en la misma máquina, podemos saber qué cambio exacto produjo el problema, lo que hace mucho más fácil arreglarlo.

Véase el artículo de Martin Fowler [fowlerci] sobre integración continua para más información.

4. No controlar el entorno

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:

  1. Obtener el número de tareas actuales, llamarlo n
  2. Añadir una tarea
  3. Comprobar que el número de tareas actuales es n + 1

Los pasos fueron:

  1. Dejar la base de datos en un estado conocido (en este caso, vacía)
  2. Añadir una tarea
  3. Comprobar que el número de tareas es exactamente 1

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:

  • Escribir código robusto requiere mucho más esfuerzo mental, especialmente a medida que crecen las posibilidades. Como no necesitamos esa robustez, mejor dejarla de lado.
  • Las pruebas serán menos flexibles, porque no podremos probar qué ocurre en casos específicos (p.ej. cuando hay exactamente 20 tareas, cuando hay más de 100 tareas, etc.).
  • Si no controlamos y rehacemos el entorno de ejecución de las pruebas, unas pruebas dependerán, potencialmente, de otras. Lo que significa que el comportamiento de unas pruebas puede cambiar el resultado de otras. En el caso ideal, que es el caso común, las pruebas se pueden ejecutar una a una independientemente y tienen exactamente el mismo resultado.
  • No siempre darán el mismo resultado, incluso cuando las ejecutemos por sí solas. Por ejemplo, digamos que hay un fallo en 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.

5. Reusar datos de prueba

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.

6. No facilitar el proceso de pruebas

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:

  • No es todo lo compacto que podría ser
  • Contiene código que sabemos que se duplicará en otras pruebas
  • No contiene abstracciones, por lo que cuando haya cambios en la aplicación (digamos, el id de "username" o "password" cambia), tendremos que buscar dónde nos referimos a éstos para actualizar el código
  • No está escrito usando el lenguaje del dominio de la aplicación, sino usando el lenguaje de automatización de un navegador, por lo que es más difícil de leer y mantener

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.
  • Como tenemos una clase base, ya no necesitamos escribir el método setUp porque ésta ya crea la base de datos e inicializa el navegador de prueba por nosotros.
  • Para abrir sesión, simplemente llamamos a un nuevo método 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.
  • Crear una nueva tarea es tan fácil como llamar a un nuevo 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.

7. Depender de muchos servicios externos

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:

  1. Qué llamadas debe generar nuestra aplicación en ciertas situaciones.
  2. Cómo tiene que reaccionar nuestra aplicación ante ciertas respuestas del servidor.

Armados con este conocimiento, podemos diseñar pruebas que no dependen del servidor. No depender del servidor tiene varias ventajas, entre otras:

  • Las pruebas se ejecutan más rápido.
  • Las pruebas se pueden ejecutar sin tener conexión a internet o acceso al servidor (incluyendo que el servidor esté funcionando correctamente).
  • Cuando hay fallos, éstos son más fáciles de depurar y corregir.
  • Podemos probar muchas situaciones que no podemos reproducir fácilmente en un servidor real, como no poder conectar al servidor, que el servidor devuelva cualquier tipo de error, que devuelva ciertas combinaciones de datos que son difíciles de obtener, condiciones de carrera, etc.

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:

  • Esa funcionalidad de la API sólo devuelve 20 elementos por llamada, por lo que tenemos que hacer varias llamadas para obtener todos los préstamos que queremos.
  • Si se añaden nuevos préstamos mientras estamos haciendo las llamadas para obtener 50 préstamos, tendremos préstamos repetidos, lo que queremos evitar.
  • Puede ocurrir que no haya 50 préstamos en un momento dado, así que tendremos que conformarnos con los datos que haya (en vez de, por ejemplo, entrar en un bucle infinito).
  • Puede que el servidor tenga algún problema y no devuelva una respuesta correcta, o que simplemente haya problemas de conectividad entre el cliente y el servidor. Como mínimo queremos asegurarnos, como en el caso anterior, de que el cliente no se queda en un bucle infinito, haciendo peticiones ciegamente hasta que hayamos recibido 50 préstamos.

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

8. Conclusión

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.