Desarrollo dirigido por pruebas

1 Estrella2 Estrellas3 Estrellas4 Estrellas5 Estrellas (1 votos, promedio: 5,00 de 5)
Cargando…

El desarrollo dirigido por pruebas (o TDD por sus siglas en inglés) consiste en escribir la prueba que demuestra una característica del software antes de la característica en sí; paso a paso, se va acrecentando una batería de pruebas con el código para explotación, al ritmo de:

rojo – verde – refactorización

Donde en el paso rojo escribimos una prueba que el software no satisface, en el verde modificamos el código de explotación para que la satisfaga y en el de refactorización mejoramos el código sin cambiar funcionalidad, con la confianza de que la batería de pruebas que hemos ido creando hasta el momento nos protege de estropear algo sin darnos cuenta.

1. Ejemplo

Probablemente la forma más fácil de explicar TDD es con un ejemplo a nivel unitario. Supongamos que nuestra aplicación necesita convertir millas a kilómetros; empezamos con una prueba:

1.1. Rojo

test("Converts miles into kilometers") {
  MilesToKilometersConverter.convert(1.0) should be(1.609)
}

Primera prueba (example0/MilesToKilometersConverterTest.scala)

A continuación, como estamos usando para los ejemplos Scala, que es un lenguaje compilado, antes de poder ejecutar las pruebas necesitamos escribir un poco más de código. Nos limitaremos a añadir el mínimo código de explotación necesario para que compile. [8]

object MilesToKilometersConverter {
  def convert(miles: Double): Double =
    throw new RuntimeException("Not yet implemented")
}

Código necesario para que la primera prueba compile

Para terminar el paso rojo, ejecutamos la prueba, comprobando que falla (lo que muchos entornos marcan en rojo) lanzando la excepción que esperábamos:

Not yet implemented
java.lang.RuntimeException: Not yet implemented

Aunque este paso parezca trivial, nos ha obligado a tomar unas cuantas decisiones importantes:

Primero, nos ha obligado a definir el contrato de uso del conversor; en este caso, el nombre sugiere que convierte específicamente millas a kilómetros, y no a la inversa; el método de conversión forma parte del objeto (y no de la clase) MilesToKilometersConverter, por lo que no es necesario crear un objeto conversor llamando a new, y sugiere que no tiene estado [9]; las millas están expresadas con tipo Double; etc.

Segundo y más fundamental, la prueba constituye un ejemplo que comienza a definir la funcionalidad de la unidad; que al estar expresado en un lenguaje de programación nos protege de las ambigüedades del lenguaje natural. Por ejemplo, no dudaremos de la definición de milla:

Cuando dijimos millas, ¿eran millas internacionales (1,609km)? ¿Millas náuticas (1,852km)? ¿Millas nórdicas (10km)?

Porque la prueba ha dejado claro que el tipo de milla que importa en nuestra aplicación es igual a 1,609 kilómetros.

Aunque a veces parezca innecesario, merece la pena, antes de progresar al paso verde, completar el rojo llegando a ejecutar una prueba que sabemos que va a fallar; esto nos obliga a pensar en la interfaz que estamos creando, y con frecuencia desvela suposiciones erróneas… especialmente cuando resulta que la prueba no falla, o que lo hace de una forma distinta de la que esperábamos.

1.2. Verde

A continuación acrecentamos el código de explotación. Lo más purista es escribir sólo el código imprescindible para superar la prueba:

object MilesToKilometersConverter {
  def convert(miles: Double): Double = 1.609
}

Código mínimo para que la prueba pase

Y ejecutar la prueba, que nuestra implementación superará con un color verde, si usamos un entorno que lo represente así.

1.3. ¿Refactorización?

En esta iteración es pronto para refactorizar. Pasamos al siguiente paso.

1.4. Rojo

Antes de saltar a la implementación general, merece la pena que consideremos otras condiciones de entrada: ¿aceptamos distancias negativas? Como en este caso sí las aceptamos, lo expresamos con otra prueba.

