Accès rapide :
Comment bien architecturer son programme pour faciliter les tests.
Introduction très rapide au TDD
Modéliser vos composants logiciels
Couplez vos composants par interfaces
Et les tests dans tout ça ?
Qu'est qu'un « Mock Object » (un bouchon en français) ?
Quelques frameworks de bouchonnage
Le framework JMock
Le framework EasyMock
Le framework Mockito
Un cas concret d'utilisation de « mock objects »
Présentation de notre composant de sécurité et de ses interfaces
Bouchonnage du composant de sécurité dans nos tests unitaires
Travaux pratiques
Le sujet
La correction
Pour aller plus loin
Il existe une méthodologie de développement d'application orientée test : son nom, « Test Driven Development » (TDD pour les intimes). Sans prétendre vous expliquer TDD en détails dans ce chapitre, je veux juste porter mon attention sur un aspect fondamental de la méthodologie : TDD préconise d'écrire les tests unitaires avant de commencer à coder le composant logiciel associé.
A ce stade, deux questions vous brûlent peut-être les lèvres :
Quand vous allez commencer à travailler sur des gros projets Java, il va être nécessaire de commencer à réfléchir avant de passer au code. Ca peut paraître évident, mais je vois encore trop souvent des projets débuter directement par le code. Et dans ce cas, j'émets de gros doute sur la réussite du projet et sur la pertinence de la procédure de tests unitaires.
Encore une fois, je ne vais pas prétendre ici faire un cours sur l'architecture logicielle (ça prendrait trop de temps), mais je voudrais en rappeler quelques fondamentaux.
Une des étapes clé de votre processus de modélisation de l'application à développer consiste à découper l'application en composants logiciels. D'une granularité inférieure, il sera plus aisé de travailler composant par composant. Il est aussi important de noter qu'un composant peu plus facilement être réutilisé sur un autre projet.
Un composant logiciel peut être vu comme un élément constitutif du logiciel à développé qui agrège un ensemble de fonctionnalités cohérentes entre elles. Pour représenter vos composants logiciels, vous pouvez utiliser le formalisme graphique UML (Unified Modeling Language). Des modeleurs UML existent afin de vous simplifier la réalisation de vos diagrammes.
Un composant se représente en UML via l'un des trois pictogrammes suivant : le visuel dépend du modeleur UML utilisé.
Imaginons que nous souhaitions développer un site de vente en ligne. Nous pourrions alors considérer le diagramme UML de composants suivant. Clairement, j'ai simplifié les choses, car un véritable site de vente en ligne pouvant être bien plus complexe.
On trouve donc, dans ce diagramme, quatre composants logiciels. Voici à quoi correspond chacun d'eux :
Pour chaque composant, nous allons devoir écrire un jeu de tests unitaires et chaque composant devra être testé indépendamment des autres composants. Mais comment se passer des autres composants dans la batterie de tests unitaires d'un composant ?
Ce point est certainement le plus important de tous le chapitre. Dans le diagramme UML précédent, les ronds représentent les interfaces de chaque composant. Le trait plein entre un composant est son interface représente la notion d'implémentation d'interface.
Mais, pourquoi est-ce si important d'avoir ces interfaces ?
Une première réponse pourrait être que grâce à cette architecture, on peut aussi imaginer répartir chaque composant sur une équipe de développement spécifique. Du coup, quatre équipes différentes peuvent travailler en parallèle sur le même projet. Comme les interfaces de communication inter-composants sont normalement modélisées en UML, il est possible d'entre produire les codes via les outils de génération de code souvent présents dans les modeleurs UML. Ainsi les interfaces deviennent de données d'entrées du processus de développement, garantissant que tout le monde travaille bien sur « le même contrat ». Les composants pourront ainsi être assemblés par la suite avec la garantie qu'ils vont pouvoir communiquer.
Une seconde réponse réside, vous vous en doutez, dans notre besoin de tester chaque composant unitairement.
Eh bien, c'est à partir de là que tout devient évident : si chaque composant est bien couplé aux autres par interface, il est donc possible d'écrire des scénarios de tests unitaires composant par composant. Dans le cadre d'un test, on appelle SUT (System Under Test) le composant en test. Pour chaque dépendance, au lieu de passer au SUT un pointeur vers un autre composant réel, on va pouvoir lui passer une implémentation « bidon » de l'interface simulant le comportement attendu. Il est donc possible de tester un composant indépendamment des autres grâce à une fausse implémentation (un simulateur) pour chaque dépendance.
Grace à une injection de dépendance, on pourra passer au SUT l'implémentation de simulation en lieu et place de l'implémentation réelle.
CQFD : la boucle est bouclée. Si l'on introduit chaque composant par son interface (son API), il devient possible d'écrire nos tests avant même d'avoir les code des composants. Je vous rappelle qu'on peut produire les codes Java de nos interfaces grâce à notre outil de modélisation UML. Donc, si nous avons fait l'effort de bien modéliser l'application avant de coder, on peut générer les interfaces et donc commencer par écrire les tests qui permettront de valider les codes restant à produire (à condition de typer par interface, bien entendu). L'axiome principal de l'approche TDD est donc tout à fait cohérent et réalisable.
En conclusion, je dirais qu'une bonne stratégie de tests présuppose un travail d'architecture du logiciel en amont. Sans ce travail d'architecture, je ne suis pas sûr que vous aurez les interfaces de communication inter-composants et donc « bye bye » les procédures de tests unitaires sérieuses.
En fait, on a déjà tout dit ! Un « mock object » est un simulacre qui se fait passer pour autre chose (ce que j'ai appelé précédemment un simulateur). Normalement, un « mock object » remplace un composant logiciel et implémente la même interface que ce dernier : c'est ce qui permet de faire croire au SUT qu'il est dans une vraie intégration de composants, mais en vérité, il tourne dans un environnement simulé.
Le terme de « Mock Object » vient que mot anglais « mockery » qui se prononce comme sa traduction française (moquerie). On fait donc une blague à un composant en lui faisant croire qu'il s'exécute dans la vraie vie, mais il en est rien. Certains d'entre vous pourront y voir une comparaison avec « la matrice de Neo » ;-)
Le terme équivalent francophone est un peu différents de celui utilisé par les anglophones : on parle de bouchon. Si l'on considère que le SUT est une bouteille mis en plat, qu'est-ce qui sort de la bouteille ? Nous diront que c'est les « outputs ». Si l'on écrit un test unitaire, il faudra récupérer les sorties (les outputs) afin de vérifier que les données produites sont bien conformes aux attendus. Mais qu'est-ce qui récupère la sortie de la bouteille ? Eh bien, un bouchon ! Peut-être que cette vision était inéluctable dans le pays du vin ;-)
La nouvelle question est maintenant de savoir comment coder nos « Mock Objects » (nos bouchons) ? Deux approches sont possibles.
C'est, bien entendu, la seconde stratégie que je vais vous montrer. Et il existe plusieurs frameworks de ce type. Bien qu'il y en ait beaucoup, j'en ai retenu trois : JMock, EasyMock et Mockito. Nous allons, avec ces trois solutions tenter de générer un mock pour l'interface suivante.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
package fr.koor.mock; public interface Subscriber { public boolean init(); public void receive( String theMessage ); public boolean destroy(); // Plein de méthodes qui ne servent à rien dans notre scénario de test, // mais qui doivent être implémentées dans notre mock. public void demoX(); public void demoY(); public void demoZ(); } |
Quel que soit le framework de bouchonnage utilisé, c'est le même mécanisme qui est utilisé pour produire le mock : la réflexion Java. Grace à la réflexion, le framework va pouvoir trouver les méthodes exposées par l'interface à bouchonner. Il va ensuite créer une classe implémentant cette interface et donc, chacune de ses méthodes.
Le code à tester sera le suivant : il s'agit d'une classe initiant des appels sur des objets basés sur l'interface précédente. Il est important de noter que cette classe est liée aux « subscribers » par interface ;-)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
package fr.koor.mock; import java.util.ArrayList; public class Publisher { private ArrayList<Subscriber> subscribers = new ArrayList<Subscriber>(); public void addSubscriber( Subscriber subsc ) { this.subscribers.add( subsc ); } public void init() { for( Subscriber subscriber : this.subscribers ) { if ( subscriber.init() == false ) { throw new RuntimeException(); } } } public void publish( String message ) { for( Subscriber subscriber : this.subscribers ) { subscriber.receive( message.toUpperCase() ); } } public void destroy() { for( Subscriber subscriber : this.subscribers ) { if ( subscriber.destroy() == false ) { throw new RuntimeException(); } } this.subscribers.clear(); } } |
JMock est l'un des frameworks de bouchonnage historique de Java. Il est disponible à l'adresse http://jmock.org/.
Pour utiliser JMock, il vous faut l'installer, soit en le téléchargeant directement à partir du site Web officiel (http://jmock.org/), soit en utilisant Maven.
pom.xml
(le fichier de configuration Maven de votre projet) pour télécharger JMock.
1 2 3 4 5 6 7 |
<!-- https://mvnrepository.com/artifact/org.jmock/jmock --> <dependency> <groupId>org.jmock</groupId> <artifactId>jmock</artifactId> <version>2.12.0</version> <scope>test</scope> </dependency> |
Pour produire un mock, il faut instancier une Mockery
JMock. Ensuite, il faut y déclarer quelles sont nos attentes (expectations, en anglais).
Une API dédiée à la définition des attentes vous est proposée. Voici un exemple de définition de mock : quelques explications suivront.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 |
package fr.koor.mock; import java.lang.reflect.Field; import java.util.ArrayList; import org.jmock.Expectations; import org.jmock.Mockery; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; public class PublisherTest { private static final String sendedMessage = "Hello World"; private static final String receivedMessage = "HELLO WORLD"; private Mockery mockContext1 = null; private Mockery mockContext2 = null; @BeforeEach public void setUp() { mockContext1 = new Mockery(); mockContext2 = new Mockery(); } @Test public void testScenarioSuccess() throws Exception { Publisher publisher = new Publisher(); // Partie 1 : création de deux mocks. final Subscriber subscriber1 = mockContext1.mock( Subscriber.class ); publisher.addSubscriber( subscriber1 ); final Subscriber subscriber2 = mockContext2.mock( Subscriber.class ); publisher.addSubscriber( subscriber2 ); // Partie 2 : nos attentes sont injectées dans les mocks. mockContext1.checking( new Expectations() {{ // Un appel à la méthode init est requis et elle doit retourner true. oneOf( subscriber1 ).init(); will( returnValue( true ) ); // Un appel à la méthode receive est requis avec la valeur spécifiée // en paramètre ("HELLO WORLD"). oneOf( subscriber1 ).receive( receivedMessage ); // Un appel à la méthode destroy est requis et elle doit retourner true. oneOf( subscriber1 ).destroy(); will( returnValue( true ) ); }}); mockContext2.checking( new Expectations() {{ oneOf( subscriber2 ).init(); will( returnValue( true ) ); oneOf( subscriber2 ).receive( receivedMessage ); oneOf( subscriber2 ).destroy(); will( returnValue( true ) ); }}); // Partie 3 : On lance le test. publisher.init(); publisher.publish( sendedMessage ); publisher.destroy(); // Partie 4 : On vérifie les attentes. mockContext1.assertIsSatisfied(); mockContext2.assertIsSatisfied(); } @Test public void testScenarioFailure() throws Exception { Assertions.assertThrows( RuntimeException.class, () -> { Publisher publisher = new Publisher(); // Partie 1 : création d'un mock. final Subscriber subscriber1 = mockContext1.mock( Subscriber.class ); publisher.addSubscriber( subscriber1 ); // Partie 2 : nos attentes sont injectées dans le mock. mockContext1.checking( new Expectations() {{ oneOf( subscriber1 ).init(); will( returnValue( false ) ); oneOf( subscriber1 ).receive( receivedMessage ); oneOf( subscriber1 ).destroy(); will( returnValue( true ) ); }}); // Partie 3 : on lance le test. publisher.init(); publisher.publish( sendedMessage ); publisher.destroy(); // Partie 4 : on vérifie les attentes. mockContext1.assertIsSatisfied(); }); } } |
Comme vous le constatez, deux méthodes de tests sont proposées dans cet exemple et c'est le framework JUnit 5.0 qui est utilisé.
La première méthode teste un cas d'utilisation en succès alors que la seconde teste un cas d'erreur.
Les moqueries, nécessaires aux deux méthodes de tests sont instanciées dans la méthode setUp
.
L'obtention du mock à proprement parler, s'obtient via la ligne de code suivante : il s'agit d'une méthode générique. Le type de retour de la méthode est donc l'interface initialement passée en paramètre.
1 |
Subscriber subscriber1 = mockContext1.mock( Subscriber.class );
|
Pour la définition des attentes, une classe anonyme basée sur le type Expectations
est utilisée. La deuxième paires d'accolades
correspond à la définition du constructeur de la classe anonyme. C'est la manière recommandée pour définir nos attentes avec JMock.
demoX
, demoY
et demoZ
de l'interface,
rien n'est dit sur ces méthodes. Elles seront générées à vide.
EasyMock est un autre framework de bouchonnage qui existe, lui aussi, depuis longtemps (début des années 2000). Il peut être téléchargé à partir de l'adresse suivante : https://easymock.org/.
pom.xml
(le fichier de configuration Maven de votre projet) pour télécharger EasyMock.
1 2 3 4 5 6 7 |
<!-- https://mvnrepository.com/artifact/org.easymock/easymock --> <dependency> <groupId>org.easymock</groupId> <artifactId>easymock</artifactId> <version>4.2</version> <scope>test</scope> </dependency> |
Comme vous allez le constater, EasyMock ressemble beaucoup à JMock sur la manière de déclarer un bouchon. Voici un exemple équivalent au précédent, mais qui utilise EasyMock.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 |
package fr.koor.mock; import org.easymock.EasyMock; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; public class PublisherTest { private static final String sendedMessage = "Hello World"; private static final String receivedMessage = "HELLO WORLD"; @Test public void testScenarioSuccess() throws Exception { Publisher publisher = new Publisher(); // Partie 1 : création de deux mocks. final Subscriber subscriber1 = EasyMock.createMock( Subscriber.class ); publisher.addSubscriber( subscriber1 ); final Subscriber subscriber2 = EasyMock.createMock( Subscriber.class ); publisher.addSubscriber( subscriber2 ); // Partie 2 : nos attentes sont injectées dans les mocks. // On attend un appel à init avec une valeur de retour true. EasyMock.expect( subscriber1.init() ).andReturn( true ); // On attend un appel à la méthode receive (qui renvoie void). subscriber1.receive( receivedMessage ); EasyMock.expectLastCall(); // On attend un appel à destroy avec une valeur de retour true. EasyMock.expect( subscriber1.destroy() ).andReturn( true ); // On a terminer la défintion des attentes du mock subscriber1. EasyMock.replay( subscriber1 ); EasyMock.expect( subscriber2.init() ).andReturn( true ); subscriber2.receive( receivedMessage ); EasyMock.expectLastCall(); EasyMock.expect( subscriber2.destroy() ).andReturn( true ); EasyMock.replay( subscriber2 ); // Partie 3 : On lance le test. publisher.init(); publisher.publish( sendedMessage ); publisher.destroy(); // Partie 4 : On vérifie les attentes. EasyMock.verify( subscriber1 ); EasyMock.verify( subscriber2 ); } @Test( expected = RuntimeException.class ) public void testScenarioFailure() throws Exception { Assertions.assertThrows( RuntimeException.class, () -> { Publisher publisher = new Publisher(); // Partie 1 : création de deux mocks. final Subscriber subscriber1 = EasyMock.createMock( Subscriber.class ); publisher.addSubscriber( subscriber1 ); // Partie 2 : nos attentes sont injectées dans le mock. EasyMock.expect( subscriber1.init() ).andReturn( false ); subscriber1.receive( receivedMessage ); EasyMock.expectLastCall(); EasyMock.expect( subscriber1.destroy() ).andReturn( true ); EasyMock.replay( subscriber1 ); // Partie 3 : On lance le test. publisher.init(); publisher.publish( sendedMessage ); publisher.destroy(); // Partie 4 : On vérifie les attentes. EasyMock.verify( subscriber1 ); }); } } |
Ici, plus de Mockery
: on peut juger que c'est plus simple. L'acquisition du mock est assez similaire au framework précédent.
Ce qui change, c'est la syntaxe utilisée pour définir les attentes (expectations).
Là, il n'y a pas de discussion possible : l'absence de la classe anonyme allège considérablement la syntaxe. Il faut quand même noter qu'on
doit activer une fois le mock pour lui indiquer ce qu'on attend, puis lancer la méthode EasyMock.replay
sur le mock pour le mettre en
« mode enregistrement ». Du coup le mock va collecter la seconde salve d'appels de méthodes et il ne restera plus qu'à vérifier
que tout à bien fonctionné en appelant la méthode EasyMock.verify
.
Le dernier framework de test que je voulais vous présenter est Mockito. Son nom est un mix entre Mock et Mojito (la célèbre boisson), d'où son logo remarquable. Il est accessible à partir de l'adresse https://site.mockito.org/.
pom.xml
(le fichier de configuration Maven de votre projet) pour télécharger Mockito.
1 2 3 4 5 6 7 |
<!-- https://mvnrepository.com/artifact/org.mockito/mockito-core --> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-core</artifactId> <version>3.4.4</version> <scope>test</scope> </dependency> |
A la base, il s'agissait d'une extension du framework EasyMock, mais qui depuis a bien évoluée et notamment avec la généralisation de l'utilisation d'annotations. Voici un exemple de bouchon équivalent aux exemples précédents.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
package fr.koor.mock; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.MockitoAnnotations; public class PublisherTest { private static final String sendedMessage = "Hello World"; private static final String receivedMessage = "HELLO WORLD"; @Mock private Subscriber subscriber1; @Mock private Subscriber subscriber2; @BeforeEach public void setUp() { MockitoAnnotations.openMocks(this); } @Test public void testScenarioSuccess() throws Exception { // Partie 1 : création de deux mocks. Publisher publisher = new Publisher(); publisher.addSubscriber( subscriber1 ); publisher.addSubscriber( subscriber2 ); // Partie 2 : on injecte nos implémentations dans le mock Mockito.when( subscriber1.init() ).thenReturn( true ); Mockito.when( subscriber1.destroy() ).thenReturn( true ); Mockito.when( subscriber2.init() ).thenReturn( true ); Mockito.when( subscriber2.destroy() ).thenReturn( true ); // Partie 3 : On lance le test. publisher.init(); publisher.publish( sendedMessage ); publisher.destroy(); // Partie 4 : On vérifie les attentes. Mockito.verify( subscriber1 ).init(); Mockito.verify( subscriber1 ).receive( receivedMessage ); Mockito.verify( subscriber1 ).destroy(); Mockito.verify( subscriber1, Mockito.times( 1 ) ).init(); Mockito.verify( subscriber2 ).receive( receivedMessage ); Mockito.verify( subscriber1, Mockito.times( 1 ) ).destroy(); } } |
Une grosse différence, par rapport aux exemples précédents, réside dans l'utilisation des annotations. Attention, il ne faut pas oublier de
démarrer le moteur Mockito pour que les injections de mocks soient réalisées (MockitoAnnotations.openMocks(this);
).
import static org.mockito.Mockito.*;
.
Dans ce cas, vous n'êtes plus obligé de spécifier les préfixes Mockito.
.
Bien qu'ayant beaucoup travaillé avec le framework JMock et étant fan des annotations, ma préférence va, aujourd'hui, plus à Mockito. C'est lui que je vais utiliser dans l'exemple suivant.
Nous allons de nouveau considérer notre exemple de site de vente en ligne. Nous partons de l'idée que nous souhaitons réaliser un test sur une
partie de code du composant CommandComponent
: or, celui-ci définit une dépendance sur le composant SecurityComponent
.
Il est donc nécessaire de bien connaître l'interface associée au composant SecurityComponent
, car nous allons devoir la
« bouchonner ».
SecurityComponent
:
https://github.com/koor-fr/SecurityModule.
Vous pourrez soit y télécharger un ZIP ou bien cloner le projet GIT (pour ceux qui savent déjà faire).
Nous parlerons aussi de GIT dans un futur chapitre.
Le diagramme de classes UML qui suit montre le contenu des cinq interfaces Java associées à notre composant de sécurité. Quelques explications suivront.
L'interface fr.koor.security.SecurityManager : le point d'entrée sur notre API de sécurité. Elle permet d'ouvrir et de fermer une session de sécurité. Elle permet aussi de récupérer les « managers » permettant la manipulation des utilisateurs et des rôles. Voici le code de cette interface.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
package fr.koor.security; /** * <p> * This interface defines methods for access to a security service. A security * service must provide two mechanisms: authentication and permissions management. * Authentication consist to identify a user and enable him (or not) connecting * to the considered system. The management of permissions allows, once the user * authenticated, him to have an access (or not) to resources. * </p> * * <p> * In the current version of the Ellipse framework, only authentication is supported. * But a future version of the framework will add the concepts of permissions. The * Ellipse framework provides the JdbcSecurityManager class : this is, of course, * an implementation of this interface that use a relational database to store * the security informations. * </p> * * @see fr.koor.security.providers.JdbcSecurityManager * @see fr.koor.security.RoleManager * @see fr.koor.security.UserManager */ public interface SecurityManager extends AutoCloseable { /** * Open a session to the considered security service. * * @throws SecurityManagerException Thrown when connection to the security * service cannot be established. */ public void openSession() throws SecurityManagerException; /** * Close the session with the considered security service. * * @throws SecurityManagerException Thrown when connection to the security * service cannot be closed. */ public void close() throws SecurityManagerException; /** * Returns the role manager associated to this security manager. * A role manager provided methods to manage roles. * * @return The role manager associated to this security manager. */ public RoleManager getRoleManager(); /** * Returns the user manager associated to this security manager. * A user manager provided methods to manage users. * * @return The user manager associated to this security manager. */ public UserManager getUserManager(); } |
L'interface fr.koor.security.UserManager : cette interface concentre toutes les opérations de manipulation d'utilisateurs.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 |
package fr.koor.security; import java.util.List; /** * This interface defines the methods used to manage User instances. * To can get a UserManager instance by asking it at your SecurityManager. * * @see fr.koor.security.SecurityManager * @see fr.koor.security.providers.JdbcSecurityManager * @see fr.koor.security.User */ public interface UserManager { /** * Check if the pair login/password represents an autorized user for the considered * application. If the identity is rejected, an exception will thrown. If the * identity is accepted, the connection number of the considered user is increased. * * @param userLogin The login for the considered user. * @param userPassword The password for the considered user. * @return The considered user instance. * * @throws AccountDisabledException Thrown when the provided account informations * are invalid. * @throws BadCredentialsException Thrown if the identity is rejected. */ public User checkCredentials( String userLogin, String userPassword ) throws AccountDisabledException, BadCredentialsException; /** * Retreive the user instance that have the desired identifier. * * @param userId The user identifier (the primary key into the security database). * @return The selected user instance. * @exception SecurityManagerException * Thrown if the searched user don't exists. * * @see #checkCredentials(String, String) * @see #getUserByLogin(String) */ public User getUserById( int userId ) throws SecurityManagerException; /** * Retreive the user instance by its login. * * @param login The user login. * @return The selected user instance. * @exception SecurityManagerException * Thrown if the searched user don't exists. * * @see #checkCredentials(String, String) * @see #getUserById(int) */ public User getUserByLogin( String login ) throws SecurityManagerException; /** * Retreive all user instances associated to the specified role. * * @param role The role that contains expected users. * @return A list of users member of this role. * @exception SecurityManagerException * Thrown when the search can't finish. * * @see #checkCredentials(String, String) * @see #getUserById(int) * @see #getUserByLogin(String) */ public List<User> getUsersByRole( Role role ) throws SecurityManagerException; /** * Insert a new user in the security system. The new used has the specified * login and the specified password. * * @param login The login for the considered user. * @param password The password for the considered user. The specified password * is automaticly encoded by this method. * @return The new user instance. * * @exception SecurityManagerException * Thrown if the new user cannot be inserted in the security system. * @exception UserAlreadyRegisteredException * Thrown if the specified login is already registered in the security system. */ public User insertUser( String login, String password ) throws UserAlreadyRegisteredException, SecurityManagerException ; /** * Update informations, in the security system, for the specified user. * * @param user The user instance to update. * * @throws SecurityManagerException * Thrown if this manager cannot update the user informations. */ public void updateUser( User user ) throws SecurityManagerException ; /** * Delete the specified user from the security system. * * @param user The user to delete. * * @throws SecurityManagerException * Thrown if this manager cannot remove the user. */ public void deleteUser( User user ) throws SecurityManagerException ; /** * Defines the algorithm used for encode password. User password is stored in * encoded format. * * @param clearPassword A password (in clear). * @return The encoded password. * * @throws SecurityManagerException * Thrown if password encription failed. */ public String encryptPassword( String clearPassword ) throws SecurityManagerException; } |
L'interface fr.koor.security.User : cette interface concentre toutes les opérations de manipulation de rôles.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 |
package fr.koor.security; import java.io.Serializable; import java.util.Date; import java.util.Set; /** * This class represents the concept of user for a considered computer system. * A user has a number of attributes and a set of roles assigned to it. * <br><br> * Note: you cannot directly create a User. Instead of, use an UserManager instance. * * @see fr.koor.security.Role * @see fr.koor.security.RoleManager * @see fr.koor.security.SecurityManager * @see fr.koor.security.UserManager */ public interface User extends Serializable { /** * Return the identifier of this user. Normaly, this identified is used as the primary key * in the security storage engine (certainly a relational database). It must be unique * within the database. Therefore, you cannot change the user identifier's. * * @return The user identifier. */ public int getIdentifier(); /** * Returns the user login. * @return The user login. */ public String getLogin(); /** * Check if the encrypted string (for the specified password) is the same that the encrypted * password store in the used security system (certainly a relational database). * * @param password The clear password to compare * @return true if encrypted version of the password is the same that the user encrypted * password, false otherwise. * * @throws SecurityManagerException Thrown if passwords cannot be compared. * * @see fr.koor.security.User#setPassword(String) */ public boolean isSamePassword( String password ) throws SecurityManagerException; /** * Set the new password for this user. Note that the password is stored in encrypted format. * * @param newPassword The new password for this user. * * @throws SecurityManagerException Thrown if security system cannot change the password. * * @see fr.koor.security.User#isSamePassword(String) */ public void setPassword( String newPassword ) throws SecurityManagerException; /** * Returns the connection number of this user. The connection number is increased as each * connection time. * * @return The actual connection number. */ public int getConnectionNumber(); /** * Returns the date and the time of the last connection for this user. * * @return The date of the last connection. */ public Date getLastConnection(); /** * Returns if the user account is disabled. * * @return true is the user account is disabled, false otherwise. */ public boolean isDisabled(); /** * Returns the consecutive errors number * * @return The consecutive errors number. */ public int getConsecutiveErrors(); /** * Returns the first name of this user. * * @return The first name. */ public String getFirstName(); /** * Returns the last name of this user. * * @return The last name. */ public String getLastName(); /** * Returns the full name (first name and last name) of this user. * * @return The full name. */ public String getFullName(); /** * Returns the email of this user. * * @return The email. */ public String getEmail(); /** * Checks is this user is associated to the specified role. * * @param role The expected role. * @return true is this user has the specified role, false otherwize. */ public boolean isMemberOfRole( Role role ); /** * Returns a set of all roles associated to this user. * * @return The set of roles. */ public Set<Role> getRoles(); /** * Adds another role to this user. * * @param role The new role to affect for this user. */ public void addRole( Role role ); /** * Removes a role to this user. * * @param role The role to remove for this user. */ public void removeRole( Role role ); } |
L'interface fr.koor.security.RoleManager : cette interface représente un utilisateur du système.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
package fr.koor.security; /** * This interface defines the methods used to manage Role instances. * To can get a RoleManager instance by asking it at your SecurityManager. * * @see fr.koor.security.SecurityManager * @see fr.koor.security.providers.JdbcSecurityManager * @see fr.koor.security.Role */ public interface RoleManager { /** * Select the role with the identifier specified in parameter. * * @param roleIdentifier The identifier of the role to returns. * @return The selected role. * * @exception SecurityManagerException * Thrown if the searched role don't exists. */ public Role selectRoleById( int roleIdentifier ) throws SecurityManagerException ; /** * Select the role with the name specified in parameter. * * @param roleName The name of the role to returns. * @return The selected role. * * @exception SecurityManagerException * Thrown if the searched role don't exists. */ public Role selectRoleByName( String roleName ) throws SecurityManagerException ; /** * Insert a new role into the used security system. * * @param roleName The name of the new role. * @return The new role. * * @exception SecurityManagerException * Thrown if the role cannot be inserted into the security system. * @exception RoleAlreadyRegisteredException * Thrown if the specified role name already exists in the security system. */ public Role insertRole( String roleName ) throws SecurityManagerException, RoleAlreadyRegisteredException; /** * Update the informations for this role (actually, only the role name). * * @param role The role to update. * * @exception SecurityManagerException * Thrown if the role cannot be updated into the security system. */ public void updateRole( Role role ) throws SecurityManagerException ; /** * Delete, on the security system, the specified role. * * @param role The role to delete. * @exception SecurityManagerException * Thrown if the specified role cannot be deleted from the security system. */ public void deleteRole( Role role ) throws SecurityManagerException ; } |
L'interface fr.koor.security.Role : cette interface représente un rôle du système.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
package fr.koor.security; import java.io.Serializable; /** * This class represents the concept of role. A role is associated with one * or more users (eg the user John Doe who has an administrator role). */ public interface Role extends Serializable { /** * Returns the unique identifier for this role. * * @return The unique identifier. */ public int getIdentifier(); /** * Returns the name of this role. * * @return Role name. * * @see fr.koor.security.Role#setRoleName */ public String getRoleName(); /** * Changes the name of this role. * * @param newRoleName The new name of the role. * * @see fr.koor.security.Role#getRoleName */ public void setRoleName( String newRoleName ); } |
Quelques autres classes d'exceptions (pour la gestion des erreurs) font aussi partie de l'API de notre composant de sécurité. Pour plus de détails sur ces classes d'exceptions, je vous renvoie vers le projet GIT.
Maintenant que ces interfaces ont été présentées, nous pouvons passer au bouchonnage du moteur de sécurité.
Nous allons maintenant considérer le code suivant : c'est ce que nous allons devoir tester. La question étant de savoir comment nous allons bouchonner nos interfaces relatives au moteur de sécurité pour tester ce code ? La réponse est simple : nous allons utiliser un framework de bouchonnage et plus précisément Mockito.
Ce premier extrait de code correspond au code à tester. Vous noterez qu'il est censé utiliser un SecurityManager
qui sera injecté
dans le composant grâce à la méthode setSecurityManager
. Ensuite la méthode doSomething
porte le code à tester.
A ce niveau il vous faudra faire un peu preuve d'imagination, car je n'y ai pas mis grand-chose.
Le point le plus important à constater, c'est que cette méthode utilise le SecurityManager
porté par notre classe : on doit
donc avoir une instanciation d'objet compatible avec l'interface attendue. C'est donc bien là que nos mocks vont devoir faire « le job »
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
package fr.koor.command; import fr.koor.security.Role; import fr.koor.security.SecurityManager; import fr.koor.security.SecurityManagerException; import fr.koor.security.User; public class ClassToValidate { private SecurityManager securityManager; // Un setter pour injecter un securityManager. public void setSecurityManager( SecurityManager securityManager ) { if ( securityManager == null ) { throw new NullPointerException( "securityManager cannot be null" ); } this.securityManager = securityManager; } // La méthode à tester. public boolean doSomething( User user ) throws SecurityManagerException { Role role = securityManager.getRoleManager().selectRoleByName( "admin" ); if ( ! user.isMemberOfRole( role ) ) { throw new SecurityManagerException( "Only admin is autorized" ); } // Imaginez ici du code plus complexe. return true; } } |
Et voici maintenant un exemple de test unitaire déclenchant la méthode doSomething
ainsi que la mise en oeuvre des mocks
permettant de bouchonner notre composant de sécurité. J'utilise le moteur d'annotations de Mockito pour réaliser, à ma place, les instanciations
des mocks. Il me reste qu'à injecter les mocks les uns dans les autres pour simuler un moteur de sécurité (voir la méthode setUp
).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
package fr.koor.mock; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.MockitoAnnotations; import fr.koor.command.ClassToValidate; import fr.koor.security.Role; import fr.koor.security.RoleManager; import fr.koor.security.SecurityManager; import fr.koor.security.SecurityManagerException; import fr.koor.security.User; import fr.koor.security.UserManager; public class ClassToValidateTest { // On déclare nos cinq mocks pour nos 5 interfaces. @Mock private User user; @Mock private Role role; @Mock private UserManager userManager; @Mock private RoleManager roleManager; @Mock private SecurityManager securityManager; @BeforeEach public void setUp() throws SecurityManagerException { // On instancie et on injecte les mocks. MockitoAnnotations.openMocks(this); // Et on les assemble les uns au autres. Mockito.when( roleManager.selectRoleByName( "admin" ) ).thenReturn( role ); Mockito.when( user.isMemberOfRole( role ) ).thenReturn( true ); Mockito.when( securityManager.getUserManager() ).thenReturn( userManager ); Mockito.when( securityManager.getRoleManager() ).thenReturn( roleManager ); } @Test public void testScenarioSuccess() throws Exception { // On lance notre test. ClassToValidate codeToValidate = new ClassToValidate(); codeToValidate.setSecurityManager( securityManager ); boolean result = codeToValidate.doSomething( user ); // On vérifie que tout a fonctionné correctement. Assertions.assertTrue( result ); Mockito.verify( securityManager, Mockito.times( 1 ) ).getRoleManager(); } } |
Nous allons maintenant considérer un nouveau composant : celui de gestion du stock de notre site de vente en ligne. Il s'agit encore une fois d'une caricature, mais j'ai néanmoins spécifié deux interfaces Java dans l'API (Application Programming Interface) de ce composant.
La première interface permet de représenter un article au sein du stock. En voici sa définition.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
package fr.koor.stock; /** * This interface represent an article in the StockManager component. */ public interface StockArticle { /** * This method returns the primary key of the article. * @return the article's primary key. */ int getIdArticle(); /** * This method returns the description of the article. * @return the article's description. */ String getDescription(); /** * This method returns the brand of the article. * @return the article's brand. */ String getBrand(); /** * This method returns the price of the article. * @return the article's price. */ double getPrice(); } |
La seconde interface définie les méthodes exposées par le composant de gestion du stock à proprement parler. Bien entendu, ces méthodes acceptent, en
paramètres, des instances de StockArticle
. En voici son code.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
package fr.koor.stock; /** * This interface gives access to stock management. */ public interface StockManager { /** * This method checks the availability of an item in a certain quantity. * @param article The desired article. * @param quantity The desired quantity. * @return This method returns true if desired articles are available, false otherwise. */ boolean isAvailable( StockArticle article, int quantity ); /** * This method removes from stock some articles. * @param article The article to remove. * @param quantity The quantity to remove. * @return The remaining article's quantity, after remove. */ int removeFromStock( StockArticle article, int quantity ); /** * This method adds from stock some articles. * @param article The article to add. * @param quantity The quantity to add. * @return The new article's quantity. */ int addToStock( StockArticle article, int quantity ); } |
Considérons maintenant le code suivant qui utilise un composant de type StockManager
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
package fr.koor.command; import java.util.Map; import fr.koor.stock.StockArticle; import fr.koor.stock.StockManager; public class CommandGenerator { private StockManager stockManager; public void setStockManager(StockManager stockManager) { if ( stockManager == null ) { throw new NullPointerException( "stockManager cannot be null" ); } this.stockManager = stockManager; } public double createFakeCommand( Map<StockArticle, Integer> rawData ) { // On vérifie que tous les articles désirés sont présents dans le stock for ( StockArticle article : rawData.keySet() ) { int quantity = rawData.get( article ); if ( ! stockManager.isAvailable( article, quantity ) ) { throw new RuntimeException("This command is not complete"); } } // Si c'est le cas, on retire tous ces articles du stock // et on calcule le prix total de la commande. double price = 0; for ( StockArticle article : rawData.keySet() ) { int quantity = rawData.get( article ); price += quantity * article.getPrice(); stockManager.removeFromStock( article, quantity ); } // On renvoie le prix total de la commande. return price; } } |
Ce TP consiste à tester, avec JUnit 5, le code présenté ci-dessous en bouchonnant, via Mockito, notre API de gestion de stock. Vous devez y coder deux tests : le premier qui simule une commande avec les articles disponibles dans le stock et un second qui simule des articles indisponibles. Bon courage !
Voici donc le code JUnit/Mockito permettant de tester la création de la commande.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 |
package fr.koor.mock; import java.util.HashMap; import java.util.Map; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.MockitoAnnotations; import fr.koor.command.CommandGenerator; import fr.koor.security.SecurityManagerException; import fr.koor.stock.StockArticle; import fr.koor.stock.StockManager; public class CommandGeneratorTest { // On déclare nos cinq mocks pour nos 5 interfaces. @Mock private StockArticle article1; @Mock private StockArticle article2; @Mock private StockManager stockManager; private Map<StockArticle, Integer> rawData; @BeforeEach public void setUp() throws SecurityManagerException { // On instancie et on injecte les mocks. MockitoAnnotations.openMocks(this); // Et on configure les mocks d'articles. Mockito.when( article1.getPrice() ).thenReturn( 10.0 ); Mockito.when( article2.getPrice() ).thenReturn( 100.0 ); // On prépare les données de la commande. rawData = new HashMap<>(); rawData.put( article1, 5 ); rawData.put( article2, 2 ); } @Test public void testGoodCommand() throws Exception { // On configure notre mock pour simuler des articles disponibles dans le stock. Mockito.when( stockManager.isAvailable(article1, 5) ).thenReturn( true ); Mockito.when( stockManager.isAvailable(article2, 2) ).thenReturn( true ); // On lance notre test. CommandGenerator codeToValidate = new CommandGenerator(); codeToValidate.setStockManager( stockManager ); double totalPrice = codeToValidate.createFakeCommand( rawData ); // On vérifie que tout a fonctionné correctement. Assertions.assertEquals( 250, totalPrice ); Mockito.verify( stockManager, Mockito.times( 1 ) ).removeFromStock( article1, 5 ); Mockito.verify( stockManager, Mockito.times( 1 ) ).removeFromStock( article2, 2 ); } @Test public void testBadCommand() throws Exception { Assertions.assertThrows( RuntimeException.class, () -> { // On configure notre mock pour simuler des articles indisponibles dans le stock. Mockito.when( stockManager.isAvailable(article1, 5) ).thenReturn( true ); Mockito.when( stockManager.isAvailable(article2, 2) ).thenReturn( false ); // On lance notre test. CommandGenerator codeToValidate = new CommandGenerator(); codeToValidate.setStockManager( stockManager ); codeToValidate.createFakeCommand( rawData ); // On vérifie que tout a fonctionné correctement. Mockito.verify( stockManager, Mockito.times( 0 ) ).removeFromStock( article1, 5 ); }); } } |
Si vous souhaitez continuer à tester l'utilisation de mocks, vous pouvez aussi refaire les dernières manipulations (bouchonnage du composant de sécurité et celui de gestion du stock) avec JMock et EasyMock. Bon courage :-)
Améliorations / Corrections
Vous avez des améliorations (ou des corrections) à proposer pour ce document : je vous remerçie par avance de m'en faire part, cela m'aide à améliorer le site.
Emplacement :
Description des améliorations :