Aplicando TDD en concursos

1
4568

En esta entrada os voy a contar mi experiencia en concursos de programación, por qué en determinados casos es bueno usar TDD y un pequeño ejemplo de cómo aplicarlo en estas circunstancias.

0. Índice de contenidos.

1. ¿A qué viene esta entrada?

A principios del pasado mes de mayo me presenté al Tuenti Challenge. Quería comprobar hasta qué punto se había oxidado mi algoritmia. Al final, por falta tanto de tiempo como de preparación, no obtuve un resultado demasiado bueno. No era el primer concurso al que me presentaba, pero sí el que me ha dado la idea para esta entrada, así que voy a basarme en mi experiencia en él a la hora de escribir esto.

Mientras resolvía los retos, me dí cuenta de que estaba cambiando por completo mi estilo habitual de programación, me centraba en tener la solución lo más rápido posible para refactorizarla y subirla enseguida. Esto en nada se parece al TDD que practicamos en Autentia, la escasez de tiempo y las ganas de hacer lo máximo posible juegan malas pasadas. Cuando me di cuenta era demasiado tarde y ya no podía seguir haciendo más retos. Hacer las cosas bien, aunque tardemos un poco más, puede ahorrarnos mucho tiempo.

2. ¿Qué os voy a contar?

Con esta entrada quiero que veáis que TDD es una práctica más que recomendable para este tipo de concursos. Por supuesto, no busco convenceros de usarlo en cada proyecto que tengáis, es necesario ser flexibles y conocer las ventajas e inconvenientes de nuestras herramientas para sacar el máximo provecho de ellas. Pero en estos casos en particular, creo que la inversión de tiempo merecerá la pena.

Además pretendo ir un paso más allá; quiero mostrar cómo reinterpretar el ciclo natural de TDD para adaptarnos a este tipo de circunstancias, con problemas de rápida implementación en los que el proceso más fuerte de refactorización se aplica sobre una versión ya completamente funcional. No se trata de un cambio drástico en la metodología, no os preocupéis.

3. Entorno

He escrito y desarrollado el tutorial usando este entorno:

  • Hardware: Portátil Mac Book Pro 15″ (2,4 Ghz Intel Core i5, 8 GB DDR3)
  • Sistema Operativo: Mac OS X El Capitan
  • Entorno de desarrollo: Eclipse Java EE IDE, Mars Release (4.5.2)
  • Java 8
  • Librerías: JUnit 4 y Hamcrest 1.3

4. ¿Por qué nos vamos a plantear usar TDD?

La mayoría de concursos a los que nos podemos enfrentar tienen ciertos factores comunes. Entre ellos hay tres que parecen pedirnos a gritos que “perdamos” un poco de tiempo haciendo tests.

  • Los requisitos, tanto de entrada como de salida, son completamente estáticos; no van a cambiar. Tenemos incluso casos de prueba y ejemplos que facilitan el diseño de los tests.
  • Se suele valorar el estilo de código, la eficiencia. Esto no se consigue en la primera versión, hay un proceso de refactorización bastante fuerte, incluso puede cambiar el algoritmo o las estructuras de datos que usamos. En esta fase, los tests son una ayuda inestimable para detectar rápidamente qué hemos roto y evitar que nos tiremos demasiado de los pelos.
  • En casi todos los retos habrá una serie de casos con un comportamiento ligeramente distinto de los normales. Tener una buena base de tests nos ayuda a saber qué casos estamos contemplando y detectar fallos de análisis más rápidamente.

No obstante, también existen casos en los que seguir TDD no será de demasiada ayuda. No es recomendable hacer TDD cuando los requisitos van a cambiar constantemente, ¿para qué testear un prototipo que seguramente desechemos?. Es el caso de hackathones y concursos de corte similar, donde nos dan las herramientas y tenemos libertad creativa.

5. Advertencias

Vamos a ver cómo afrontar el primero de los retos que proponían desde la perspectiva de TDD. Tened en mente que el efecto no se verá inmediatamente, ya que se trata de una medida de prevención de problemas más que de una herramienta de agilización. Además quiero dejar claro que no todo es seguir un tutorial como éste o saber programar bien, también se necesita cierta cantidad de pensamiento lateral, a veces incluso para llegar a conocer el enunciado que nos proponen.