test("Converts negative miles into kilometers") {
  MilesToKilometersConverter.convert(-2.0) should be(-3.218)
}

Segunda prueba (example0/MilesToKilometersConverterTest.scala)

Ejecutamos las pruebas para ver como la nueva falla.

1.609 was not equal to -3.218
org.scalatest.TestFailedException: 1.609 was not equal to -3.218

1.5. Verde

Ahora que tenemos dos ejemplos de la funcionalidad de convert, es un buen momento para buscar una implementación más general [10] que no sólo satisfaga estas dos pruebas, sino que también nos proporcione la funcionalidad general de la que son ejemplo. Como la siguiente:

object MilesToKilometersConverter {
  def convert(miles: Double): Double = miles * 1.609
}

Código para la segunda prueba (example0/MilesToKilometersConverter.scala)

Podríamos haber pasado a la solución general directamente, y a menudo haremos exactamente eso, sin embargo, como vimos en el anterior paso verde, empezar por la solución más simple y dejar la generalización para el siguiente ciclo, nos ayuda a centrarnos primero en definir qué funcionalidad vamos a añadir y cuál va a ser el contrato de la unidad que estamos probando, resolviendo ambigüedades y desvelando suposiciones erróneas. Esto será particularmente útil cuando no tengamos claro lo que queremos hacer y cómo queremos implementarlo.

1.6. Refactorización

Tras cada paso verde debemos plantearnos la mejora del código, con la confianza de que las pruebas nos van a proteger de romper algo que funcionaba anteriormente. Las refactorizaciones pueden centrarse en el detalle (¿es mejor expresar miles * 1.609 o 1.609 * miles? ¿el parámetro debería llamarse miles o distanceInMiles?), pero es fundamental que con frecuencia también reconsideremos el diseño de la aplicación y lo transformemos en lo más apropiado para el estado actual del código, sin ignorar la dirección futura en la que queremos ir pero sin fraguar demasiado lo que puede que nunca necesitemos.

2. Por qué y para qué

El motivo más importante [11] para desarrollar dirigido por pruebas es el énfasis en la función de nuestro código y en su idoneidad para ponerlo a prueba y por lo tanto usarlo. Este énfasis nos obliga a preguntarnos cada vez que vamos a añadir código de explotación: ¿es esto lo que necesito? ¿hace falta ahora?; ayudándonos a escribir exclusivamente lo que necesitamos y a mantener el tamaño y la complejidad del código al mínimo necesario.

Si bien TDD no exime de buscar y dirigir el diseño deliberadamente, escribir código que, desde el primer momento, es fácil de probar favorece una cierta simplicidad y, definitivamente, evidencia el acoplamiento, guiándonos hacia el cuidado de la colaboración entre las unidades. En particular, la inyección de dependencias y la separación entre sus interfaces y las implementaciones, emergen de forma natural, dado que facilitan las pruebas automatizadas.

Los proyectos que se desarrollan dirigidos por pruebas cuentan en todo momento con una batería de pruebas al día, que documenta la intención de cada unidad del software, de combinaciones de unidades y del software en su totalidad. Además, las pruebas, si bien no la garantizan, dan una buena indicación de la corrección del software; lo que reduce el miedo a romper algo, y lo sustituye por un hábito diligente de refactorizar con frecuencia y mejorar el diseño progresivamente.

2.1. Ejemplo de cómo las pruebas nos guían respecto a la cohesión y el acoplamiento

En la clase siguiente, el método translate traduce un texto del español a una especie de inglés, mostrando por pantalla el resultado; este código es poco probable que haya sido desarrollado guiado por pruebas, dado que el resultado principal, la traducción, no se pone a disposición del codigo externo de ninguna manera que sea fácil de incluir en una prueba, sino que se manifiesta llamando al método println que da acceso a la consola, es decir, mediante un efecto secundario, lo que dificulta la verificación desde una prueba.

