Illustration en Delphi du pattern Test Data Builder

Bruno Orsier

- Titre : Illustration en Delphi du pattern Test Data Builder

- Auteur : Bruno Orsier

- Parution : 21 avril 2010

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2010 Bruno Orsier. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Introduction

- II. Principe

- III. Combinaison de Data Builders

- Remerciements

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).