Voy a dar por hecho que tenéis unas nociones, aunque sean básicas, de qué es TDD y su ciclo de desarrollo (Rojo – Verde – Refactorización). La primera iteración la explicaré detalladamente, pero si lo hiciese con todo el proceso, la entrada (que ya de por sí es larga) se volvería monstruosa. No obstante, en este repositorio está subido el código final.
He intentado hacer un commit por cada cambio relevante para que sea más fácil seguir los cambios. Os recomendaría que le echaseis un vistazo mientras leéis esto.

6. Cómo voy a enfrentarme al problema

Las condiciones de trabajo que nos proponen para cada reto son bastante simples:

  • El lenguaje para resolverlo queda a nuestra elección.
  • Nos dan un fichero con los datos de entrada que hay que pasarle al algoritmo.
  • Hay que subir el fichero con los datos de la salida resultante de la ejecución con los datos anteriores y el código en un único fichero.

Me he decantado por Java porque es con el que más cómodo me siento a la hora de programar y hacer testing. Las ideas son fácilmente extrapolares a cualquier otro, no tendréis problemas por eso.

Por su parte, los tests los haremos en una clase separada, como dictan los principios de TDD. No podremos subirlos para corrección, pero tampoco es necesario. En este caso, son solo una herramienta para facilitar el desarrollo de la solución.

7. Procedimiento y ejemplo

Como ya hemos dicho, haremos este tutorial sobre el primero de los retos que se nos proponía: Team Lunch. Nos piden que calculemos el número mínimo de mesas que tendríamos que juntar para sentar a un determinado número de personas, teniendo en cuenta que cada lado de las mesa no puede estar ocupado por más de un comensal. El enunciado completo, con ejemplos, está en el enlace.

7.1. Análisis

El primer paso será, obviamente, leer y analizar el enunciado. Prestaremos especial atención a las entradas, salidas y otros datos característicos, pues son los elementos que más afectarán a nuestros tests. Recomiendo encarecidamente coger papel y bolígrafo durante este proceso, así no nos arriesgamos a dejar nada por el camino.

A continuación, antes de pasar a escribir ni una sola línea de código, pensaremos en la solución. Aún no buscamos la más eficiente ni la mejor implementada, de eso nos preocuparemos después. En este paso debemos centrarnos en identificar un patrón de comportamiento “normal” y aquellos casos que podamos calificar de “especiales”, con esto construiremos nuestro algoritmo y decidiremos cómo organizar los datos que vamos a utilizar.

Tendremos unas estructuras muy simples:

  • 1 entero que indique el número de casos que tenemos.
  • 1 array de tantos enteros como número de casos en lo que tendremos el número de personas que tenemos que sentar en cada uno.
  • 1 array de tantos enteros como número de casos en lo que tendremos el número de mesas necesarias para sentar a todos los asistentes de cada uno.

En base al enunciado y la relación entre entradas y salidas, desarrollaremos un algoritmo que se regirá por estas condiciones iniciales:

  • Por restricción, no es posible que se nos de un número negativo de comensales.
  • Si no hay comensales, no necesitaremos ninguna mesa.
  • Si hay 4 o menos comensales, nos bastará con una sola mesa.
  • Si hay más de 4 comensales y son pares, pondremos a 2 por cada mesa excepto a los dos que presidirán las mesas inicial y final. El número de mesas será (N-2)/2.
  • Si hay más de 4 comensales y son impares, pondremos a 2 por cada mesa excepto al que presidirá la mesa. El número de mesas será (N-1)/2.

7.2. Manos a la obra, ¿cómo adaptamos TDD?

Para lograr la máxima eficiencia, vamos a reinterpretar ligeramente el proceso normal de TDD a nuestras necesidades. Con esto quiero desviar gran parte de la carga de la refactorización a un punto donde ya tenemos tests de todo y corremos menos riesgos de romper nada.

Diagrama del proceso de TDD para concursos.

Primero, y aplicando el ciclo de TDD, crearemos una versión inicial del programa. Aquí la refactorización serán cambios muy pequeños, en su mayoría dirigidos a los tests. Como vemos, dividiremos nuestro código en tres grandes bloques: carga de datos de entrada, salida de los resultados y el algoritmo principal.
De esta fase obtendremos una versión inicial plenamente funcional y una batería de tests que cubre todos los casos contemplados.

