Introducción a los dobles para pruebas

Como ya he comentado en artículos anteriores, los tests de unidad deben ser independientes y autocontenidos, deberían utilizar la mínima cantidad de infraestructura necesaria para hacer sus pruebas, y deberían estar escritos de forma que sólo fallen o tengan éxito si la unidad que estamos probando falla o funciona correctamente.

Con esto en mente, vamos a escribir unos cuantos tests de unidad para esta clase:

public class WebPageTranslator {
	public String translate(String url, String fromLanguage, String toLanguage) {
		Downloader downloader = new HttpDownloader();
		String page = downloader.download(url);
		if (page == null || fromLanguage.equals(toLanguage)) {
			return page;
		}
		Translator translator = new GoogleTranslator();
		return translator.translate(page, fromLanguage, toLanguage);
	}
}

Como podéis apreciar, vamos a tener un problema bastante gordo a la hora de escribir tests, independientes, autocontenidos y con poca infraestructura. En particular, esta clase depende de HttpDownloader y de GoogleTranslator, que (se supone) siempre descargan contenido de la web y utilizan Google Translate. Esto significa que nuestros tests también dependerán de estas dos clases, por lo que no serán autocontenidos, y podrán fallar si por algún motivo no se puede descargar algo de la web o Google Translate no responde.

Para solucionar este problema debemos rediseñar la clase para que se le puedan inyectar sus dependencias, y utilizar en lugar de HttpDownloader y GoogleTranslator unas clases especiales para pruebas que no necesiten acceder a Internet ni a los servicios de Google:

public class WebPageTranslator {
	private final Downloader downloader;
	private final Translator translator;

	@Inject
	public WebPageTranslator(Downloader downloader, Translator translator) {
		this.downloader = downloader;
		this.translator = translator;
	}

	public String translate(String url, String fromLanguage, String toLanguage) {
		String page = downloader.download(url);
		if (page == null || fromLanguage.equals(toLanguage)) {
			return page;
		}
		return translator.translate(page, fromLanguage, toLanguage);
	}
}

Vamos a escribir ahora unos cuantos tests de unidad, y veremos más adelante cómo serían estas dos clases especiales para tests:

@Test
public void testTranslate() {
	Downloader downloader = new StubDownloader("Valeu!");
	Translator translator = new MockTranslator("Valeu!", "Thank you!",
			"pt", "en");
	WebPageTranslator pageTranslator = new WebPageTranslator(downloader,
			translator);
	assertEquals("Thank you!", pageTranslator.translate(
			"http://example.com/valeu.html", "pt", "en"));
}

@Test
public void doesNotTranslateWhenLanguagesAreEqual() throws Exception {
	Downloader downloader = new StubDownloader("Valeu!");
	Translator translator = new DummyTranslator();
	WebPageTranslator pageTranslator = new WebPageTranslator(downloader,
			translator);
	assertEquals("Valeu!", pageTranslator.translate(
			"http://example.com/valeu.html", "pt", "pt"));
}

@Test
public void returnsNullWhenDownloadFails() throws Exception {
	Downloader downloader = new StubDownloader(null);
	Translator translator = new DummyTranslator();
	WebPageTranslator pageTranslator = new WebPageTranslator(downloader,
			translator);
	assertNull(pageTranslator.translate("http://example.com/valeu.html",
			"pt", "en"));
}

@Test
public void returnsNullWhenTranslatorFails() throws Exception {
	Downloader downloader = new StubDownloader("Valeu!");
	Translator translator = new MockTranslator("Valeu!", null, "pt", "en");
	WebPageTranslator pageTranslator = new WebPageTranslator(downloader,
			translator);
	assertNull(pageTranslator.translate("http://example.com/valeu.html",
			"pt", "en"));
}

Aquí tenemos cuatro tests, que comprueban qué ocurre en el caso normal, cuando el idioma de origen y de destino son el mismo, cuando falla la descarga del texto y cuando falla la llamada al traductor. Para cada uno de estos tests estamos utilizando instancias de StubDownloader, MockTranslator y DummyTranslator, que son clases especiales que hemos creado para los tests, que cumplen las interfaces Downloader y Translator pero que hemos implementado de forma muy simple y están bajo nuestro completo control.

Veamos qué hacen estas tres clases:

La clase StubDownloader devuelve siempre el mismo valor cuando se llama a su método translate(); este valor es el que le hemos pasado en el constructor. En todos los tests este valor es el texto de la página que queremos traducir; en el segundo test, sin embargo, el valor es null para simular un fallo en la descarga.

La clase MockTranslator comprueba que, cuando llamamos al método translate(), le pasamos parámetros con ciertos valores esperados, y si es así, devuelve un resultado predeterminado. Como en el caso anterior, estos valores esperados y resultado predeterminado se le pasan en el constructor.