class SpanishIntoEnglishTranslator {
  def translate(spanish: String) {
    println(spanish.split(' ').map(_ match {
      case "yo" => "I"
      case "soy" => "am"
      case _ => "mmmeh"
    }).mkString(" "))
  }
}

Código díficil de probar (example1coupled/SpanishIntoEnglishTranslator.scala)

Si lo desarrollamos con la facilidad de prueba en mente desde el principio, probablemente nos encontraremos con que, para probar el resultado de la traducción, necesitamos que el código que traduce devuelva el resultado; de hecho, ¿acaso no es la traducción en sí la responsabilidad principal de esta clase, y no el mostrar por pantalla? Si pudiéramos obtener el resultado, una prueba de nuestro traductor podría ser algo así:

var translator: SpanishIntoEnglishTranslator = _

before {
  translator = new SpanishIntoEnglishTranslator()
}

test("translates what it can") {
  translator.translate("yo soy") should be("I am")
}

test("mmmehs what it can't") {
  translator.translate("dame argo") should be("mmmeh mmmeh")
}

Primera prueba para el traductor (example2/SpanishIntoEnglishTranslatorTest.scala)

Lo que nos llevaría a un traductor menos acoplado a la muestra por pantalla

class SpanishIntoEnglishTranslator {
  def translate(spanish: String): String =
    spanish.split(' ').map(_ match {
      case "yo" => "I"
      case "soy" => "am"
      case _ => "mmmeh"
    }).mkString(" ")
}

Un traductor más fácil de probar (example2/SpanishIntoEnglishTranslator.scala)

3. Probar el conjunto de la aplicación

Hasta ahora nos hemos centrado en las pruebas unitarias; no obstante, si somos consecuentes con los principios que hemos visto —guiarnos manteniendo el enfoque en los objetivos del software, documentar y verificar—, deberemos considerar fundamental guiar el desarrollo de cada parte de la funcionalidad mediante una prueba que la ejercite en su conjunto; lo ideal será que todas las pruebas funcionales verifiquen el conjunto del software, en un entorno similar al de explotación, o incluso en el entorno de explotación en sí. En la práctica suele haber muchos obstáculos, por ejemplo, puede que sea demasiado costoso llevar a cabo de verdad ciertas acciones destructivas, que no haya suficientes recursos, o que se hayan impuesto decisiones arquitectónicas que dificulten las pruebas; sin embargo, eso no significa que tengamos que claudicar completamente; a menudo, lograremos las mejoras más importantes en el software y en la organización en la que se crea cuestionando las limitaciones.

3.1. Ejemplo

Volvamos al ejemplo inicial de conversión de distancias, y supongamos que necesitamos ofrecer a nuestros clientes un servicio de conversión de unidades a través de un servicio web, porque hemos decidido que no hay suficientes conversores en Internet. La primera prueba que vamos a escribir, incluso antes de las que vimos en la introducción, es una prueba que ejercite el conjunto de la aplicación. Nos concentraremos en un cierto mínimo incremento de funcionalidad, visible para los usuarios del sistema, que requiera una implementación reducida y que tenga un alto valor desde un punto de vista comercial o de los objetivos últimos del proyecto. En nuestro caso empezamos con la conversión de millas a kilómetros.

class ConversionTest
    extends FunSuite with ShouldMatchers with BeforeAndAfter {

  test("Converts miles into kilometers") {
    get("http://localhost:8080/1.0") should be("1.609")
  }

  def get(url: String): String = {
    Request.Get(url).execute().returnContent().asString()
  }

  var converter: Server = _

  before {
    converter = Converter.start(8080)
  }

  after {
    converter.stop()
  }
}

Prueba funcional para el conversor de millas (step1/functional/ConversionTest.scala)

