✏️ Exercices : Testabilité et doublures

1. Exercice 1 − AddressBook

On considère le code Java montré suivant, qui fait partie d’une bibliothèque logicielle permettant de gérer un carnet de contacts similaire à ce qu’on trouve sur de nombreux téléphones:

Interface DataStorage
/**
 * A service able to manage simple data persistence (in a "NoSQL-like" fashion).
 * Data is managed in collections, each collection containing a set of Record objects.
 * Record objects can be stored or loaded from a collection.
 */
interface DataStorage {	

    /**
     * Store a record in a specified collection of the data storage service.
     * 
     * @param collection The name of the collection where to store the record.
     * @param record     The record to store.
     */
	void storeRecord(String collection, Record record);

    /**
     * Load all records from a specified collection of the data storage service.
     * 
     * @param collection The name of the collection.
     * @return All records currently stored in the collection.
     */
	List<Record> loadRecords(String collection);
}
Classe Record
/**
 * A key-value structure to store data.
 * Each record is a set of key-value pairs of strings.
 * Example of record with two key-value pairs : 
 *    [ { "name" : "alpha" }, { "color" : "red" } ]
 */
class Record {

    /**
     * Construct an empty record.
     */
	public Record() {};

    /**
     * Set a value in the record.
     * If the provided key already exists, change the value associated with the key.
     * If the provided key does not exist, create a new key-value pair in the record.
     * 
     * @param key The key of the value to set.
     * @param value The value to set.
     */
	public void setValue(String key, String value) { … }

    /**
     * Retrieve a value associated with a key in the record.
     * If the key does not exist, null is returned.
     * 
     * @param key The key for which the value must be retrieved.
     * @return The value associated with the key if it exists, 'null' otherwise.
     */
	public String getValue(String key)  { … }

    /**
     * Compare two records, considered equal if they contain the exact same set of key/value pairs.
     * 
     * @param other The object to compare to.
     * @return true if other is a Record and contain the same key/value pairs as the caller.
     */
    @Override
    public boolean equals(Object other) { … }
}
Classe MongoDBStorage (code existant mais non détaillé ici)
/**
 * An implementation of the DataStorage service using a MongoDB backend.
 */
class MongoDBStorage implements DataStorage {
	
    /**
     * Constructor for the MongoDBStorage class. 
     * When the object is constructed, a network connection is created with the specified MongoDB server.
     * 
     * @param host The IP address of the MongoDB server to use.
     * @param port The listening port of he MongoDB server to use.
     */
	public MongoDBStorage(String host, int port) { … }
    
    …
}
Classe SimpleAddressBook, que l’on souhaite tester
/**
 * Address book service, to store and manage contacts.
 * Requires a DataStorage object to deal with data persistence.
 */
class SimpleAddressBook {
	
	private DataStorage dataStorage;

	/**
	 * Constructor for the SimpleAddressBook service.
     * 
	 * @param dataStorage the DataStorage object to use for loading
	                      and storing persistent address book data.
	 */
	public SimpleAddressBook(DataStorage dataStorage) {
		this.dataStorage = dataStorage;
	}
	
	/**
	 * Create and add a new contact in the address book.
	 * 
	 * @param firstName   The first name of the contact.
	 * @param lastName    The last name of the contact.
	 * @param phoneNumber The phone number of the contact.
	 * @param address     The postal address of the contact.
	 * @throws ExistingContactException if a contact with the same phone number 
     *                                  already exists in the address book.
	 *
	 */
	public void addContact(String firstName, String lastName, String phoneNumber, String address) 
	                       throws ExistingContactException {
		for (Record contact : this.dataStorage.loadRecords("contacts")) {
			if (contact.getValue("phone_number").equals(phoneNumber)) 
				throw new ExistingContactException();
		}
		Record newContact = new Record();
		newContact.setValue("first_name", firstName);
		newContact.setValue("last_name", lastName);
		newContact.setValue("phone_number", phoneNumber);
		newContact.setValue("address", address);
		this.dataStorage.storeRecord("contacts", newContact);
	}
}

Notre objectif est de tester la méthode addContact() de la classe SimpleAddressBook. Cette méthode permet d’ajouter un nouveau contact dans le carnet d’adresse. Si un contact existe déjà avec le même numéro de téléphone dans le carnet d’adresse, une exception est levée et aucun contact n’est créé.

Pour enregistrer et lire des contacts de manière persistante, le service repose sur un système de stockage :

  • Une interface DataStorage définit les services nécessaires pour interagir avec le système de stockage, et SimpleAddressBook nécessite une implémentation de cette interface pour pouvoir fonctionner.

  • Le fonctionnement d’un DataStorage est le suivant : le stockage comporte un ensemble de collections, et chaque collection possède un nom unique. Une collection comporte un ensemble de records, chaque record contenant un ensemble de paires clé-valeur, où au maximum une paire existe pour une clé donnée. La classe Record est fournie pour représenter les records.

  • Une classe MongoDBStorage existe et implémente l’interface DataStorage. Cette classe permet d’utiliser un serveur MongoDB pour stocker les données de l’application, et nécessite les coordonnées de ce serveur pour fonctionner. Le code complet de MongoDBStorage n’est pas montré, mais existe et fonctionne.

Le scénario de test que l’on souhaite programmer est le suivant :

« Si le stockage contient un contact contenant les informations

  • {"first_name" : "John", "last_name" : "Doe", "phone_number" : "01234", "address" : "Nantes"},

et qu’on crée un nouveau contact contenant les informations

  • {"first_name" : "Jane", "last_name" : "Doe", "phone\_number" : "56789", "address" : "Nantes"},