A continuación nos dedicaremos únicamente a modificar el código que hemos creado para optimizarlo y mejorar su estilo y legibilidad. Como vemos, no siempre pasaremos por la fase en la que los tests no pasan. Cuando estamos haciendo refactorizaciones propiamente dichas, los tests siempre estarán en verde. Solo al hacer mejoras sobre la interfaz de los métodos o puntos clave del análisis deberían fallar. Una vez estemos satisfechos con nuestro código, tendremos ya la versión final que subiremos para evaluación.

7.3. Poniendo los cimientos de nuestro proyecto

Con las estructuras de datos y el algoritmo ya definido, podemos empezar a escribir código. Empecemos por algo similar a esto:

Estructura básica para de la solución de un reto

Tenemos un paquete para la clase principal y otro para los tests. Además hay una carpeta files con dos subdirectorios: input y tests. En el primero pondremos los casos de prueba que nos dan en el concurso y en el segundo los que creemos para nuestros tests.

Para dejar aún más claro el procedimiento que vamos a seguir, iniciamos los tests (y crearemos la clase que usan para quitar los errores de compilación). Observad cómo ya hacemos la distinción en los tres bloques lógicos en los que dividiremos el problema.

Forma inicial de los tests, con los tres bloques de código principales

7.4. Entrada de datos

La entrada de datos será siempre lo primero en lo que trabajaremos. Sería muy difícil poder testear el comportamiento central del algoritmo sin tener un mecanismo para insertar los datos.

Cambiaremos el nombre del test para que sea un poco más descriptivo y escribimos las comprobaciones que necesitamos. Queremos asegurarnos de que el número de casos y el array con los comensales a sentar en cada uno se han iniciado siguiendo los datos del fichero.

@Test
public void shouldCorrectlyInitializeDataStructureFromAnInputFile() throws FileNotFoundException {
    // Given
    String inputFileRoute = "files/tests/test_input.txt";

    // When
    teamLunch.initializeDinnersDataFromInputFile(inputFileRoute);

    // Then
    int casesAmount = teamLunch.getCasesAmount();
    assertThat(casesAmount, is(3));

    int[] dinnersToSitForEachCase = teamLunch.getDinnnersForEachCase();
    assertThat(dinnersToSitForEachCase.length, is(casesAmount));
    assertThat(dinnersToSitForEachCase[0], is(24));
    assertThat(dinnersToSitForEachCase[1], is(5913));
    assertThat(dinnersToSitForEachCase[2], is(3));
}

Ahora crearemos el fichero que hemos dicho que vamos a cargar para que contenga los datos que se amolden a los resultados que esperamos:

3
24
5913
3

Es un test muy simple y, si nos ceñimos a las guías de TDD al 100%, no del todo correcto. En realidad lo que estamos comprobando es que se devuelven unos valores concretos. En ese caso, el test pasaría aún cambiando el fichero, siempre que mantuviésemos los mismos asserts. Además, como nosotros sí iniciamos correctamente los datos, si cambiásemos el fichero pero no los valores que esperamos el test fallaría a pesar de que nuestro método hace lo que debe. Al tratar con un ejemplo, y para no complicarnos demasiado, vamos a dejarlo así. No obstante, una posible opción sería crear al principio del test el fichero con valores aleatorios y usar estos mismos valores generados en las comprobaciones.

A continuación, poblaremos la clase principal con los métodos que usamos para no tener errores de compilación y lanzamos el test. Por supuesto, fallará porque no hemos implementado aún el comportamiento. Primer paso del TDD conseguido, el test debe estar en rojo. Completemos ahora el código para lograr que pase el test:

public class TeamLunch {

    private int casesAmount;

    private int[] dinnersForEachCase;

    public void initializeDinnersDataFromInputFile(String inputFileRoute) throws NumberFormatException, IOException {
        BufferedReader reader = new BufferedReader(new FileReader(inputFileRoute));
        casesAmount = Integer.parseInt(reader.readLine());
        dinnersForEachCase = new int[casesAmount];
        for (int i = 0; i < casesAmount; i++) {
            dinnersForEachCase[i] = Integer.parseInt(reader.readLine());
        }
        reader.close();
    }

    public int getCasesAmount() {
        return casesAmount;
    }

    public int[] getDinnnersForEachCase() {
        return dinnersForEachCase;
    }
}

Si ejecutamos de nuevo el test, esta vez pasará. Segunda parte del ciclo de TDD lograda, lo hemos puesto en verde. Ahora vendría la tercera y última fase de TDD: refactorización. En este caso no hay mucho que hacer. Únicamente extraeremos la carga de los datos para cada caso a otro método, por hacer el código más legible.