La clase DummyTranslator no hace nada; simplemente existe para poder inyectar un objeto de tipo Translator en los tests en los que no se realiza ninguna llamada a translate().

Aquí está el código completo de los tests de unidad y de esas tres clases, por si tenéis curiosidad:

public class WebPageTranslatorTest {
	@Test
	public void testTranslate() {
		Downloader downloader = new StubDownloader("Valeu!");
		Translator translator = new MockTranslator("Valeu!", "Thank you!",
				"pt", "en");
		WebPageTranslator pageTranslator = new WebPageTranslator(downloader,
				translator);
		assertEquals("Thank you!", pageTranslator.translate(
				"http://example.com/valeu.html", "pt", "en"));
	}

	@Test
	public void doesNotTranslateWhenLanguagesAreEqual() throws Exception {
		Downloader downloader = new StubDownloader("Valeu!");
		Translator translator = new DummyTranslator();
		WebPageTranslator pageTranslator = new WebPageTranslator(downloader,
				translator);
		assertEquals("Valeu!", pageTranslator.translate(
				"http://example.com/valeu.html", "pt", "pt"));
	}

	@Test
	public void returnsNullWhenDownloadFails() throws Exception {
		Downloader downloader = new StubDownloader(null);
		Translator translator = new DummyTranslator();
		WebPageTranslator pageTranslator = new WebPageTranslator(downloader,
				translator);
		assertNull(pageTranslator.translate("http://example.com/valeu.html",
				"pt", "en"));
	}

	@Test
	public void returnsNullWhenTranslatorFails() throws Exception {
		Downloader downloader = new StubDownloader("Valeu!");
		Translator translator = new MockTranslator("Valeu!", null, "pt", "en");
		WebPageTranslator pageTranslator = new WebPageTranslator(downloader,
				translator);
		assertNull(pageTranslator.translate("http://example.com/valeu.html",
				"pt", "en"));
	}

	private class StubDownloader implements Downloader {
		private final String response;

		public StubDownloader(String response) {
			this.response = response;
		}

		@Override
		public String download(String url) {
			return response;
		}
	}

	private class DummyTranslator implements Translator {
		@Override
		public String translate(String text, String fromLanguage,
				String toLanguage) {
			throw new IllegalStateException();
		}
	}

	private class MockTranslator implements Translator {
		private final String text;
		private final String translation;
		private final String from;
		private final String to;

		public MockTranslator(String text, String translation, String from,
				String to) {
			super();
			this.text = text;
			this.translation = translation;
			this.from = from;
			this.to = to;
		}

		@Override
		public String translate(String text, String from, String to) {
			if (text.equals(this.text) && from.equals(this.from)
					&& to.equals(this.to)) {
				return translation;
			} else {
				throw new IllegalStateException(
						"translate() called with wrong arguments");
			}
		}
	}
}

Las tres clases que describí arriba son tres ejemplos de lo que en inglés llaman “test doubles” (podríamos llamarlas “dobles para pruebas” en español). Los dobles para pruebas son clases que se utilizan en los tests para sustituir a clases que requieren mucha infraestructura, se ejecutan lentamente, son difíciles de utilizar, etc. La gente que trabaja en el asunto suele distinguir cuatro tipos: “dummy”, “stub”, “mock” y “fake”. Los tres primeros tipos los hemos visto en los ejemplos anteriores; el cuarto, “fake”, es una implementación completa del interfaz utilizando tablas hash y otros sistemas para mantener todo en memoria en lugar de usar la red, el disco, la base de datos, etc.

No existen unos criterios bien formados sobre cuándo utilizar uno u otro tipo de doble para pruebas; en general, se utiliza lo que sea más fácil de usar y proporcione unos resultados más fiables. Por ejemplo, los objetos “fake” suelen tener un comportamiento muy similar al del objeto al que sustituyen, pero pueden necesitar mucho código para ponerlos en el estado adecuado para cada test. Los objetos “mock” o “stub” son más fáciles de preparar, pero si la persona que los usa no entiende bien cómo funciona el objeto al que sustituyen, pueden causar falsos positivos o negativos en los tests; además, los tests hechos a base de mocks suelen necesitar muchos cambios si cambia la implementación del objeto al que prueban, lo que no ocurre con tanta frecuencia en los tests hechos a base de fakes.

Otro inconveniente de los mocks es que hace falta escribir mucho código para definirlos; la clase MockTranslator, por ejemplo, tiene 26 líneas y no es particularmente sofisticada porque en cada test sólo se llama a un método una sola vez; imaginad qué pasaría si quisiéseis hacer un objeto mock para sustituir a un PreparedStatement. Sin embargo, este inconveniente se puede obviar utilizando EasyMock, que es una biblioteca que permite crear objetos mock en pocas líneas y con mucha facilidad.

Cuando se utiliza EasyMock sólo hay que crear un objeto mock llamando a una función de EasyMock, luego registrar qué métodos se van a llamar con qué parámetros y qué valor deben devolver, y luego ejecutar el test y comprobar que se hicieron todas las llamadas esperadas.