El método get es aquí un método de ayuda para pruebas, que hace una petición HTTP get y devuelve en contenido del cuerpo del mensaje. Evidentemente, poner esto en funcionamiento requiere un cierto trabajo, pero si nos concentramos en lo fundamental, no será tanto y además nos ayudará a plantearnos cuestiones importantes acerca del sistema, particularmente a nivel de aplicación, por ejemplo: ¿cómo nos comunicaremos con el sistema?; y acerca de cómo lo vamos a probar. Así, desde el primer momento la facilidad de prueba es un usuario de pleno derecho de nuestro proyecto.

Con esta prueba como guía, nos concentraremos ahora en recorrer todo el sistema, casi a toda velocidad, hasta que la satisfagamos. En el mundo de Java/Scala, la forma típica de resolver esto es con un Servlet. De nuevo comenzamos con una prueba, esta vez a nivel unitario. [12]

class ConverterTest extends FunSuite {
  test("Responds to get requests converting miles into kilometers") {
    val response = mock(classOf[HttpServletResponse])
    val printWriter = mock(classOf[PrintWriter])
    when(response.getWriter).thenReturn(printWriter)

    new Converter().doGet(mock(classOf[HttpServletRequest]), response)

    verify(printWriter).print("1.609")
  }
}

Prueba unitaria para el conversor de millas (step1/ConverterTest.scala)

Un conversor más o menos mínimo usando Jetty viene a ser:

class Converter extends HttpServlet {
  override def doGet(req: HttpServletRequest, resp: HttpServletResponse) {
    resp.getWriter.print("1.609")
  }
}

object Converter {
  def main(args: Array[String]){
    start(8080)
  }

  def start(port: Int): Server = {
    val context = new ServletContextHandler()
    context.setContextPath("/")
    context.addServlet(new ServletHolder(new Converter()), "/*")

    val converter = new Server(port)
    converter.setHandler(context)

    converter.start()
    converter
  }
}

Primera implementación del servicio de conversión de millas (step1/Converter.scala)

Como vemos la funcionalidad que estamos ofreciendo es, como en el ejemplo inicial, trivial. Pero llegar a ella nos ha obligado a definir el esqueleto de todo nuestro sistema, incluyendo código de explotación y de prueba.

A continuación progresaremos dependiendo de nuestras prioridades. Por ejemplo, podemos concentrarnos en completar funcionalmente la conversión de millas a kilómetros.

test("Converts negative miles into kilometers") {
  get("http://localhost:8080/-2.0") should be("-3.218")
}

Segunda prueba funcional del conversor de millas (step2/functional/ConversionTest.scala)

class Converter extends HttpServlet {
  override def doGet(req: HttpServletRequest, resp: HttpServletResponse) {
    val miles = req.getRequestURI.substring(1).toDouble
    resp.getWriter.print(miles * 1.609)
  }
}

Código para la segunda prueba funcional del conversor de millas (step2/Converter.scala)

A continuación el manejo de los casos de error, como cantidades de millas que no sean numéricas

test("Responds with 400 (Bad Request) and error message to unparseable amounts of miles") {
  statusCode("http://localhost:8080/blah") should be(400)
  get("http://localhost:8080/blah") should be("Miles incorrectly specified: /blah")
}

Prueba para caso de error del conversor de millas (step3/functional/ConversionTest.scala)

class Converter extends HttpServlet {
  override def doGet(req: HttpServletRequest, resp: HttpServletResponse) {
    val milesAsString = req.getRequestURI.substring(1)
    try {
      val miles = milesAsString.toDouble
      resp.getWriter.print(miles * 1.609)
    }
    catch {
      case _: NumberFormatException => {
        resp.setStatus(HttpServletResponse.SC_BAD_REQUEST)
        resp.getWriter.print("Miles incorrectly specified: " + req.getRequestURI)
      }
    }
  }
}

Código de manejo de errores para el conversor de millas (step3/Converter.scala)

Además de los pasos rojos y verdes que hemos visto en el ejemplo hasta ahora, a medida que la aplicación va creciendo, debemos tanto refactorizar a nivel unitario como ir mejorando el diseño; por ejemplo, si en los próximos pasos la aplicación necesitase responder a distintas rutas con distintas conversiones, probablemente decidiríamos extraer el análisis de las URIs a una unidad independiente e introduciríamos distintos objetos a los que delegar dependiendo del tipo de conversión.

