Après l’article sur FEST-Assert, la série des Craftsman Recipes continue avec bon goût : j’ai nommé Mockito.
Le principe fondamental du test unitaire est qu’il doit remplir la condition sine qua none de l’unicité et de l’isolation complète. Cependant, dans certains cas, l’unité de test interagit directement avec des objets non instanciables, ni contrôlables aisément. Le Mocking est une réponse à cette problématique, puisque par définition, un Mock est un objet simulé qui reproduit le comportement d’un objet réel de manière contrôlée.
Voyons dans la pratique comment faire du Mocking avec Mockito et par la même occasion rendre nos tests plus joyeux. À noter que l’ensemble du code source est disponible sur Github.
Préambule
- Cette article n’a pas vocation à décrire, ou à expliquer, les différentes catégories de Mocks (mock / stub / dummy / fake). Si vous souhaitez en savoir plus, alors, je vous renvoie vers cette page, qui explique bien les différentes terminologies et la diversité de l’univers du Mocking.
- Les exemples de codes sont à titre purement illustratifs et volontairement simplistes, dans la vraie vie selon le cas de test, il aurait été préférable d’utiliser l’objet réel plutôt qu’un Mock, voir de réaliser des tests builders, afin de ne pas tomber dans les mauvaises pratiques du Mocking.
Classes métiers
Afin d’illustrer les exemples de l’article, nous utiliserons un cas métier simple élaboré à partir de commandes contenant une liste de produits.
La classe Product représente un produit avec un prix :
public class Product { private final BigDecimal price; public Product(BigDecimal price) { this.price = price != null ? price : BigDecimal.ZERO; } public BigDecimal getPrice() throws Exception { return price; } }
Rien de bien compliqué dans cette classe, si ce n’est que la méthode getPrice() peut déclencher une exception, en théorie, du moins.
Le classe Order représente une commande avec une liste de produits :
public class Order { List<Product> products; public Order(List<Product> products) { this.products = products != null ? products : new ArrayList<Product>(); } public BigDecimal getTotalPrice() throws Exception { BigDecimal total = BigDecimal.ZERO; for (Product product : products) { total = total.add(product.getPrice()); } return total; } public String formatTotalPrice(Locale locale) { try { return NumberFormat.getCurrencyInstance(locale).format(getTotalPrice()); } catch (Exception e) { return StringUtils.EMPTY; } } }
Cette classe contient deux méthodes :
- getTotalPrice() : retourne le prix total de la commande calculé à partir de la liste de produits.
- formatTotalPrice() : retourne le prix total de la commande formaté selon une Locale.
L’objectif est de réaliser des tests unitaires sur ces deux méthodes en utilisant, bien évidemment, des Mocks.
Création des Mocks
Nous allons donc écrire un test simple qui permet de vérifier la création d’une nouvelle commande avec deux nouveaux produits.
Mockito propose plusieurs méthodes pour instancier des Mocks. La façon la plus simple est d’utiliser la méthode static mock() :
public class OrderTest { @Test public void should_instantiate_an_order_with_2_products() throws Exception { Product product1 = mock(Product.class); Product product2 = mock(Product.class); Order order = new Order(newArrayList(product1, product2)); assertThat(product1).isNotNull(); assertThat(product2).isNotNull(); assertThat(order.products).hasSize(2); } }
Une deuxième méthode, un peu plus à la mode, est d’utiliser l’annotation @Mock :
@RunWith(MockitoJUnitRunner.class) public class OrderTest { @Mock Product product1; @Mock Product product2; @Test public void should_instantiate_an_order_with_2_products() throws Exception { Order order = new Order(newArrayList(product1, product2)); assertThat(product1).isNotNull(); assertThat(product2).isNotNull(); assertThat(order.products).hasSize(2); } }
Dans ce cas, il est nécessaire d’utiliser le runner MockitoJunitRunner qui permet d’instancier automatiquement les Mocks annotés.
Si vous trouvez cela un peu trop intrusif, alors il est aussi possible d’utiliser la méthode initMocks(), à l’initialisation du test :
public class OrderTest { @Mock Product product1; @Mock Product product2; @Before public void setUp() throws Exception { initMocks(this); } @Test public void should_instantiate_an_order_with_2_products() throws Exception { Order order = new Order(newArrayList(product1, product2)); assertThat(product1).isNotNull(); assertThat(product2).isNotNull(); assertThat(order.products).hasSize(2); } }
Cependant, je déconseille d’abuser de ces annotations, car d’expérience, les tests ont tendance à augmenter très vite en complexité due au nombre de traitements dans la phase d’initialisation. C’est souvent le cas lorsque l’on a besoin d’utiliser plusieurs frameworks de tests en même temps tels que Spring Test ou Arquillian. On se retrouve alors avec des attributs doublement ou triplement annotés, ce qui rend les tests de plus en plus compliqués à refactorer.
Simuler du comportement
En plus des différents modes de création, on peut aussi définir le comportement par défaut qu’aura notre Mock, autrement dit, les valeurs retournées lors d’un appel à une méthode ou d’un accès à un de ses attributs :
- RETURNS_DEFAULTS : retourne une valeur appropriée pour les primitives, les classes de wrapping (Integer, Boolean,…), les collections, et les méthodes toString() et compareTo() sinon null sera retourné. Actuellement, c’est le mode par défaut, en version 1.9.5.
- RETURNS_SMART_NULLS : c’est le même mode que précédemment, à la différence près que les messages d’exceptions sont plus explicites, puisqu’au lieu de renvoyer null, l’objet ReturnsSmartNull sera retourné. Deviendra, en version 2.0, le mode par défaut.
- RETURNS_MOCKS : c’est un mode utile, car au lieu de renvoyer null pour les cas non traités précédemment, Mockito va essayer de créer un Mock.
- RETURNS_DEEP_STUBS : c’est un mode très utile aussi mais à utiliser avec précaution, car il va permettre de gérer les longues chaînes d’appels que l’on retrouve souvent dans du legacy code.
En pratique, voici ce que ces différents comportements donnent :
@Test public void test_default_answer_mocking() throws Exception { // RETURNS_DEFAULTS Product product1 = mock(Product.class, RETURNS_DEFAULTS); assertThat(product1.getPrice()).isNull(); // product1.getPrice().abs() -> NPE avec un message d'exception non explicite // RETURNS_SMART_NULLS Product product2 = mock(Product.class, RETURNS_SMART_NULLS); assertThat(product2.getPrice()).isNotNull(); // product2.getPrice().abs() -> NPE avec un message d'exception explicite // RETURNS_MOCKS Product product3 = mock(Product.class, RETURNS_MOCKS); assertThat(product3.getPrice()).isNotNull(); assertThat(product3.getPrice().abs()).isNotNull(); assertThat(product3.getPrice().getClass().getName()).contains(BigDecimal.class.getName()); }
Il faut quand même garder à l’esprit une citation plutôt anecdotique mais vraie :
every time a mock returns a mock a fairy dies
Bon maintenant que l’on sait correctement créer nos Mocks, on va simuler le comportement d’un produit pour qu’il retourne un prix concret et donc tester notre méthode getTotalPrice() :
@Test public void should_have_a_total_price_equal_to_8_99() throws Exception { Product product1 = mock(Product.class); Product product2 = mock(Product.class); when(product1.getPrice()).thenReturn(new BigDecimal("3.99")); when(product2.getPrice()).thenReturn(new BigDecimal("5.00")); Order order = new Order(newArrayList(product1, product2)); assertThat(order.getTotalPrice()).isEqualTo(new BigDecimal("8.99")); }
On peut aussi vouloir simuler le déclenchement d’une exception :
@Test(expected = IllegalStateException.class) public void should_throws_exception_when_calling_get_price() throws Exception { Product product1 = mock(Product.class); when(product1.getPrice()).thenThrow(new IllegalStateException()); product1.getPrice(); }
Parfois, lorsque le cas de test présente une complexité telle que les méthodes thenReturn() et thenThrow() ne suffisent pas, on peut avoir à définir sa propre méthode de réponse en utilisant thenAnswer(). Un des cas classiques est lorsque l’on a besoin de changer le comportement d’une méthode en fonction de ces arguments, et que ces arguments ne sont pas prévisibles lors de l’écriture du test.
Utiliser des Mocks partiels (Spy)
Maintenant, nous allons faire un autre test unitaire sur la méthode formatTotalPrice(). Cette méthode, vous l’aurez remarqué, fait appel à getTotalPrice() testé précédemment.
Voilà une première version :
@Test public void should_format_total_price_to_10_00_euros_string() throws Exception { Product product = mock(Product.class); when(product.getPrice()).thenReturn(BigDecimal.TEN); Order order = new Order(newArrayList(product)); assertThat(order.formatTotalPrice(Locale.FRANCE)).isEqualTo("10,00 €"); }
Pour rappel, lorsque l’on fait un test unitaire, l’unité de test peut-être une méthode, une classe, voir un package. Dans notre cas, si on considère que l’unité testée est la méthode formatTotalPrice() alors l’exemple précédent n’est pas complètement juste. Pourquoi? Tout simplement, parce que le système testé n’est pas en isolation complète puisqu’il dépend de la méthode getTotalPrice() qui en aucun cas n’a été simulée. C’est là qu’intervient la notion de Mock partiel, ou communément appelé Spy dans Mockito. Un Mock partiel peut être vu comme une copie d’une instance de classe.
Et voici le même test, mais unitaire, cette fois-ci :
@Test public void should_format_total_price_to_10_00_euros_string() throws Exception { Order order = spy(new Order(mock(List.class)); doReturn(BigDecimal.TEN).when(order).getTotalPrice(); assertThat(order.formatTotalPrice(Locale.FRANCE)).isEqualTo("10,00 €"); }
Lorsque l’on utilise un Spy, il est conseillé d’utiliser la forme doReturn() | doThrow() | doAnswer() , selon la documentation de Mockito.
Vérifier l’interaction avec le Mock
Faire de la simulation c’est bien, mais vérifier le comportement et l’interaction peut s’avérer dans certains cas encore mieux.
Un exemple, nous voulons nous assurer que dans notre test précédent la méthode formatTotalPrice(), fait un appel à la méthode getTotalPrice() une et une seule fois seulement :
@Test public void should_format_total_price_to_10_00_euros_string() throws Exception { Order order = spy(new Order(mock(List.class)); doReturn(BigDecimal.TEN).when(order).getTotalPrice(); assertThat(order.formatTotalPrice(Locale.FRANCE)).isEqualTo("10,00 €"); verify(order, times(1)).getTotalPrice(); }
La méthode verify() de Mockito, permet de vérifier qu’un comportement s’est produit :
- Exactement n fois : times(n)
- Au moins n fois : atLeastOnce(n)
- Jamais : never()
D’expérience, la vérification du comportement d’un Mock est souvent utilisée lorsque l’on veut s’assurer qu’une méthode n’est jamais appelée lorsque certaines conditions ne sont pas réunies, ou bien comme dans l’exemple précédent, pour s’assurer que l’on ne fait pas appel plusieurs fois à une méthode coûteuse en temps, ou en CPU. Typiquement, un appel à un web service, un chargement en base, ou encore une entrée/sortie système. Cela peut aussi servir, tout simplement, à vérifier qu’un traitement a bien été exécuté.
Et le BDD, c’est possible ?
Pour les amateurs de BDD (Behavior Driven Development), depuis la version 1.8, Mockito propose la classe BDDMockito.
Ci-dessous les tests précédents réécrit en BDD :
@RunWith(MockitoJUnitRunner.class) public class OrderBDDTest { @Mock Product product1; @Mock Product product2; @Test public void should_have_a_total_price_equal_to_8_99() throws Exception { Order order = new Order(newArrayList(product1, product2)); // given given(product1.getPrice()).willReturn(new BigDecimal("3.99")); given(product2.getPrice()).willReturn(new BigDecimal("5.00")); // when BigDecimal totalPrice = order.getTotalPrice(); // then assertThat(totalPrice).isEqualTo(new BigDecimal("8.99")); } @Test public void should_format_total_price_to_10_00_euros_string() throws Exception { Order order = spy(new Order(null)); // given given(order.getTotalPrice()).willReturn(BigDecimal.TEN); // when String price = order.formatTotalPrice(Locale.FRANCE); // then assertThat(price).isEqualTo("10,00 €"); } }
Conclusion
Tout bon craftsman, se doit d’utiliser une librairie de mock, pour d’une part, les rendre plus rapides et concis à écrire, et d’autre part, favoriser le développement piloté par les tests (TDD).
Mockito est une des librairies de référence, de par sa puissance, et de son API simple d’usage, cependant, elle a aussi ses propres limites puisque les méthodes/attributs static et final ne sont pas encore bien gérés, voire pas du tout. Si dans votre projet, vous avez du bon vieux legacy code avec du static à la pelle, vous pourrez alors jeter un oeil sur la librairie PowerMock.
Si vous n’êtes pas encore convaincu, alors voici un lien vers quelques citations pertinentes du site de Mockito.