Atención: No leer sin antes haber realizado el problema de la colonia de bacterias.

Ninguna prueba es suficiente

Cuando se aplica un enfoque de desarrollo dirigido por pruebas (TDD), la principal novedad a nivel metodológico es comenzar el diseño de una solución con las pruebas que nos harán comprobar finalmente que el funcionamiento es el deseado. Para ello debemos imaginar cómo se va a utilizar una determinada interfaz, cuál va a ser el comportamiento que va a observar un usuario de dicha interfaz desde fuera, sin acceso alguno al código fuente. Con este enfoque se acaba mejorando la usabilidad de las propias interfaces, que ya no vienen definidas por los caprichos, vicios y/o estilo de programación del desarrollador de la implementación, sino que tienen al consumidor de la interfaz como prioridad en el diseño. Y la interfaz no podrá modificarse porque la implementación sea más o menos complicada. Debemos adaptarnos a ella.

Aunque se recomienda separar el rol de probador y programador, esto no es siempre posible y cuando estamos solos ante el peligro debemos asumir un cambio de roles en el que en un momento diseñas pruebas y en otro diseñas código. Para evitar que el código pervierta las pruebas, os recomiendo que establezcáis una regla que impida realizar el paso de un rol a otro sin al menos 15 minutos de descanso entre ambos. Así evitáis la tentación de cambiar las pruebas sin "haberlo dejado macerar" un rato. En este caso, ir a pensar a cualquier rincón alicatado que tengáis a mano fomentará un mejor funcionamiento de vuestras neuronas.

En el caso de la colonia de bacterias habéis partido de pruebas desarrolladas por otra persona ajena al código y que os ha impuesto una interfaz y un comportamiento que se infiere de la documentación en el código y de los propios ejemplos de uso de las pruebas. Al ir desarrollando una solución, habéis podido comprobar en cada momento si vuestro código es correcto o no respecto a las pruebas. Sin embargo, las pruebas facilitadas no son suficientemente exhaustivas y dejan muchos casos de uso sin probar, y por tanto comprometiendo la calidad de la implementación de vuestra solución.

En el mundo del software, es imposible realizar pruebas completas del código fuente. Una prueba es completa cuando se comprueban las salidas obtenidas para todas las posibles entradas. De esta manera, para un único método de una interfaz deberíamos probar todas las combinaciones de valores que puedan tomar cada uno de los parámetros de entrada. Así para un método suma(int izq, int der), debemos probar todas las posibles combinaciones de números. Ya que un número entero de 32 bits puede tomar 4.294.967.296 valores, para dos números tenemos un total de 1.8446744E18 posibles combinaciones. En el caso del método setData tenemos dos números enteros además de una lista de posiciones de bacterias. ¿Somos acaso capaces de probar todos los posibles escenarios?

Realizar pruebas completas es imposible; ser exhaustivo es una obligación.

Ya que no es posible realizar pruebas completas, debemos ser al menos exhaustivos en el diseño de pruebas. Se deben escoger aquellos casos que prueben el mayor número de comportamientos diferentes posibles. Así por ejemplo, en la colonia de bacterias una prueba de una sola iteración en la que sólo existe una bacteria sólo prueba la muerte de la misma al no tener comida. Esta prueba es menos efectiva que crear una colonia donde en una sola iteración muera una bacteria, se genere una nueva y otra sobreviva. Sin embargo, al encontrar un error en el primer caso sabemos que existe un error en la regla de muerte por inhanición, mientras que encontrar el error en el segundo caso es más complejo al deber discriminar cuál o cuáles de las tres reglas de evolución está fallando.

Pero las pruebas no se acaban con los casos de prueba habituales. Un buen probador debe tener una mente perversa que le lleve a pensar mal de los programadores. Un buen probador debe pensar que quien utiliza la interfaz no se ha leido la documentación sobre su uso, envía accidentamente parámetros incorrectos, utiliza los métodos en un orden inadecuado, etc. Con las pruebas anteriormente facilitadas habéis trabajado con un probador novato. En el capítulo de hoy os animo a probar vuestro código con la siguiente prueba unitaria:

package problems.bacteriaColony;

import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;

public class BacteriaColonyIntensiveTest {
	IBacteriaColonyProblem colonyProblem;
	
	@Before
	public void setup() {
		colonyProblem = new BacteriaColonyProblem();
	}
	
	@Test
	public void testBacteriaProblem1DoubleSetData() {
		int [][]bacteria = {{0,2},{1,1},{1,2},{2,2},{3,2}};
		boolean [][]expected = {{false,true,true,false},
								{false,true,true,true},
								{false,false,true,true},
								{false,false,false,false}};
		colonyProblem.setData(4,1,bacteria);
		colonyProblem.setData(4,1,bacteria);
		colonyProblem.run();
		checkResult(expected,colonyProblem.getResult());
	}
	