4. Probar una sola cosa a la vez

El mantenimiento de la batería de pruebas, que crece con la aplicación, requiere una inversión de esfuerzo constante; hacer que cada prueba verifique únicamente un aspecto de la aplicación nos ayudará a mantener este esfuerzo manejable y además las hará más fáciles de entender, y por lo tanto más eficientes. Idealmente, el cambio de un detalle del funcionamiento de nuestra aplicación debería afectar exclusivamente a una prueba que sólo verifica ese detalle, o, dicho de otra manera:

  • si es relativamente fácil cambiar un cierto aspecto del funcionamiento sin que falle ninguna prueba, tenemos una laguna en la cobertura de la batería; [13]
  • si falla más de una, la batería tiene código redundante, incrementando el coste de mantenimiento;
  • si la prueba que falla incluye la verificación de elementos que no están directamente relacionados con nuestro cambio, probablemente sea demasiado compleja, dado que introducir el cambio en el sistema requiere tener en cuenta aspectos independientes de la aplicación.

4.1. Ejemplo de pruebas centradas

Volvamos a donde dejamos el ejemplo del traductor y supongamos que lo siguiente que queremos hacer es separar las palabras del texto original no sólo mediante espacios, sino también mediante cambios de línea. Como estamos guiando los cambios con pruebas, añadimos a SpanishIntoEnglishTranslatorTest una prueba que verifique el nuevo funcionamiento.

test("splits by change of line") {
  translator.translate("yo\nsoy") should be("I am")
}

Ejemplo de prueba no centrada

El problema que tiene esto es que la prueba mezcla la separación del texto original y la traducción de las palabras; la idea que queremos transmitir con este ejemplo estaría más clara si pudiéramos expresar la entrada como "xxx\nxx" y la condición a cumplir como should be(Seq("xxx", "xx")); sin embargo, la forma actual del sistema no lo permite, porque la traducción es parte del método que estamos probando.

Supongamos además que el siguiente incremento funcional afectase a la traducción de palabras en sí, por ejemplo, cambiando el idioma origen al francés o a otra variante del español; este cambio afectaría a cada una de las pruebas de SpanishIntoEnglishTranslatorTest, pero, ¿por qué debería verse afectada una prueba como test("splits by change of line"), cuyo propósito es probar la separación en palabras?

Podemos ver estas deficiencias de nuestra batería como el resultado de una granularidad inapropiada, dado que la misma prueba está verificando varias cosas: separación, traducción y reunión de palabras. La solución consistiría en refactorizar antes de aplicar el cambio: ¿Quizá la clase que se encarga de descomponer y componer debería ser distinta de la que traduzca palabra por palabra?

Extraemos la división de palabras a su propia unidad, con lo que podemos expresar, con una prueba más centrada, la división del texto a traducir por saltos de línea:

class SplitterTest extends FunSuite with ShouldMatchers {
  test("splits by space") {
    Splitter("xxx xx") should be(Seq("xxx", "xx"))
  }

  test("splits by change of line") {
    Splitter("xxx\nxx") should be(Seq("xxx", "xx"))
  }
}

Prueba del divisor de palabras (example3/SplitterTest.scala)

object Splitter extends ((String) => Seq[String]) {
  def apply(s: String): Seq[String] = s split """[ \n]"""
}

Código para el divisor de palabras (example3/Splitter.scala)

El traductor lo recibe como una dependencia inyectada mediante el constructor. [14]

class SpanishIntoEnglishTranslatorTest
    extends FunSuite with ShouldMatchers with BeforeAndAfter {

  var translator: SpanishIntoEnglishTranslator = _

  before {
    translator = new SpanishIntoEnglishTranslator(_ split ' ')
  }

  test("translates what it can") {
    translator.translate("yo soy") should be("I am")
  }

  test("mmmehs what it can't") {
    translator.translate("dame argo") should be("mmmeh mmmeh")
  }
}