public void initializeDinnersDataFromInputFile(String inputFileRoute) throws NumberFormatException, IOException {
    BufferedReader reader = new BufferedReader(new FileReader(inputFileRoute));
    casesAmount = Integer.parseInt(reader.readLine());
    loadDinnersData(reader);
    reader.close();
}

private void loadDinnersData(BufferedReader reader) throws IOException {
    dinnersForEachCase = new int[casesAmount];
    for (int i = 0; i < casesAmount; i++) {
        dinnersForEachCase[i] = Integer.parseInt(reader.readLine());
    }
}

Al lanzar el test vemos que sigue pasando, por lo que hemos hecho todo bien. De ahora en adelante no comentaré tanto los pasos que he ido haciendo, pero podéis verlos en el repositorio.

7.5. Devolviendo los resultados

Al testear la salida de datos nos encontramos con un problema. ¿Cómo especificar el valor de lo que queremos que salga en el fichero?¿Hacemos un setter para la estructura de datos que guardará los resultados? ¿Lo dejamos para el final y la hacemos dependiente del algoritmo?

Yo me decidí por inicializar las estructuras de los resultados con un valor por defecto al leer del fichero. Es una solución sencilla que genera pocas dependencias y no complica innecesariamente unos tests que no se van a entregar. Observad que creamos un test distinto en lugar de reutilizar el que teníamos. Al fin y al cabo vamos a comprobar algo que, aunque se haga en el mismo sitio, corresponde a una lógica distinta.

@Test
public void shouldCorrectlyInitilizeStructuresForResultsAfterReadingFromFile()
        throws NumberFormatException, IOException{
 // Given
    String inputFileRoute = "files/tests/test_input.txt";

    // When
    teamLunch.initializeDinnersDataFromInputFile(inputFileRoute);

    // Then
    int casesAmount = teamLunch.getCasesAmount();
    assertThat(casesAmount, is(3));

    int[] tablesAmountForEachCase = teamLunch.getTablesAmountForEachCase();
    assertThat(tablesAmountForEachCase.length, is(casesAmount));
    for (int i = 0; i < teamLunch.getCasesAmount(); i++) {
        assertThat(tablesAmountForEachCase[i], is(-1));
    }
}

Cuando lo resolvamos, ya podremos testear como se vuelcan los resultados a un fichero. No es necesario comprobar qué valor exacto tienen los datos. Solo asegurarnos que lo que hemos mostrado es lo que correspondía con lo que había en las estructuras de datos.

@Test
public void shouldCreateAnOutputFileWithOneLineDescribingEachCase() throws IOException {
    // Given
    String outputFileRoute = "files/tests/test_output.txt";

    // When
    teamLunch.writeResultsInFile(outputFileRoute);

    // Then
    BufferedReader reader = new BufferedReader(new FileReader(outputFileRoute));
    int[] tablesAmountForEachCase = teamLunch.getTablesAmountForEachCase();
    for (int i = 0; i < teamLunch.getCasesAmount(); i++) {
        assertThat(reader.readLine(), equalTo("Case #" + i + ": " + tablesAmountForEachCase[0]));
    }
    assertThat(reader.read(), is(-1));
    reader.close();
}

7.6. Algoritmo principal

Con las entradas y salidas aseguradas, centremos la atención en el plato principal. Al haber hecho un análisis previo, el trabajo casi se reduce a hacer un test para cada caso encontrado.

Una ventaja de trabajar con un producto tan pequeños es poder empezar por los casos especiales e ir generalizando o al revés; como cada uno se organice mejor. Lo importante es que todos estén contemplados para poder conocer qué hace el algoritmo solamente echando un vistazo a los nombres de los tests. Aquí lo más problemático (y una de mis carencias) es la habilidad para poner nombres descriptivos.

@Test
public void shouldNeedZeroTableWhenThereAreNoDinners() { }
    
@Test
public void shouldGoThroughAllCasesInTheProcessMethod() { }
    
@Test
public void shouldNeedOneTableWhenThereAreLessThanFiveDinners() { }
    
@Test
public void shouldNeedOneTablePerCoupleMinusTwoInBordersWhenMoreThanFourDinnersAndEvenNumber() { }
    
@Test
public void shouldNeedOneTablePerCoupleMinusOneInBorderWhenMoreThanFourDinnersAndOddNumber() { }