	@Test
	public void testBacteriaProblem1Iter2TwoRun() {
		int [][]bacteria = {{0,2},{1,1},{1,2},{2,2},{3,2}};
		boolean [][]expected = {{false,true,false,true},
								{false,false,false,false},
								{false,true,false,true},
								{false,false,false,false}};
		colonyProblem.setData(4,2,bacteria);
		colonyProblem.run();
		colonyProblem.run();
		checkResult(expected,colonyProblem.getResult());
	}
	
	@Test
	public void testBacteriaProblem2() {
		colonyProblem.run();
	}
	
	@Test
	public void testBacteriaProblemTwoSetData() {
		int [][]unusedBacteria = {{0,2},{1,1},{1,2},{2,2},{3,2}};
		colonyProblem.setData(4,2,unusedBacteria);
		int [][]bacteria = {{0,2},{1,1},{1,2},{2,2},{3,2}};
		boolean [][]expected = {{false,true,true,false},
								{false,true,true,true},
								{false,false,true,true},
								{false,false,false,false}};
		colonyProblem.setData(4,1,bacteria);
		colonyProblem.run();
		checkResult(expected,colonyProblem.getResult());
	}
	
	@Test (expected=IllegalArgumentException.class)
	public void testBacteriaIllegalData() {
		int [][]bacteria = {{0,2},{1,1},{1,2},{2,2},{3,2}};
		colonyProblem.setData(-1,-1,bacteria);
	}
	
	private void checkResult (boolean[][] expected, boolean[][] result) {
		Assert.assertEquals("Colony width error", expected.length, result.length);
		for (int x = 0; x < result.length; x++) {
			Assert.assertEquals("Colony height error", expected[x].length, result[x].length);
			for (int y = 0; y < result[x].length; y++)
				Assert.assertEquals ("Incorrect value at ["+x+","+y+"]",
									expected[x][y], result[x][y]);
		}	
	}


}

¿Era vuestro código correcto? ¿Podemos hacer pruebas mejores y más exhaustivas? ¿Podemos confiar siempre en que nuestro código fuente funciona? ¡Responded a estas preguntas en los comentarios!

3 octubre, 2014

Posted In: Aprendiendo algoritmia, Tecnologería

3 Comments

  • De los nuevos 5 test no pasó testBacteriaProblem1Iter2TwoRun() ni testBacteriaProblem2(). No se me ocurrió pensar en lo que pasaría si se ejecutaba dos veces el método run o si se ejecutaba este método sin inicializar antes los atributos. Ahora se que el probador tendrá una mente perversa y hará todo lo posible por impedir que el código pase los tests correctamente, así que tendré que ponerme en su mente y sobre todo en la de la persona que utilizará la interfaz.

    Lo primero que se me ha ocurrido es añadir dos atributos que permita ejecutar el código del método run() cuando se hayan inicializado antes los atributos del problema y cuando sea la primera vez que se ejecuta. https://github.com/JesusTinoco/bacteriaColony

    Queda claro que se podrán hacer pruebas mejores y más exhaustivas, y que no podemos confiar en que nuestro código fuente funcione correctamente siempre. Aunque una buena batería de pruebas hará que nuestro código mejore, cumpla con todos los requisitos y funcione.

    • Pablo Trinidad dice:

      No voy a ofrecer soluciones en general, pero este caso merece la pena. Existe otra alternativa a modelar la excepción de los dos usos con un atributo booleano o similar, y es decrementar el atributo de transiciones cada vez que se realiza una. De esta forma en lugar de tener:

      // Este código permite la ejecución de run 2 o más veces, realizándose cada vez tantas iteraciones como indique &lt;i&gt;transitions&lt;/i&gt; 
      for (int currentTransition = 0; currentTransition &lt; transitions; currentTransition++)
      // Este código sólo ejecuta las iteraciones indicadas una vez
      for (; transitions &gt; 0; transitions--)
      

      Debes conocer qué hace cada una de las tres partes del bucle for para poder utilizarlo adecuadamente. La primera sección se ejecuta la primera vez que se ejecuta el bucle; la segunda es la comprobación de fin del bucle; la tercera es la acción/es a realizar justo al acaban una iteración del bucle.

  • Carlos Jimeno dice:

    Buenas de nuevo, mi código no era correcto, de todos los tests de la nueva tanda no ha pasado el de ejecutar run() dos veces seguidas. He optado por usar la opción de decrementar el atributo transitions. Nuevo código (más comentarios y correción de errores):

    https://drive.google.com/file/d/0B8pb6om2L_V1VWFSRjRKMllmdEk/view?usp=sharing

    En cuanto a las preguntas coincido con la opinión de Jesús: "Queda claro que se podrán hacer pruebas mejores y más exhaustivas, y que no podemos confiar en que nuestro código fuente funcione correctamente siempre. Aunque una buena batería de pruebas hará que nuestro código mejore, cumpla con todos los requisitos y funcione".

    Simplemente a mayor número de pruebas en TODOS los aspectos de nuestro código, mayor será la confianza que podremos tener de que nuestro código funciona correctamente.

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *