I. Introduction▲
Dans les tests unitaires nous devons construire des objets avec divers paramètres, et pour éviter les duplications de code, nous utilisons en général le pattern Factory pour produire des objets pré-configurés. Cette utilisation du pattern Factory dans les tests s'appelle en fait le pattern ObjectMother. L'inconvénient de ObjectMother est qu'il peut conduire à un grand nombre de méthodes pour produire toutes les variantes d'objets dont nous avons besoin dans les tests, peut contenir beaucoup de duplications et devenir difficile à maintenir. Par conséquent ObjectMother est parfois abandonné par des équipes de développement pour un autre pattern appelé Test Data Builder.
Dans cet article je montre comment réaliser Test Data Builder en Delphi (mais le code se transposera facilement en C# ou en Java, et il sera même plus simple dans ces langages).
Enfin, pour mémoriser le pattern, je vous propose le diagramme suivant - sa signification devrait s'éclaircir à la lecture de l'article !
II. Principe▲
Supposons que nous ayons une classe TCustomer souvent utilisée dans les tests, qui se présente de la manière suivante :
TCustomer = class
public
constructor
Create(const
address, name: string
);
function
ToString: string
;
private
Faddress: string
;
Fname: string
;
end
;
C'est bien sûr une classe simpliste utilisée juste pour montrer le principe.
Le pattern Test Data Builder consiste à créer une classe TCustomerBuilder qui sera chargée de produire des instances de TCustomer correctement initialisée avec les valeurs les plus couramment utilisées dans les tests, et qui pourront être facilement remplacées si besoin. Voici un exemple de code utilisant TCustomerBuilder :
customer_builder := TCustomerBuilder.Create() ;
customer_default := customer_builder.build();
customer_one_change := customer_builder
.withName('Toto'
)
.build();
customer_two_changes := customer_builder
.withAddress('1 rue des champs'
)
.withName('Truc'
)
.build();
//do something with the instances
customer_two_changes.Free;
customer_one_change.Free;
customer_default.Free;
customer_builder.Free ;
Le code ci-dessus montre les particularités du pattern :
- utilisation minimale pour avoir une instance directement utilisable, et initialisée avec des valeurs par défaut valables pour une majorité de tests : customer_default.
- utilisations avec surcharge d'une valeur par défaut : customer_one_change.
- chaînage de surchages, pour une plus grande lisibilité du code de test : customer_two_changes.
C'est malheureusement un peu lourd en Delphi, car il faut gérer manuellement la destruction des objets (et pour cela stocker toutes les instances dans des variables). Par contre en C# ou Java on écrit simplement :
customer_one_change =
new
TCustomerBuilder
(
).WithName
(
'Hurlu Berlu'
).build
(
);
Et l'on ne s'occupe plus du tout de l'instance de TCustomerBuilder qui sera supprimée par le ramasse-miettes. De plus cela présente l'avantage de repartir avec une nouvelle instance de TCustomerBuilder à chaque fois, et c'est vital pour que chaque création d'instance de TCustomer soit indépendante de la précédente. Pour arriver à ce résultat en Delphi, il faudrait à chaque fois créer un nouveau TCustomerBuilder (lourd car il faut le supprimer aussitôt), ou encore le réinitialiser après chaque utilisation (soit manuellement avec une méthode reset, soit un peu plus automatiquement dans la méthode build). C'est cette dernière option que j'ai choisie dans mon implémentation :
TCustomerBuilder = class
public
constructor
Create;
function
build(const
reset : boolean
= true
) : TCustomer;
function
withAddress(const
address: string
): TCustomerBuilder;
function
withName(const
name: string
): TCustomerBuilder;
private
Faddress: string
;
Fname: string
;
end
;
Notez l'appel du constructeur Create depuis une méthode, ce qui permet de refaire les initialisations sans réallouer de la mémoire (c'est une particularité peu connue des constructeurs Delphi).
On verra plus loin qu'il n'est pas toujours souhaitable de réinitialiser le builder, et c'est pourquoi j'ai introduit ce paramètre reset avec une valeur par défaut.
Le reste de l'implémentation de TCustomerBuilder est très direct. Il faut simplement prendre soin de regrouper toutes les initialisations dans le constructeur, et de bien retourner self dans les functions with... afin de permettre le chaînage :
constructor
TCustomerBuilder.Create;
begin
Faddress := '1 rue Bidule'
;
Fname := 'Test'
;
end
;
function
TCustomerBuilder.withAddress(
const
address: string
): TCustomerBuilder;
begin
Faddress := address;
result := self
;
end
;
function
TCustomerBuilder.withName(const
name: string
): TCustomerBuilder;
begin
Fname := name;
result := self
;
end
;
Et pour terminer, voici l'interface de TCustomerBuilder :
TCustomerBuilder = class
public
constructor
Create;
function
build(const
reset : boolean
= true
) : TCustomer;
function
withAddress(const
address: string
): TCustomerBuilder;
function
withName(const
name: string
): TCustomerBuilder;
private
Faddress: string
;
Fname: string
;
end
;
III. Combinaison de Data Builders▲
Dans l'exemple précédent, l'adresse pour un TCustomer était de type string ; si à la place j'utilise maintenant un type plus structuré (afin de gérer indépendamment les différents constituants de l'adresse), je vais pouvoir écrire du code de test comme :
address_builder_Grenoble := TAddressBuilder.Create.WithCity('Grenoble'
) ;
customer_builder := TCustomerBuilder.Create ;
customer1 := customer_builder
.withAddress(address_builder_Grenoble)
.withName('Y. Truc'
)
.withAccountId(123
)
.build();
customer2 := customer_builder
.withAddress(address_builder_Grenoble)
.withName('F. Truc'
)
.withAccountId(123
)
.build();
// do something
customer2.Free;
customer1.Free;
customer_builder.Free ;
address_builder_Grenoble.Free ;
Au passage j'ai un peu enrichi la classe TCustomer avec un nouveau champ AccountId - afin d'introduire une répétition dont je me débarrasserai dans la section suivante.
Pour arriver au code de test ci-dessus, il me faut une classe TAddress :
TAddress = class
public
constructor
Create(const
street, postalCode, city: string
);
function
toString: string
;
private
Fstreet, FpostalCode, Fcity: string
;
end
;
ainsi qu'un Builder associé :
type
TAddressBuilder = class
public
constructor
Create;
constructor
newAddress;
function
build: TAddress;
function
WithStreet(const
street: string
): TAddressBuilder;
function
WithPostalCode(const
postalcode: string
): TAddressBuilder;
function
WithCity(const
city: string
): TAddressBuilder;
private
Fstreet, FpostalCode, Fcity: string
;
end
;
J'ai introduit un constructeur newAddress (qui appelle simplement Create - ce dernier doit continuer à regrouper toutes les initialisations), afin de voir si cela rendait l'écriture un peu plus "fluide". En effet l'une des idées derrière ce pattern est d'arriver à créer un petit langage pour faciliter l'écriture des tests. Avec ce constructeur vous pourriez écrire le code ci-dessous. Mais l'intérêt n'est pas évident en Delphi où nous sommes obligés de stocker le builder dans une variable pour le détruire nous-mêmes plus loin. Cela réduit la fluidité, par rapport à C# ou Java.
address_builder_Grenoble := TAddressBuilder.newAddress.WithCity('Grenoble'
) ;
En C# ou Java, nous écririons simplement :
customer1 =
new
TCustomerBuilder
(
)
.WithAddress
(
new
TAddressBuilder
(
).WithCity
(
'Grenoble'
))
...
.build
(
);
On peut noter dans ces exemples de code de test que le TCustomerBuilder reçoit directement un TAddressBuilder, et non pas un TAddress. Cela permet d'éviter de mettre des appels à build() partout dans le code de test.
Pour en revenir à Delphi, où décidément tout cela est un peu plus laborieux, voici les modifications de TCustomerBuilder pour accepter en paramètre un TAddressBuilder :
function
TCustomerBuilder.build: TCustomer;
begin
result := TCustomer.Create(FaddressBuilder.build(), Fname, FaccountId);
end
;
constructor
TCustomerBuilder.Create;
begin
FaddressBuilder := TAddressBuilder.newAddress;
Fname := 'Test'
;
FaccountId := 1
;
end
;
function
TCustomerBuilder.withAddress(
const
addressBuilder: TAddressBuilder): TCustomerBuilder;
begin
FaddressBuilder.Free; // fuite de mémoire sinon !
FaddressBuilder := addressBuilder;
result := self
;
end
;
Il faut surtout faire très attention à ne pas provoquer de fuite mémoire (voir commentaire dans le code ci-dessus). On peut également remarquer que l'appel du build de TAddressBuilder est maintenant localisé dans TCustomerBuilder.build, ce qui semble être une "bonne odeur".
Remerciements▲
Merci à Johan Martinsson qui m'a fait découvrir le livre Growing Object-Oriented Software, Guided by Tests, et ses nombreux exemples en Java dont je me suis inspiré pour cet article (en particulier le chapitre 22 qui est consacré à ce pattern).