Pruebas para un traductor desacoplado del divisor de palabras (example3/SpanishIntoEnglishTranslatorTest.scala)

class SpanishIntoEnglishTranslator(val splitter: (String) => Seq[String]) {
  def translate(spanish: String): String = {
    val split = splitter(spanish)
    split.map(_ match {
      case "yo" => "I"
      case "soy" => "am"
      case _ => "mmmeh"
    }).mkString(" ")
  }
}

Código del traductor desacoplado del divisor de palabras (example3/SpanishIntoEnglishTranslator.scala)

El aumento de la granularidad nos ha permitido que la introducción de funcionalidad nueva no afecte a multitud de pruebas. Sin embargo, esto no ha sido gratis; hemos aumentado la complejidad del código. Al final, todas estas decisiones hay que valorarlas una a una y decidir qué es lo más apropiado en cada caso, teniendo en cuenta aspectos como la complejidad, el tiempo de ejecución y la dirección en la que esperamos y queremos que vaya el proyecto.

5. Mantener verde la batería

La batería de pruebas es la documentación de la funcionalidad de nuestro código. Una documentación que se mantiene al día, porque va creciendo con cada cambio y es ejercitada, es decir, ejecutamos las pruebas, al menos con cada envío de los cambios al repositorio.

Trabajar dirigido por pruebas significa mantener siempre el correcto funcionamiento del sistema; idealmente la última versión en el repositorio común deberá estar en todo momento lista para ponerla en explotación, y las pruebas satisfechas en todo momento [15], con lo que la documentación proporcionada por las pruebas estará siempre al día. Para lograrlo, deberemos comprobar la satisfacción de las pruebas antes de enviar cualquier cambio al repositorio común; además, muchos equipos se ayudan de un sistema de integración continua que verifica automáticamente la batería cada vez que se detecta un cambio en el repositorio.

A medida que crece la aplicación, el tiempo que requiere la batería completa tiende a aumentar, lo que incrementa el coste del desarrollo y motiva a los desarrolladores a no siempre satisfacer la batería completa o a espaciar los envíos de cambios al repositorio común; ambos efectos muy perniciosos. Para mantener viable la programación dirigida por pruebas, debemos esforzarnos en mantener reducido este tiempo; la manera de lograrlo va más allá de un artículo introductorio, pero incluye la selección y el ajuste de la tecnología empleada para los distintos elementos de la batería, la ejecución en paralelo e incluso la partición de la aplicación en sí o cualquier ajuste que la haga más rápida.[16]

Otro problema relacionado con el coste de la batería son los fallos intermitentes, que necesitarán un esfuerzo importante de mantenimiento; hemos de invertir el esfuerzo necesario para entender la raíz de cada fallo y resolverlo. Las fuentes típicas de fallos intermitentes son los aspectos no deterministas del software; por ejemplo, cuando lo que verificamos es de naturaleza asíncrona, necesitamos controlar el estado de la aplicación mediante puntos de sincronización. Algunas condiciones son imposibles de verificar per se, como la ausencia de comportamiento; en estos casos a menudo la solución pasa por alterar la aplicación desvelando su estado lo suficiente como para que las pruebas puedan sincronizarse con la aplicación y sepan cuándo debería haber tenido lugar el evento que estemos verificando. También suelen causar fallos intermitentes las dependencias de elementos fuera del control de la prueba, como el reloj del sistema; y su solución pasa normalmente por la inclusión y el control de dicho elemento desde la prueba, por ejemplo, alterando la percepción del tiempo de la aplicación mediante un proxy controlable desde las pruebas.

6. Críticas

6.1. Diseño prematuro

