Mocks aren't stubs - Développement piloté par les tests et doublures d’objets


L'application testée

L'exemple d'application employé ici se veut simpliste afin de se concentrer sur l'essentiel : la différence entre les deux styles de test. Il y'a deux objets : un entrepôt (warehouse) et une commande (order). L'entrepôt contient différents types de produits dans des quantités limitées. Une commande concerne un produit pour une quantité donnée. S'il y'a suffisamment de stock dans l'entrepôt, la commande est passée ; sinon, rien ne se passe. C'est donc un simple couple d'objets producteur/consommateur.

Seul le code source des tests sera présentés pour cet exemple. Par ailleurs, le langage est ici Java mais les concepts sont applicables à tous les langages.


Test classique

Voici un test classique :

			
public class OrderClassicTest extends TestCase {

	private static String GUINESS = "Guiness";
	private static String CHIMAY = "Chimay";
	private Warehouse warehouse;

	// Initialization, processed before each test thanks to the Before annotation
	@Before
	protected void setUp() throws Exception {
		// The fixtures or, collaborators
		warehouse = new Warehouse();
		warehouse.add(GUINESS, 50);
		warehouse.add(CHIMAY, 25);
	}
	
	// Teardown, precessed after each test thanks to the After annotation
	@After
	protected void tearDown() throws Exception {
		warehouse = null;
	}

	public void testOrderIsFilledIfEnoughInWarehouse() {
		// The System Under Testing
		Order order = new Order(GUINESS, 50);
		
		// Exercise
		order.fill(warehouse);
		
		// Check
		assertTrue(order.isFilled());
		assertEquals(0, warehouse.getInventory(GUINESS));
	}

	public void testOrderDoesNotRemoveIfNotEnough() {
		Order order = new Order(GUINESS, 51);
		order.fill(warehouse);
		assertFalse(order.isFilled());
		assertEquals(50, warehouse.getInventory(GUINESS));
	}
}
			
			

L'objet testé est donc ici Order. En anglais on parle de SUT pour System Under Testing. Les autres objets - en l'occurrence ici Warehouse -, sont les collaborateurs. Le SUT en a besoin pour fonctionner.

Une fois cette distinction faite, il est possible de se pencher sur les différentes étapes d'un test classique :

Le test unitaire classique intéragit alors avec le SUT et ses collaborateurs et vérifie leurs états.


Test avec mock

Un mock permet de simuler un objet sans même à avoir à l'écrire, généralement grâce à un framework de mocking. Il suffit de partir d'une interface disposant des méthodes à implémenter pour le fonctionnement de notre SUT. Le framework générera un objet anonyme via l'interface "mockée". C'est au développeur ensuite de déterminer quels appels de méthodes sont attendus sur ce mock, combien de fois, avec quels paramètres et quelles valeurs de retour. Après l'exécution du test, il faut vérifier que les méthodes ont été appelées convenablement sur le mock.

Ci-dessous, un test vérifiant les mêmes fonctionnalités que le test classique, mais en utilisant cette fois-ci un mock, grâce au framework Jmock :

			
public class OrderMockistTest extends MockObjectTestCase {
	@Rule public JUnitRuleMockery context = new JUnitRuleMockery();
	public Order order;
	private static String GUINESS = "Guiness";
	
	@Test
	public void testFillingRemovesInventoryIfInStock() {
		// Setup - data
		Order order = new Order(GUINESS, 50);
		final WarehouseInterface warehouseMock = context.mock(WarehouseInterface.class);
		
		// Setup - expectations
		context.checking(new Expectations() {{
			oneOf(warehouseMock).hasInventory(with(equal(GUINESS)), with(equal(50))); will(returnValue(true));
			oneOf(warehouseMock).remove(GUINESS, 50);
		}});
		
		// Exercise + Verify
		order.fill(warehouseMock);
		
		// Verify
		assertTrue(order.isFilled());
	}
	
	@Test
	public void testFillingDoesNotRemoveIfNotEnoughInStock() {
		// Setup
		Order order = new Order(GUINESS, 51);    
		final WarehouseInterface warehouseMock = context.mock(WarehouseInterface.class);

		context.checking(new Expectations() {{
			oneOf(warehouseMock).hasInventory(with(any(String.class)), with(any(int.class))); will(returnValue(false));
		}});

		// Exercise and verify
		order.fill(warehouseMock);
		assertFalse(order.isFilled());
	}
}
			
			

Les étapes sont les mêmes qu'avant : initialisation, exécution, vérification. Néanmoins, l'initialisation diffère, puisqu'elle n'instancie pas un objet de type Warehouse, mais un mock à partir de l'interface WarehouseInterface. Cette interface dispose des méthodes nécessaires à l'implémentation de Warehouse pour son bon fonctionnement avec Order.

Une deuxième phase d'initialisation est exclusive au test avec mock : l'intialisation des attentes, les "Expectations". Il s'agit de la partie du test où le développeur écrit le comportement attendu par l'objet mocké.

C'est sans doute la partie la moins simple à assimiler du mocking, d'autant plus que tous les frameworks n'ont pas la même stratégie pour vérifier les attentes comportementales sur les objets mockés. Jmock a une approche plutôt simple et il est intéressant de s'attarder sur le code :

			
			final WarehouseInterface warehouseMock = context.mock(WarehouseInterface.class);
			...
			// Setup - expectations
			context.checking(new Expectations() {{
				oneOf(warehouseMock).hasInventory(with(equal(GUINESS)), with(equal(50))); will(returnValue(true));
				oneOf(warehouseMock).remove(GUINESS, 50);
			}});
			...
			
			

La création du mock d'un objet de Warehouse se fait grâce à un objet context déclaré dans les paramètres de la classe de test. C'est l'objet au coeur du framework Jmock.

Le second appel sur context est l'invocation de la méthode checking(). Cette méthode prend en paramètre un objet Expectations. C'est une collection propre à Jmock, dans laquelle on décrit tous les appels attendus sur les mocks lors de l'exécution du test. Dans l'exemple, cela signifie que l'objet warehouseMock sera surveillé par Jmock lorsqu'il sera utilisé à l'intérieur de l'appel order.fill(wareouseMock). Cela signifie que la phase d'exécution du test est combinée avec une phase de vérification. Cela est prope à Jmock, d'autres frameworks font appel à une méthode de vérification.

Bien que les frameworks de mocking aient tous leur syntaxe, l'écriture des attentes suit généralement la même structure. Voici un l'exemple détaillé pour Jmock :

Si à la fin du test, la méthode hasInventory n'a pas été appelée une fois seulement, avec les paramètres "GUINESS" et "50", le test échouera. Sinon, la méthode retournera true. En écrivant le test ainsi, il faut en déduire que la méthode fill() doit utiliser les paramètres passés en construction de l'objet order afin d'effectuer les opérations sur l'objet collaborateur, warehouse.

Ce n'est donc pas un test sur l'état de l'objet, mais de son comportement et de sa collaboration avec les autres objets.


Synthèse

Les deux styles de test sont fondamentalement différents dans leur approche :

À première vue, l'utilisation de l'une ou l'autre méthode ne semble pas changer beaucoup de chose si ce n'est l'écriture du test, et la possibilité de s'abstenir d'écrire une classe entière pour tester grâce à un mock. Néanmoins, non seulement le mock n'est pas le seul objet simulé, mais le choix d'une technique ou d'une autre prend tout son sens lorsque les tests sont écrits avant le code applicatif. On parle alors de développement piloté par les tests, Test Driven Development en anglais.