alors ce contact est correctement ajouté dans le stockage ».

1.1. Question 1

Faites un diagramme de classe UML simplifié (sans attributs ni opérations) illustrant les relations entre les interfaces et classes du problème.

1.2. Question 2

Faites un diagramme de séquence UML du scénario de test souhaité.

1.3. Question 3

Quel(s) problème(s) de testabilité se présente(nt) à nous ?

1.4. Question 4

À l’aide de la notion de doublure de test, comment peut-on résoudre les problèmes de testabilité évoqués pour tester la méthode addContact ? Sans écrire de code, détailler la solution proposée, en précisant notamment, le type de l’objet doublure, comment cette doublure est configurée et, seulement si nécessaire, les changements à effectuer dans le code de la bibliothèque.

1.5. Question 5

À l’aide de JUnit et Mockito, implémentez le scénario de test souhaité.

2. Exercice 2 − FindMyBakery

On considère le code Java suivant, qui fait partie d’une bibliothèque logicielle d’une application mobile appelée FindMyBakery, qui permet de localiser une boulangerie à proximité de soi :

/**
 * Required data types.
 */

public record Bakery (String name, Coordinates coordinates, boolean isOrganic) {}
public record Coordinates (float latitude, float longitude) {
    public static computeDistanceBetween(Coordinates c1, Coordinates c2) { … }
}

/**
 * To retrieve the current coordinates of the user.
 */
public interface LocalizationModule {
    Coordinates getCurrentCoordinates();
    String getCurrentCityName();
}

/**
 * To ask a server about the bakeries in a given city.
 */
public interface BakeryServerConnector {
    List<Bakery> getBakeriesIn(String city);
}

/**
 * Available working implementations of LocalizationModule and BakeryServerConnector (not shown but exist).
 */
public class PhoneLocalizationModule implements LocalizationModule { … }
public class WebsocketBakeryServerConnector implements BakeryServerConnector { … }

/**
 * Main class of the FindMyBakery app being developped.
 */
public class FindMyBakeryApp {
    /**
     * Find the best bakery the closest from the current location.
     * An organic bakery always comes before a non-organic bakery.
     */
    public Bakery findBestNearestBakery() {
        LocalizationModule localizationModule = new PhoneLocalizationModule();
        BakeryServerConnector bakeryServer = new WebsocketBakeryServerConnector("https://api.findmybakery/socket");
        Coordinates currentCoordinates = localizationModule.getCurrentCoordinates();
        Bakery best = null;
        for (Bakery candidateBakery : bakeryServer.getBakeriesIn(localizationModule.getCurrentCityName())) 
            if (best == null || (!best.isOrganic() && candidateBakery.isOrganic())
                 || (Coordinates.computeDistanceBetween(best.getCoordinates(), currentCoordinates) 
                    > Coordinates.computeDistanceBetween(candidateBakery.getCoordinates(), currentCoordinates))) 
                best = candidateBakery;
        return best;
    }
}

Au sein de la classe FindMyBakeryApp, on souhaite tester la méthode findBestNearestBakery(), qui permet de trouver quelle est la boulangerie bio (en anglais, organic) la plus proche. Si aucune boulangerie bio n’est disponible dans la ville, alors la méthode trouvera la boulangerie non-bio la plus proche.

Pour trouver une boulangerie proche, deux choses sont nécessaires :

  • Il faut acquérir l’emplacement actuel de l’utilisateur de l’application. Pour cela, notre bibliothèque possède une interface LocalizationModule qui définit les services nécessaires. Une implémentation de cette interface a été mise au point, PhoneLocalizationModule (dont le code n’est pas entièrement montré, mais qui est bien disponible), qui permet d’obtenir les coordonnées via le GPS du téléphone qui exécute l’application.

  • Il faut communiquer avec un serveur qui possède une base de données sur toutes les boulangeries qui existent dans le monde, avec notamment l’emplacement de chacune d’entres elles. Pour cela, notre bibliothèque possède une interface BakeryServerConnector qui définit les services nécessaires. Une implémentation de cette interface a été mise au point, WebsocketBakeryServerConnector (là aussi dont le code n’est pas entièrement montré, mais qui est bien disponible), qui permet de se connecter au serveur FindMyBakery via un websocket.

Le scénario de test que l’on souhaite programmer est le suivant :

« Si on se situe en coordonnées (0.0, 0.0) dans la ville de ImaginaryTown, et que deux boulangeries bios B1 en coordonnées (1.0, 0.0) et B2 en coordonnées (2.0, 0.0) sont présentes dans la ville, alors, findBestNearestBakery rend bien B1 ».

2.1. Question 1

Faites un diagramme de classe UML simplifié (sans attributs ni opérations) illustrant les relations entre les interfaces et classes du problème.

2.2. Question 2

Faites un diagramme de séquence UML du scénario de test souhaité.

2.3. Question 3

Quel(s) problème(s) de testabilité présente la méthode findBestNearestBakery ?

2.4. Question 4

A-t-on besoin de doublure(s) pour tester findBestNearestBakery ? Si oui, pour chaque doublure nécessaire :

  • Donnez la classe qui nécessite une doublure, et le type de l’objet doublure à créer.

  • Décrivez quels changements sont nécessaires dans le code pour pouvoir utiliser cette doublure.

2.5. Question 5

À l’aide de JUnit et Mockito, implémentez le scénario de test souhaité.

Vous considérerez que les changements que vous avez estimé nécessaires dans la question précédente ont été réalisés.