Como ejemplo, veamos qué aspecto tiene testTranslate() reescrito usando EasyMock:

@Test
public void testTranslate() throws Exception {
	Downloader downloader = EasyMock.createMock(Downloader.class);
	Translator translator = EasyMock.createMock(Translator.class);
	EasyMock.expect(downloader.download("http://example.com/valeu.html"))
			.andReturn("Valeu!");
	EasyMock.expect(translator.translate("Valeu!", "pt", "en")).andReturn(
			"Thank you!");
	EasyMock.replay(downloader, translator);
	WebPageTranslator pageTranslator = new WebPageTranslator(downloader,
			translator);
	assertEquals("Thank you!", pageTranslator.translate(
			"http://example.com/valeu.html", "pt", "en"));
	EasyMock.verify(downloader, translator);
}

En las dos primeras líneas se crean los objetos mock llamando a EasyMock.createMock() para cada interfaz. En las siguientes se le dice a EasyMock que va a haber llamadas a downloader.download() y translator.translate() con ciertos argumentos, y se le dice qué valores tiene que devolver. En la siguiente línea se le dice a EasyMock que ponga a downloader y translator en modo “replay”; a partir de este punto, cada vez que se haga una llamada a un método de cualquiera de estos dos objetos, EasyMock comprobará si era una llamada que esperaba y devolverá el valor indicado si lo era o emitirá una excepción si no lo era. Finalmente, en la última línea, se le dice a EasyMock que verifique si se han realizado todas las llamadas esperadas.

Así, a simple vista, parece que no hemos ganado mucho usando EasyMock, ya que hemos tenido que añadir cinco líneas al test; sin embargo, si reescribimos todos los tests para utilizar EasyMock podremos deshacernos de nuestros tres dobles para pruebas y reducir la cantidad total de código:

public class WebPageTranslatorTest {
	private Downloader downloader;
	private Translator translator;
	private WebPageTranslator pageTranslator;

	@Before
	public void setUp() {
		downloader = EasyMock.createMock(Downloader.class);
		translator = EasyMock.createMock(Translator.class);
		pageTranslator = new WebPageTranslator(downloader, translator);
	}

	@After
	public void tearDown() {
		EasyMock.verify(downloader, translator);
	}

	private void replay() {
		EasyMock.replay(downloader, translator);
	}

	@Test
	public void testTranslate() throws Exception {
		EasyMock.expect(downloader.download("http://example.com/valeu.html"))
				.andReturn("Valeu!");
		EasyMock.expect(translator.translate("Valeu!", "pt", "en")).andReturn(
				"Thank you!");
		replay();
		assertEquals("Thank you!", pageTranslator.translate(
				"http://example.com/valeu.html", "pt", "en"));
	}

	@Test
	public void doesNotTranslateWhenLanguagesAreEqual() throws Exception {
		EasyMock.expect(downloader.download("http://example.com/valeu.html"))
				.andReturn("Valeu!");
		replay();
		assertEquals("Valeu!", pageTranslator.translate(
				"http://example.com/valeu.html", "pt", "pt"));
	}

	@Test
	public void returnsNullWhenDownloadFails() throws Exception {
		EasyMock.expect(downloader.download("http://example.com/valeu.html"))
				.andReturn(null);
		replay();
		assertNull(pageTranslator.translate("http://example.com/valeu.html",
				"pt", "en"));
	}

	@Test
	public void returnsNullWhenTranslatorFails() throws Exception {
		EasyMock.expect(downloader.download("http://example.com/valeu.html"))
				.andReturn("Valeu!");
		EasyMock.expect(translator.translate("Valeu!", "pt", "en")).andReturn(
				null);
		replay();
		assertNull(pageTranslator.translate("http://example.com/valeu.html",
				"pt", "en"));
	}
}

Como podéis ver, es bastante fácil evitar introducir dependencias excesivamente onerosas en vuestros tests, utilizando inyección de dependencias y dobles para pruebas. Además, con EasyMock, podréis crear objetos mock con mucha facilidad, así que no tenéis excusas para no hacerlo :)

En el siguiente artículo veremos cómo se utilizan dobles para pruebas en lenguajes dinámicos, usando Python para los ejemplos. También estamos llegando al fin de la serie, así que si tenéis preguntas o dudas o lo que sea, hacédmelas llegar y trataré de responderlas en uno o más artículos posteriores.

(Primer artículo, siguiente artículo).

Comments

Gracias!

No conocía EasyMock, lo añado a mi arsenal :)
Por cierto, tampoco conocía guice. He usado Spring durante mucho tiempo, pero guice parece mucho más ligero! En el caso concreto en que yo usé Spring, necesitaba más elementos del stack que la inyección de dependencias, pero me lo apunto para otros proyectos...