El ejemplo de pruebas centradas ilustra también una de las principales críticas contra el desarrollo dirigido por pruebas: para poder probar la clase de traducción satisfactoriamente, la hemos descompuesto en un diccionario y un desensamblador/ensamblador de palabras; sin embargo, si de verdad fuéramos a diseñar un sistema de traducción automatizada esta abstracción no sería apropiada, ya que el diccionario necesita el contexto, la disposición de palabras en el texto resultante depende de la función gramatical, etc. ¿Significa esto que el TDD nos ha guiado en la dirección incorrecta?

Como dijimos antes, desarrollar dirigidos por pruebas significa considerar las pruebas como usuarios de pleno derecho de nuestro código, añadiendo, así, un coste en cantidad de código y en la complejidad del diseño que elegimos pagar para beneficiarnos de la guía de las pruebas; optar por el TDD es considerar que este coste vale la pena. Sería menos costoso desarrollar sin la guía de las pruebas si supiésemos exactamente cuáles son los requisitos e incluso qué código escribir desde el principio. El TDD se opone a esta visión considerando el desarrollo como un proceso progresivo en el que el equipo va descubriendo qué y cómo desarrollar, a medida que crea la aplicación y la va poco a poco transformando, enriqueciendo y simplificando, pasando siempre de un sistema que funciona a un otro sistema que funciona, y que hace quizá un poco más que el anterior.

Volviendo a la prematuridad del diseño, si bien es cierto que las pruebas a veces adelantan la necesidad de descomponer el código, estas decisiones se vuelven más baratas dado que los cambios en el diseños son menos costosos gracias a la protección que nos da nuestra batería. Y, además, esto se ve también compensado por las innumerables ocasiones en las que no añadiremos complejidad al código de explotación porque esa complejidad no es necesaria para satisfacer las pruebas, probablemente porque no lo es en absoluto para lo que requerimos de nuestra aplicación en ese momento.

6.2. Guiar sólo mediante pruebas funcionales

Algunos equipos experimentados deciden utilizar las pruebas para guiar el desarrollo a nivel funcional, pero no a nivel unitario, sólo escribiendo pruebas unitarias cuando no resulta práctico cubrir con pruebas funcionales ciertas partes del código. Esto lo hacen porque ven las pruebas unitarias más como un lastre que como una guía útil en el diseño interno de la aplicación, quizá porque consideran que su criterio es suficiente para lograr un buen diseño interno. Nuestra recomendación es comenzar guiando todas las unidades por pruebas y progresar hacia un equilibrio en el que no probemos a nivel unitario el código que sea obvio, pero donde reconozcamos que el código que no merece la pena guiar mediante pruebas por ser obvio probablemente es poco útil [17], y que el que es difícil de probar probablemente peca de acoplado. La recomendación es, en definitiva, aplicar el sentido común pero no renunciar a la guía que nos proporcionan las pruebas en el desarrollo de los niveles inferiores de la aplicación.

7. Conclusión

El desarrollo dirigido por pruebas nos ayuda a construir aplicaciones donde aquello que nos motiva a escribir el software guía cada línea de código, evitando a cada nivel desperdiciar nuestros esfuerzos en desarrollar funcionalidad que no sea exactamente la que necesitamos, o que sea simplemente innecesaria. La guía de las pruebas, combinada con el diseño continuo, hace posible un estilo de desarrollo orgánico, en el que el equipo evoluciona la aplicación, a medida que mejora su conocimiento de los requisitos del dominio y de la implementación ideal para resolverlos. Además, la utilización de pruebas desde el primer momento, desvela el acoplamiento y nos incita a descomponer el código en unidades cohesivas con depencias claramente identificadas.

Si bien no hay un concenso absoluto en la idoneidad del desarrollo guiado por pruebas, muchos equipos lo usan, particularmente para aplicaciones comerciales y en lenguajes orientados a objetos. Nuestra opinión es que, además de dar sentido y ritmo a cada actividad del desarrollo, lo hace más divertido, porque da al desarrollador más libertad para mejorar y acrecentar el software.

Por Joaquín Caraballo,
Licencia Creative Commons


