En la migración del blog a GitHub Pages uno de los objetivos era no perder contenido, por lo que una vez puesta en pie toda la infraestructura, toca migrar los posts (mucho me temo que los comentarios si se van a perder…). Solución: Hacer un pequeño programa en Java (casi que diría script) que realice automáticamente esta conversión, además voy a seguir TDD para “mantenerme en forma”. En un principio lo voy a plantear como una mera conversión de formatos, como formato inicial tengo el que devuelve Wordpress para la exportación: Wordpress Extended RSS y como formato final quiero un archivo en el formato específico de JBake, que no deja de ser Markdown con unas cabeceras (metadata) particulares:
- title: El título del post
- date: La fecha del post
- type: Será siempre post
- tags: Las etiquetas del post
- status: Será siempre published
El WXR es un solo archivo con una serie de elementos item que corresponde cada uno a un post, un elemento item tiene los siguientes campos interesantes:
- title: casa con la cabecera title que quiero
- pubdate: casa con la cabecera date
- category: Las categorías se dividen en dominios que puede ser category (bien Wordpress, bien) o post_tag, en concreto me interesan solo aquellas de tipo post_tag y su contenido, es decir tendré que concatener el contenido de todas las categorías de tipo post_tag.
- content: Este es el contenido el post en sí, como se puede ver viene en HTML tal cual dentro de un CDATA, esto me permite aprovechar que con Markdown puedo utilizar el HTML inline así que en un principio lo voy a volcar tal cual, aunque preveo ciertos problemas con las etiquetas de código…
Por último, por cada item quiero generar un archivo con el nombre dd-title.md (donde dd es el día de la fecha) dentro de una carpeta mm (mes) dentro de una carpeta aaaa (año…).
Pues con esto, empezamos!! Primero: crear el projecto en Intellij y con Maven, creo el repositorio en GitHub y lo añado. A continuación, actualizo el .gitignore, hago el commit inicial y cambio a la rama development.
El comienzo es un no brainer, necesito un main que arranque la aplicación como tal y que recibirá como parámetros:
- El nombre del archivo WXR
- El directorio de salida
Eso quiere decir que la clase de entrada a la aplicación (Wp2JBake) tendrá un constructor con dos parámetros, así que siguiendo TDD, empiezo con los tests:
- Construir con los parámetros a null.
- Construir con el archivo origen a null.
- Construir con el directorio destino a null.
En todos estos casos lanzaré una InvalidArgumentException, así que inicialmente tendría como pruebas algo así:
private Wp2JBake sut;
@Test(expected = IllegalArgumentException.class)
public void buildWithoutParameters() {
sut = new Wp2JBake(null, null);
}
@Test(expected = IllegalArgumentException.class)
public void buildWithoutOrigin() {
sut = new Wp2JBake(null, "");
}
@Test(expected = IllegalArgumentException.class)
public void buildWithoutDestination() {
sut = new Wp2JBake("", null);
}
Y como implementación lo siguiente:
public Wp2JBake(String origin, String destination) {
if (origin == null) {
throw new IllegalArgumentException("Origin is not a valid file");
}
if (destination == null) {
throw new IllegalArgumentException("Destination is not a valid folder");
}
}
Pero… un segundo, ¿me dá igual la IllegalArgumentException que se lanza? No, en cada caso quiero verificar que se esta lanzando la que se debe, refactorizo las pruebas, ahora voy a utilizar un @Rule de JUnit para comprobar que se lanza la excepción y el mensaje de error:
@Rule
public ExpectedException thrown = ExpectedException.none();
private Wp2JBake sut;
@Test
public void buildWithoutParameters() {
thrown.expect(IllegalArgumentException.class);
thrown.expectMessage("Origin");
sut = new Wp2JBake(null, null);
}
@Test
public void buildWithoutOrigin() {
thrown.expect(IllegalArgumentException.class);
thrown.expectMessage("Origin");
sut = new Wp2JBake(null, "foo");
}
@Test
public void buildWithoutDestination() {
thrown.expect(IllegalArgumentException.class);
thrown.expectMessage("Destination");
sut = new Wp2JBake("foo", null);
}
Vale, ya he controlado que no sea null, ahora toca comprobar que tampoco sea cadena vacía:
@Test
public void buildWithEmptyOrigin() {
thrown.expect(IllegalArgumentException.class);
thrown.expectMessage("Origin");
sut = new Wp2JBake("", "");
}
@Test
public void buildWithEmptyDestination() {
thrown.expect(IllegalArgumentException.class);
thrown.expectMessage("Destination");
sut = new Wp2JBake("foo", "");
}
Ahora toca cambiar la implementación, me voy a apoyar en las commons-lang:
public Wp2JBake(String origin, String destination) {
if (StringUtils.isEmpty(origin)) {
throw new IllegalArgumentException("Origin is not a valid file");
}
if (StringUtils.isEmpty(destination)) {
throw new IllegalArgumentException("Destination is not a valid folder");
}
}
Siguiente restricción, el origen además debe existir:
@Test
public void buildWithInvalidOrigin() {
thrown.expect(IllegalArgumentException.class);
thrown.expectMessage("Origin");
sut = new Wp2JBake("foo", "");
}
Y la implementación:
public Wp2JBake(String origin, String destination) {
if (StringUtils.isEmpty(origin) || !existsOrigin(origin)) {
throw new IllegalArgumentException("Origin is not a valid file");
}
if (StringUtils.isEmpty(destination)) {
throw new IllegalArgumentException("Destination is not a valid folder");
}
}
private boolean existsOrigin(String origin) {
File originFile = new File(origin);
return originFile.exists();
}
Esta implementación hace saltar las pruebas de origen inválido, claro como para “callar” los tests estoy pasando como primer parámetro una cadena cualquiera, ahora falla porque no existe el parámetro foo. Aquí hay dos opciones:
- Pasar un archivo que si exista.
- Cambiar la implementación para que primero compruebe que la cadena es válida en los dos casos y después que compruebe si el archivo es válido.
El problema de 2 es que tendría que lanzar la misma excepción dos veces mientras que el de 1 es que se parecería más a un test de integración que a una prueba unitaria en sí. Para mi gusto esta es una de las zonas grises en TDD, porque, ¿ahora qué hago?¿Creo un mock del SUT? No lo veo claro, así que trataré de tirar por el camino del medio y pasar una ruta de archivo que sepa que siempre existe, por ejemplo, el pom.xml.
Ahora podría seguir comprobando que el destino no sea inválido, pero… ¿puede serlo? Al ser un directorio, si no existe, debería crearlo y si existe, no hacer nada. En todo caso la comprobación debería ser si se puede crear el directorio y si se puede escribir en él.
De aquí saco estas dos pruebas:
@Test
public void buildWithNonWritableDestination() {
File destination = new File("destination");
destination.mkdir();
destination.deleteOnExit();
destination.setReadOnly();
thrown.expect(IllegalArgumentException.class);
thrown.expectMessage("Destination");
sut = new Wp2JBake("pom.xml", destination.getAbsolutePath());
}
@Test
public void buildWithNonWritableDestinationParent() {
File destinationParent = new File("destinationParent");
destinationParent.mkdir();
destinationParent.deleteOnExit();
destinationParent.setReadOnly();
thrown.expect(IllegalArgumentException.class);
thrown.expectMessage("Destination");
sut = new Wp2JBake("pom.xml", destinationParent.getAbsolutePath() + File.separator + "destination");
}
Y la implementación sigue creciento:
public Wp2JBake(String origin, String destination) {
if (StringUtils.isEmpty(origin) || !existsOrigin(origin)) {
throw new IllegalArgumentException("Origin is not a valid file");
}
if (StringUtils.isEmpty(destination) || !isWritable(destination)) {
throw new IllegalArgumentException("Destination is not a valid folder");
}
}
private boolean isWritable(String destination) {
File destinationFolder = new File(destination);
if (destinationFolder.exists()) {
return destinationFolder.canWrite();
} else {
return destinationFolder.getParentFile().canWrite();
}
}
private boolean existsOrigin(String origin) {
File originFile = new File(origin);
String path = originFile.getAbsolutePath();
return originFile.exists();
}
Por último me quedaría probar el caso en el que ambos parámetros son válidos:
@Test
public void buildWithValidParameters() {
sut = new Wp2JBake("pom.xml", "destination");
File destination = new File("destination");
destination.delete();
}
Con esto puedo empezar a refactorizar y a remplatearme las cosas. La verdad que Wp2JBake empieza a tener un tamaño considerable teniendo en cuenta que tan sólo tiene como API un constructor. La verdad que las comprobaciones que estoy haciendo sobre los parámetros no me convencen, me dan la impresión de que estoy violando el Single Responsability, por otra parte sería un poco artificial crear una clase de validadores únicamente.