Avec l'avancement de la technologie et de l'industrie passant du modèle en cascade à Agile et maintenant au DevOps, les modifications et les améliorations d'une application sont déployées en production à la minute où elles sont effectuées. Le code étant déployé en production aussi rapidement, nous devons être sûrs que nos modifications fonctionnent et qu’elles n’interrompent aucune fonctionnalité préexistante.
Pour construire cette confiance, nous devons avoir un cadre pour les tests de régression automatique. Pour effectuer des tests de régression, de nombreux tests doivent être effectués d'un point de vue au niveau de l'API, mais nous aborderons ici deux principaux types de tests:
Il existe de nombreux frameworks disponibles pour chaque langage de programmation. Nous nous concentrerons sur l'écriture de tests unitaires et d'intégration pour une application Web écrite en Java Framework Spring.
La plupart du temps, nous écrivons des méthodes dans une classe, et celles-ci interagissent à leur tour avec les méthodes d'une autre classe. Dans le monde d’aujourd’hui, en particulier applications de l'entreprise - la complexité des applications est telle qu'une seule méthode peut appeler plus d'une méthode de plusieurs classes. Ainsi, lors de l'écriture du test unitaire pour une telle méthode, nous avons besoin d'un moyen de renvoyer des données simulées à partir de ces appels. En effet, l'intention de ce test unitaire est de tester une seule méthode et non tous les appels effectués par cette méthode particulière.
Passons aux tests unitaires Java dans Spring à l'aide du framework JUnit. Nous allons commencer par quelque chose dont vous avez peut-être entendu parler: la moquerie.
Supposons que vous ayez une classe, CalculateArea
, qui a une fonction calculateArea(Type type, Double... args)
qui calcule l'aire d'une forme du type donné (cercle, carré ou rectangle).
Le code ressemble à ceci dans une application normale qui n'utilise pas d'injection de dépendances:
public class CalculateArea { SquareService squareService; RectangleService rectangleService; CircleService circleService; CalculateArea(SquareService squareService, RectangleService rectangeService, CircleService circleService) { this.squareService = squareService; this.rectangleService = rectangeService; this.circleService = circleService; } public Double calculateArea(Type type, Double... r ) { switch (type) { case RECTANGLE: if(r.length >=2) return rectangleService.area(r[0],r[1]); else throw new RuntimeException('Missing required params'); case SQUARE: if(r.length >=1) return squareService.area(r[0]); else throw new RuntimeException('Missing required param'); case CIRCLE: if(r.length >=1) return circleService.area(r[0]); else throw new RuntimeException('Missing required param'); default: throw new RuntimeException('Operation not supported'); } } }
public class SquareService { public Double area(double r) { return r * r; } }
public class RectangleService { public Double area(Double r, Double h) { return r * h; } }
public class CircleService { public Double area(Double r) { return Math.PI * r * r; } }
public enum Type { RECTANGLE,SQUARE,CIRCLE; }
Maintenant, si nous voulons tester unitaire la fonction calculateArea()
de la classe CalculateArea
, alors notre motif devrait être de vérifier si le switch
les cas et les conditions d'exception fonctionnent. Nous ne devons pas tester si les services de forme renvoient les valeurs correctes, car comme mentionné précédemment, le motif du test unitaire d'une fonction est de tester la logique de la fonction, et non la logique des appels effectués par la fonction.
Nous allons donc nous moquer des valeurs renvoyées par des fonctions de service individuelles (par exemple rectangleService.area()
et tester la fonction appelante (par exemple CalculateArea.calculateArea()
) en fonction de ces valeurs simulées.
Un cas de test simple pour le service rectangle - testant que calculateArea()
appelle en effet rectangleService.area()
avec les paramètres corrects - ressemblerait à ceci:
import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.mockito.Mockito; public class CalculateAreaTest { RectangleService rectangleService; SquareService squareService; CircleService circleService; CalculateArea calculateArea; @Before public void init() { rectangleService = Mockito.mock(RectangleService.class); squareService = Mockito.mock(SquareService.class); circleService = Mockito.mock(CircleService.class); calculateArea = new CalculateArea(squareService,rectangleService,circleService); } @Test public void calculateRectangleAreaTest() { Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); Double calculatedArea = this.calculateArea.calculateArea(Type.RECTANGLE, 5.0d, 4.0d); Assert.assertEquals(new Double(20d),calculatedArea); } }
Deux lignes principales à noter ici sont:
rectangleService = Mockito.mock(RectangleService.class);
—Cela crée un faux, qui n'est pas un objet réel, mais un objet simulé.Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d);
—Cela dit que, lorsqu'on se moque, et le rectangleService
de l’objet area
est appelée avec les paramètres spécifiés, puis retourne 20d
.Maintenant, que se passe-t-il lorsque le code ci-dessus fait partie d'une application Spring?
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component public class CalculateArea { SquareService squareService; RectangleService rectangleService; CircleService circleService; public CalculateArea(@Autowired SquareService squareService, @Autowired RectangleService rectangeService, @Autowired CircleService circleService) { this.squareService = squareService; this.rectangleService = rectangeService; this.circleService = circleService; } public Double calculateArea(Type type, Double... r ) { // (same implementation as before) } }
Ici, nous avons deux annotations que le framework Spring sous-jacent doit détecter au moment de l'initialisation du contexte:
@Component
: Crée un bean de type CalculateArea
@Autowired
: Recherche les beans rectangleService
, squareService
et circleService
et les injecte dans le haricot calculatedArea
De même, nous créons des beans pour d'autres classes:
import org.springframework.stereotype.Service; @Service public class SquareService { public Double area(double r) { return r*r; } }
import org.springframework.stereotype.Service; @Service public class CircleService { public Double area(Double r) { return Math.PI * r * r; } }
import org.springframework.stereotype.Service; @Service public class RectangleService { public Double area(Double r, Double h) { return r*h; } }
Maintenant, si nous exécutons les tests, les résultats sont les mêmes. Nous avons utilisé l'injection de constructeur ici, et heureusement, ne changez pas notre cas de test.
Mais il existe une autre façon d'injecter les beans des services carrés, cercles et rectangulaires: l'injection de champ. Si nous utilisons cela, notre scénario de test nécessitera quelques modifications mineures.
Nous n’entrerons pas dans la discussion sur le meilleur mécanisme d’injection, car cela n’entre pas dans le cadre de l’article. Mais nous pouvons dire ceci: quel que soit le type de mécanisme que vous utilisez pour injecter des beans, il existe toujours un moyen d'écrire des tests JUnit pour cela.
Dans le cas de l'injection de champ, le code ressemble à ceci:
@Component public class CalculateArea { @Autowired SquareService squareService; @Autowired RectangleService rectangleService; @Autowired CircleService circleService; public Double calculateArea(Type type, Double... r ) { // (same implementation as before) } }
Remarque: Puisque nous utilisons l'injection de champ, il n'y a pas besoin d'un constructeur paramétré, donc l'objet est créé en utilisant celui par défaut et les valeurs sont définies à l'aide du mécanisme d'injection de champ.
Le code de nos classes de service reste le même que ci-dessus, mais le code de la classe de test est le suivant:
public class CalculateAreaTest { @Mock RectangleService rectangleService; @Mock SquareService squareService; @Mock CircleService circleService; @InjectMocks CalculateArea calculateArea; @Before public void init() { MockitoAnnotations.initMocks(this); } @Test public void calculateRectangleAreaTest() { Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); Double calculatedArea = this.calculateArea.calculateArea(Type.RECTANGLE, 5.0d, 4.0d); Assert.assertEquals(new Double(20d),calculatedArea); } }
Quelques choses se passent différemment ici: pas les bases, mais la façon dont nous y parvenons.
Premièrement, la façon dont nous nous moquons de nos objets: nous utilisons @Mock
annotations avec initMocks()
pour créer des moqueries. Deuxièmement, nous injectons des simulations dans l'objet réel en utilisant @InjectMocks
avec initMocks()
.
Ceci est juste fait pour réduire le nombre de lignes de code.
Dans l'exemple ci-dessus, le programme d'exécution de base utilisé pour exécuter tous les tests est BlockJUnit4ClassRunner
qui détecte toutes les annotations et exécute tous les tests en conséquence.
Si nous voulons plus de fonctionnalités, nous pouvons écrire un coureur personnalisé. Par exemple, dans la classe de test ci-dessus, si nous voulons sauter la ligne MockitoAnnotations.initMocks(this);
alors nous pourrions utiliser un autre runner qui est construit au-dessus de BlockJUnit4ClassRunner
, par exemple MockitoJUnitRunner
.
En utilisant MockitoJUnitRunner
, nous n'avons même pas besoin d'initialiser les simulacres et de les injecter. Cela sera fait par MockitoJUnitRunner
lui-même simplement en lisant les annotations.
(Il y a aussi SpringJUnit4ClassRunner
, qui initialise le ApplicationContext
nécessaire pour les tests d'intégration Spring - tout comme un ApplicationContext
est créé lorsqu'une application Spring démarre. Nous reviendrons plus tard.)
Lorsque nous voulons qu'un objet de la classe de test simule une ou plusieurs méthodes, mais appelle également une ou plusieurs méthodes réelles, nous avons besoin d'une simulation partielle. Ceci est réalisé via @Spy
dans JUnit.
Contrairement à l'utilisation de @Mock
, avec @Spy
, un objet réel est créé, mais les méthodes de cet objet peuvent être moquées ou peuvent être réellement appelées - tout ce dont nous avons besoin.
Par exemple, si le area
méthode dans la classe RectangleService
appelle une méthode supplémentaire log()
et nous voulons réellement imprimer ce journal, puis le code change en quelque chose comme ci-dessous:
@Service public class RectangleService { public Double area(Double r, Double h) { log(); return r*h; } public void log() { System.out.println('skip this'); } }
Si nous changeons le @Mock
annotation de rectangleService
à @Spy
, et apportez également des modifications au code comme indiqué ci-dessous, puis dans les résultats, nous verrions en fait les journaux être imprimés, mais la méthode area()
sera moqué. Autrement dit, la fonction d'origine est exécutée uniquement pour ses effets secondaires; ses valeurs de retour sont remplacées par des valeurs simulées.
@RunWith(MockitoJUnitRunner.class) public class CalculateAreaTest { @Spy RectangleService rectangleService; @Mock SquareService squareService; @Mock CircleService circleService; @InjectMocks CalculateArea calculateArea; @Test public void calculateRectangleAreaTest() { Mockito.doCallRealMethod().when(rectangleService).log(); Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); Double calculatedArea = this.calculateArea.calculateArea(Type.RECTANGLE, 5.0d, 4.0d); Assert.assertEquals(new Double(20d),calculatedArea); } }
Controller
ou RequestHandler
?D'après ce que nous avons appris ci-dessus, le code de test d'un contrôleur pour notre exemple serait quelque chose comme ci-dessous:
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; @Controller public class AreaController { @Autowired CalculateArea calculateArea; @RequestMapping(value = 'api/area', method = RequestMethod.GET) @ResponseBody public ResponseEntity calculateArea( @RequestParam('type') String type, @RequestParam('param1') String param1, @RequestParam(value = 'param2', required = false) String param2 ) { try { Double area = calculateArea.calculateArea( Type.valueOf(type), Double.parseDouble(param1), Double.parseDouble(param2) ); return new ResponseEntity(area, HttpStatus.OK); } catch (Exception e) { return new ResponseEntity(e.getCause(), HttpStatus.INTERNAL_SERVER_ERROR); } } }
import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.MockitoJUnitRunner; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @RunWith(MockitoJUnitRunner.class) public class AreaControllerTest { @Mock CalculateArea calculateArea; @InjectMocks AreaController areaController; @Test public void calculateAreaTest() { Mockito .when(calculateArea.calculateArea(Type.RECTANGLE,5.0d, 4.0d)) .thenReturn(20d); ResponseEntity responseEntity = areaController.calculateArea('RECTANGLE', '5', '4'); Assert.assertEquals(HttpStatus.OK,responseEntity.getStatusCode()); Assert.assertEquals(20d,responseEntity.getBody()); } }
En regardant le code de test du contrôleur ci-dessus, cela fonctionne bien, mais il a un problème de base: il ne teste que l'appel de méthode, pas l'appel d'API réel. Tous ces cas de test où les paramètres d'API et l'état des appels d'API doivent être testés pour différentes entrées sont manquants.
Ce code est meilleur:
import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; @RunWith(SpringJUnit4ClassRunner.class) public class AreaControllerTest { @Mock CalculateArea calculateArea; @InjectMocks AreaController areaController; MockMvc mockMvc; @Before public void init() { mockMvc = standaloneSetup(areaController).build(); } @Test public void calculateAreaTest() throws Exception { Mockito .when(calculateArea.calculateArea(Type.RECTANGLE,5.0d, 4.0d)) .thenReturn(20d); mockMvc.perform( MockMvcRequestBuilders.get('/api/area?type=RECTANGLE¶m1=5¶m2=4') ) .andExpect(status().isOk()) .andExpect(content().string('20.0')); } }
Ici, nous pouvons voir comment MockMvc
prend le travail d'effectuer des appels API réels. Il a également quelques matchers spéciaux comme status()
et content()
qui facilitent la validation du contenu.
Maintenant que nous savons que les unités individuelles du code fonctionnent, assurons-nous qu'elles interagissent également les unes avec les autres comme prévu.
Tout d'abord, nous devons instancier tous les beans, la même chose qui se produit au moment de l'initialisation du contexte Spring lors du démarrage de l'application.
Pour cela, nous définissons tous les beans d'une classe, disons TestConfig.java
:
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class TestConfig { @Bean public AreaController areaController() { return new AreaController(); } @Bean public CalculateArea calculateArea() { return new CalculateArea(); } @Bean public RectangleService rectangleService() { return new RectangleService(); } @Bean public SquareService squareService() { return new SquareService(); } @Bean public CircleService circleService() { return new CircleService(); } }
Voyons maintenant comment nous utilisons cette classe et écrivons un test d'intégration:
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; @RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration(classes = {TestConfig.class}) public class AreaControllerIntegrationTest { @Autowired AreaController areaController; MockMvc mockMvc; @Before public void init() { mockMvc = standaloneSetup(areaController).build(); } @Test public void calculateAreaTest() throws Exception { mockMvc.perform( MockMvcRequestBuilders.get('/api/area?type=RECTANGLE¶m1=5¶m2=4') ) .andExpect(status().isOk()) .andExpect(content().string('20.0')); } }
Quelques choses changent ici:
@ContextConfiguration(classes = {TestConfig.class})
- cela indique au cas de test où résident toutes les définitions de bean.@InjectMocks
nous utilisons: @Autowired AreaController areaController;
Tout le reste reste le même. Si nous déboguons le test, nous verrions que le code s'exécute réellement jusqu'à la dernière ligne du area()
méthode dans RectangleService
où return r*h
est calculé. En d'autres termes, la logique métier réelle s'exécute.
Cela ne signifie pas qu'il n'y a pas de simulation d'appels de méthode ou d'appels de base de données disponibles dans les tests d'intégration. Dans l'exemple ci-dessus, aucun service ou base de données tiers n'était utilisé, nous n'avons donc pas besoin d'utiliser des simulations. Dans la vraie vie, de telles applications sont rares et nous allons souvent frapper une base de données ou une API tierce, ou les deux. Dans ce cas, lorsque nous créons le bean dans le TestConfig
classe, nous ne créons pas l'objet réel, mais un objet simulé, et l'utilisons là où c'est nécessaire.
Souvent, ce qui empêche les développeurs back-end d'écrire des tests unitaires ou d'intégration, ce sont les données de test que nous devons préparer pour chaque test.
Normalement, si les données sont suffisamment petites, comportant une ou deux variables, il est alors facile de créer simplement un objet d’une classe de données de test et d’attribuer des valeurs.
Par exemple, si nous nous attendons à ce qu'un objet simulé renvoie un autre objet, lorsqu'une fonction est appelée sur l'objet simulé, nous ferions quelque chose comme ceci:
Class1 object = new Class1(); object.setVariable1(1); object.setVariable2(2);
Et puis pour utiliser cet objet, nous ferions quelque chose comme ceci:
Mockito.when(service.method(arguments...)).thenReturn(object);
C'est bien dans les exemples JUnit ci-dessus, mais lorsque les variables membres ci-dessus Class1
la classe continue d'augmenter, puis définir des champs individuels devient assez pénible. Parfois, il peut même arriver qu'une classe ait un autre membre de classe non primitif défini. Ensuite, la création d'un objet de cette classe et la définition de champs obligatoires individuels augmente encore l'effort de développement juste pour accomplir un passe-partout.
La solution consiste à générer un schéma JSON de la classe ci-dessus et à ajouter une fois les données correspondantes dans le fichier JSON. Maintenant dans la classe de test où nous créons le Class1
objet, nous n'avons pas besoin de créer l'objet manuellement. Au lieu de cela, nous lisons le fichier JSON et, en utilisant ObjectMapper
, nous le mappons dans le Class1
requis | classe:
ObjectMapper objectMapper = new ObjectMapper(); Class1 object = objectMapper.readValue( new String(Files.readAllBytes( Paths.get('src/test/resources/'+fileName)) ), Class1.class );
Il s'agit d'un effort ponctuel pour créer un fichier JSON et y ajouter des valeurs. Tout nouveau test après cela peut utiliser une copie de ce fichier JSON avec des champs modifiés en fonction des besoins du nouveau test.
Il est clair qu'il existe de nombreuses façons d'écrire des tests unitaires Java en fonction de la manière dont nous choisissons d'injecter des beans. Malheureusement, la plupart des articles sur le sujet ont tendance à supposer qu’il n’existe qu’un seul moyen, il est donc facile de se tromper, en particulier lorsque vous travaillez avec du code écrit sous une hypothèse différente. Espérons que notre approche permet aux développeurs de gagner du temps pour déterminer la bonne façon de se moquer et quel testeur utiliser.
Indépendamment du langage ou du cadre que nous utilisons - peut-être même de toute nouvelle version de Spring ou de JUnit - la base conceptuelle reste la même que celle expliquée dans le didacticiel JUnit ci-dessus. Bon test!
JUnit est le framework le plus connu pour écrire des tests unitaires en Java. Vous écrivez des méthodes de test qui appellent les méthodes réelles à tester. Le cas de test vérifie le comportement du code en affirmant la valeur de retour par rapport à la valeur attendue, compte tenu des paramètres passés.
La majorité des développeurs Java conviennent que JUnit est le meilleur cadre de test unitaire. C'est la norme de facto depuis 1997, et a certainement le plus grand support par rapport aux autres frameworks de tests unitaires Java.
Dans les tests unitaires, les unités individuelles (souvent, les méthodes objet sont considérées comme une «unité») sont testées de manière automatisée.
Les tests JUnit sont utilisés pour tester le comportement des méthodes à l'intérieur des classes que nous avons écrites. Nous testons une méthode pour les résultats attendus et parfois les cas de levée d'exceptions - si la méthode est capable de gérer les exceptions comme nous le souhaitons.
JUnit est un framework qui fournit de nombreuses classes et méthodes différentes pour écrire facilement des tests unitaires.
Oui, JUnit est un projet open-source, maintenu par de nombreux développeurs actifs.
JUnit réduit le standard que les développeurs doivent utiliser lors de l'écriture de tests unitaires.
Kent Beck et Erich Gamma ont initialement créé JUnit. Aujourd'hui, le projet open-source compte plus d'une centaine de contributeurs.