[8] Si estamos usando un entorno de desarrollo, la función de arreglo hará la mayor parte del trabajo por nosotros. En muchos lenguajes como Scala, el compilador nos obligará a incluir alguna implementación antes de permitirnos ejecutar. Algunos desarrolladores suelen implementar inicialmente los métodos lanzando una excepción como en el ejemplo, lo que ayuda a mantener la separación rojo-verde, ya que no se piensa en la implementación hasta el paso verde. Aunque esto pueda parecer prolijo, resulta bastante rápido de producir si tenemos preparada una plantilla en nuestro entorno que introducimos con un atajo. Otra opción es generar la implementación más sencilla que se nos ocurra —por ejemplo, devolviendo 0 o null—.

[9] Para los lectores más familiarizados con Java que con Scala, los objectos creados con object en Scala son semejantes a la parte estática de las clases en Java; los métodos definidos bajo object no están ligados a ningún ejemplar (en inglés instance) de la clase MilesToKilometersConverter —que de hecho aquí no existe—. Lo cierto es que estos objetos sí que pueden tener estado (de la misma manera que podemos tener campos estáticos en Java), pero muchos equipos eligen no usar esta característica del lenguaje, a no ser que haya un buen motivo; por eso decimos que definir el método convert en el objeto sugiere que el conversor no tiene estado.

[10] A esta generalización Kent Beck la llama triangulación. No estoy seguro de que me guste el término, porque la triangulación geométrica a la que hace analogía permite de forma determinista encontrar una posición a partir de los datos de que se dispone. Aquí, sin embargo, los ejemplos por sí solos no nos permitirían encontrar la solución general, que precisa que además entendamos el problema más allá de los ejemplos.

[11] Para mí el más importante, seguro que otros discreparán.

[12] En este ejemplo hemos usado dobles de prueba para verificar el comportamiento de Converter con sus colaboradores; simplemente estamos comprobando que, cuando llamamos al método doGet() pasando un objeto de tipo HttpServletRequest y otro de tipo HttpServletResponse, el método bajo prueba llama al método getWriter del segundo parámetro y, al devolver getWriter un objeto de tipo PrintWriter, el método bajo prueba llama al método print del PrintWriter con el parámetro "1.609". Gracias a herramientas como Mockito ([mockito]), que hemos usado en el ejemplo, es más fácil explicar la interacción en código que en español. En Diseño ágil con TDD ([bleyco-bis]) se explica el uso de dobles y de otros aspectos de la programación dirigida por pruebas.

[13] El tipo de pruebas con las que guiamos el desarrollo en este artículo documentan el comportamiento con buenos ejemplos, pero generalmente no lo garantizan para todas las posibles entradas, ni aspiran a hacerlo; por lo tanto, normalmente es muy fácil cambiar el comportamiento de forma intencionada sin que las pruebas dejen de satisfacerse —por ejemplo, con algo como if(input==15) throw new EasterEgg—; una buena cobertura en TDD está en el punto de equilibrio en el que sea poco probable cambiar la funcionalidad accidentalmente sin que fallen las pruebas.

[14] En este ejemplo nos hemos quedado con pruebas que ejercitan las unidades unidades independientemente; perdiendo la cobertura de la combinación de ambas unidades. Sin embargo, en la práctica, este código formaría parte de un proyecto mayor que contaría con baterías de pruebas de mayor ámbito, que incluirían la verificación del comportamiento conjunto.

[15] De hecho, algunos equipos hacen exactamente eso, ponen cada versión que satisface la batería completa automáticamente en explotación.

[16] Otra forma de reducir el tiempo es transigiendo en alguna medida, es decir, no cumpliendo en ocasiones todo lo que describimos en este artículo.

[17] En algunos casos, debido a una convención sospechosa o a las limitaciones de nuestro lenguaje de programación —por ejemplo, los métodos set y get en Java—.

 

This entry was posted in Blog. Bookmark the permalink.