Quiero llamar la atención sobre el test que comprueba que estamos trabajando sobre todos los casos que nos llegan y no solo sobre el primero. Me va a servir para ilustrar que no podemos limitarnos a testear solo los puntos que hemos detectado durante el análisis. También debemos tener en cuenta las peculiaridades de las estructuras que hayamos escogido y asegurarnos de hacer todo el procesamiento correctamente.

Una vez que todos los requisitos están probados e implementados, deberíamos poder resolver las pruebas que nos pone Tuenti para poder subir el fichero. En caso contrario tocaría darle una vuelta al análisis y tests buscando qué se nos ha olvidado contemplar.

7.7. Refactorización y mejora del código

Llegado a este punto, es el momento de que los tests que hemos hecho nos ayuden de verdad. Vamos a modificar el código una y otra vez hasta lograr una versión óptima de la que no nos avergoncemos.

Solo tenemos que seguir un par de reglas. Si estamos haciendo una refactorización (que únicamente debería tocar el interior de un método pero no su interfaz) los tests deben de seguir pasando en todo momento porque la funcionalidad es la misma.

Si hacemos una mejora de cualquier tipo (casos analizados, estructuras en las que almacenamos datos de entrada o resultados…), deberemos primero cambiar los tests para adaptarnos a la misma situación. Por lo tanto estarán en rojo hasta que terminemos de implementar la mejora.

Aunque podamos mejorar cualquier parte, la entrada y salida de datos apenas suelen tocarse porque son similares a todos los retos. En este caso haremos tres modificaciones principales en el código.

  1. Simplificar nuestro if. Podemos ver que en caso de que tengamos más de 4 comensales, sin importar su paridad, la cantidad de mesas que necesitamos será de ⌈(D-2) / 2⌉ (el entero más pequeño que sea mayor o igual a esa cantidad), donde D es el número de comensales de cada caso.
  2. Extraer el procesamiento de un caso concreto a un método privado separado. Esto es principalmente para aumentar la legibilidad del código.
  3. Si volvemos a comprobar el análisis que hicimos, vemos que en realidad el límite para los casos especiales no es el 4, sino el 2. Así que vamos a adaptar el código para esta nueva especificación. En este caso sí que cambiaremos ligeramente los tests, pues aunque la salida vaya a ser la misma no lo serán los casos con los que trabajamos.

Cada vez que completemos uno de estos puntos lanzaremos los tests y el programa con el input de prueba que nos dan. Si los tests siguen pasando significará que no hemos alterado la lógica de nuestra aplicación. De forma similar, podemos usar los datos de entrada de ejemplo para asegurarnos que no hemos dado lugar a un comportamiento extraño que no identificamos en el proceso de análisis.

8. Y ya hemos terminado

Ahora ya tenemos una versión optimizada de nuestro código, que es más legible y se adapta mejor a los requisitos. Y gracias a los tests hemos podido asegurar que durante el proceso todo ha seguido funcionando igual. Seguramente el código se pueda mejorar aún, pero para este ejemplo creo que es suficiente. Hemos hecho una refactorización donde mejoramos la eficiencia del código uniendo dos casos de un if en uno, otra en la que extraemos un método para aumentar la legibilidad y una mejora en la toma de requisitos que detectamos inicialmente (con su correspondiente cambio en tests). Con esto tenemos una muestra de 3 de las modificaciones más probables con las que nos podremos topar durante un proceso de refactorización.

Solo nos quedaría subir el código que hemos creado y prepararnos para lo que espere en el siguiente reto…

9. Conclusiones

Después de una entrada tan larga como esta (he tratado de hacerla todo lo breve posible, pero se me da muy mal) siempre debemos hacer un resumen de las conclusiones a las que hemos llegado.

Cuando afrontamos un problema, una buena batería de pruebas siempre nos ayudará. Aunque a veces no podamos dedicar tiempo a crearla, concursos como este tienen las características idóneas para que ese tiempo sea una muy buena inversión. Si no dejamos que las prisas nos cieguen, vemos que nos permite montar una fantástica defensa contra nuestros propios errores. TDD es la metodología por excelencia para crear estos tests.

Yo he intentado explicar lo mejor posible por qué puede sernos de utilidad y cómo aplicarlo a los concursos. Ahora solo me queda animaros a que comenteis cualquier cosa que queráis y que os planteéis seriamente usarlo la próxima vez que tengáis oportunidad.

1 COMENTARIO

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad