Lorsque l’on fait du développement conduit par les tests (Test Driven Development), on écrit les tests avant d’écrire son code. Ce qui entraine naturellement d’écrire du code testable. Et l’on a donc naturellement tendance à rendre public tout ce qui doit être testable (classes ou méthodes).
Jusqu’ici je n’avais pas vraiment rencontré de cas où ce principe était en défaut. Mais c’était surtout parce que j’écrivais des applications complètes et non des librairies. Puis j’ai travaillé sur nDumbster.
nDumbster avait été écrit en TDD, donc toutes les classes et méthodes testé étaient publique. Et certaines classes uniquement nécessaires au fonctionnement de nDumbster avaient été déclaré publiques pour cette raison. Mais elles ne servaient qu’à modéliser le protocole SMTP et n’avaient pas à être connus du développeur utilisant nDumbster pour tester son application.
Ceci ne me posait pas de problème, jusqu’à ce que j’utilise nDoc pour générer la documentation des classes de nDumbster. Et évidement toutes les classes et les méthodes apparaissaient dans la documentation, alors que 2 classes sont réellement utiles. D’où une documentation plus difficile à comprendre.
J’ai d’abord pensé à utiliser des tags spécifiques pour ne documenter que les classes et les méthodes utiles, ce qui n’est pas la bonne solution. D’une part parce qu’il peut être utile d’avoir une documentation complète de toutes les classes, à destination des développeurs de la librairie, ce qu’il est facile de faire en activant ou non l’option de nDoc de documentation des classes et méthode privées et internes. Et d’autre part parce que les utilisateurs de la librairie ne devraient pas pouvoir utiliser les classes et les méthodes d’implémentations (sinon à quoi servirait la notion de portée en Csharp ?).
Donc il fallait rendre internal les classes et les méthodes que l’utilisateur de la librairie n’avait pas à connaitre. Cela étant dit, il reste le problème des tests unitaires. Comment tester une classe internal ?
Une première solution est d’écrire les tests dans la même assembly que les classes privées. C’est possible car NUnit peut lire n’importe quelle assembly. Le problème c’est de livrer un assembly contenant des tests et lié aux assembly de nUnit. C’est vrai que nDumbster est censé être utilisé pour faire des tests, mais pas nécessairement avec NUnit ! Si on part dans cette voie, la solution est d’entourer la définition des classes de test par des conditions de compilation, par exemple #if test. Il vaut mieux utiliser une autre définition que DEBUG, pour pouvoir tester les deux versions debug et release. Et dans ce cas si aucune utilisation des classes de nunit n’est faite, la référence n’est pas prise en compte par le compilateur, ce qui élimine la dépendance dans les versions non testables. Mais cela ne permet pas de tester les méthodes privées ou protégées.
Le problème de cette approche est que dans l’utilisation de directives de compilation a tendance à perturber les IDE et les outils comme ReSharper. De plus mélanger le code utile et le code de test empêche de calculer facilement un ratio code utile/code de test. Et bien sur nDumbter le code utile et les tests était déjà dans deux assembly différents. Il me fallait une autre solution.
Sous l’influence de Rédo depuis notre rencontre, je me dis qu’avec System.Reflection je devrais trouver mon bonheur.
Je cherche un peu sur le web, et je trouve le blog de Steven M. Cohn qui propose une classe répondant presque à mon besoin. Je vous livre ici la version modifiée par mes soins, afin de pouvoir créer des objets public pour utiliser leur méthodes protégée ou internes, et de pouvoir appeler les méthodes privée/internes d’un objet dont on a une référence (par exemple le résultat d’une méthode d’une autre classe).
Le constructeur de la classe PrivateClassTester utilise comme premier paramètre le nom complet du type d’objet à créer (utilisé par Type.GetType()). Les arguments suivants sont les paramètres du constructeur à utiliser pour l’objet à tester. Ils détermine la signature du constructeur qui sera utilisé.
Le nom complet sera de la forme MonEspaceDeNom.MaClasse,MonNomDAssembly.
Un autre constructeur accepte simplement l’instance d’un objet dont on veut appeler les méthodes privées.
Voici le code de la classe PrivateObjectTester :
using System;
using System.Reflection;
using System.Security.Permissions;
#region .
///
/// This utility class uses reflection to wrap an instance of a
/// class to gain access to non-public members of that class.
/// This is an internal utility class used for unit testing.
///
/// Based on a class by Steven M. Cohn
/// http://weblogs.asp.net/stevencohn/archive/2004/06/08/151235.aspx
///
#endregion
public class PrivateObjectTester
{
private const BindingFlags bindingFlags =
BindingFlags.Instance | BindingFlags.Public |
BindingFlags.NonPublic | BindingFlags.Static;
private Type type; // type of class to manage
private object instance; // managed instance of type
private ReflectionPermission perm; // for non-public members
#region .
///
/// Initializes a new instance wrapping a new instance of the
/// target type. One PrivateObject manages exactly one instance
/// of a target type.
///
///
The qualified name of the type.
/// This should include the full assembly qualified name including
/// the namespace, for example "MyNamespace.MyType,MyAssemblyBaseName"
/// where MyNamespace is the dotted-notation namespace, MyType is the
/// name of the type and MyAssemblyBaseName is the base name of the
/// assembly containing the type.
///
///
An optional array of parameters to pass to
/// the constructor. If this argument is not specified then the
/// default constructor is used. Otherwise, a constructor that
/// matches the number and type of parameters is used.
///
#endregion
public PrivateObjectTester (string qualifiedTypeName, params object[] args)
{
perm = new ReflectionPermission(PermissionState.Unrestricted);
perm.Demand();
type = Type.GetType(qualifiedTypeName);
Type[] types = new Type[args.Length];
for (int i=0; i < args.Length; i++)
{
types[i] = args[i].GetType();
}
ConstructorInfo constructor =
type.GetConstructor(bindingFlags,null,types,null);
instance =constructor.Invoke(args);
}
public PrivateObjectTester (object instance)
{
perm = new ReflectionPermission(PermissionState.Unrestricted);
perm.Demand();
type = Type.GetTypeFromHandle(Type.GetTypeHandle(instance));
this.instance = instance;
}
#region .
///
/// Gets the instance of the managed object.
///
#endregion
public object Instance
{
get { return instance; }
}
#region .
///
/// Gets the value of a non-public field (member variable) of the
/// managed type.
///
///
The name of the non-public field to
/// interrogate
/// A value whose type is specific to the field.
#endregion
public object GetField (string name)
{
FieldInfo fi = type.GetField(name, bindingFlags);
return fi.GetValue(instance);
}
#region .
///
/// Gets the value of a non-public property of the managed type.
///
///
The name of the non-public property to
/// interrogate.
/// A value whose type is specific to the property.
#endregion
public object GetProperty (string name)
{
PropertyInfo pi = type.GetProperty(name,bindingFlags);
return pi.GetValue(instance,null);
}
#region .
///
/// Invokes the non-public method of the managed type.
///
///
The name of the non-public method to invoke.
///
And optional array of typed parameters to pass to the
/// method. If this argument is not specified then the routine searches
/// for a method with a signature that contains not parameters. Otherwise,
/// the procedure searches for a method with the number and type of parameters
/// specified.
///
/// A value who type is specific to the invoked method.
#endregion
public object Invoke (string name, params object[] args)
{
Type[] types = new Type[args.Length];
for (int i=0; i < args.Length; i++)
{
types[i] = args[i].GetType();
}
return type.GetMethod(name,bindingFlags,null,types,null).Invoke(instance,args);
}
#region .
///
/// Sets the value of a non-public field (member variable) of the managed type.
///
///
The name of the non-public field to modify.
///
A value whose type is specific to the field.
#endregion
public void SetField (string name, object val)
{
type.GetField(name,bindingFlags).SetValue(instance,val);
}
#region .
///
/// Sets the value of a non-public property of the managed type.
///
///
The name of the non-public property to modify.
///
A value whose type is specific to the property.
#endregion
public void SetProperty (string name, object val)
{
type.GetProperty(name,bindingFlags).SetValue(instance,val,null);
}
}
-
using System;
-
-
using System.Reflection;
-
-
using System.Security.Permissions;
-
-
-
-
-
-
#region .
-
-
/// <summary>
-
-
/// This utility class uses reflection to wrap an instance of a
-
-
/// class to gain access to non-public members of that class.
-
-
/// This is an internal utility class used for unit testing.
-
-
///
-
-
/// Based on a class by Steven M. Cohn
-
-
/// http://weblogs.asp.net/stevencohn/archive/2004/06/08/151235.aspx
-
-
/// </summary>
-
-
#endregion
-
-
public class PrivateObjectTester
-
-
{
-
-
private const BindingFlags bindingFlags =
-
-
BindingFlags.Instance | BindingFlags.Public |
-
-
BindingFlags.NonPublic | BindingFlags.Static;
-
-
private Type type; // type of class to manage
-
-
private object instance; // managed instance of type
-
-
private ReflectionPermission perm; // for non-public members
-
-
#region .
-
-
/// <summary>
-
-
/// Initializes a new instance wrapping a new instance of the
-
-
/// target type. One PrivateObject manages exactly one instance
-
-
/// of a target type.
-
-
/// </summary>
-
-
/// <param name="qualifiedTypeName">The qualified name of the type.
-
-
/// This should include the full assembly qualified name including
-
-
/// the namespace, for example "MyNamespace.MyType,MyAssemblyBaseName"
-
-
/// where MyNamespace is the dotted-notation namespace, MyType is the
-
-
/// name of the type and MyAssemblyBaseName is the base name of the
-
-
/// assembly containing the type.
-
-
/// </param>
-
-
/// <param name="args">An optional array of parameters to pass to
-
-
/// the constructor. If this argument is not specified then the
-
-
/// default constructor is used. Otherwise, a constructor that
-
-
/// matches the number and type of parameters is used.
-
-
/// </param>
-
-
#endregion
-
-
public PrivateObjectTester (string qualifiedTypeName, params object[] args)
-
-
{
-
-
perm =
new ReflectionPermission
(PermissionState.
Unrestricted);
-
-
perm.Demand();
-
-
-
-
type = Type.GetType(qualifiedTypeName);
-
-
-
-
Type
[] types =
new Type
[args.
Length];
-
-
for (int i=0; i < args.Length; i++)
-
-
{
-
-
types[i] = args[i].GetType();
-
-
}
-
-
-
-
ConstructorInfo constructor =
-
-
type.GetConstructor(bindingFlags,null,types,null);
-
-
instance =constructor.Invoke(args);
-
-
}
-
-
-
-
public PrivateObjectTester (object instance)
-
-
{
-
-
perm =
new ReflectionPermission
(PermissionState.
Unrestricted);
-
-
perm.Demand();
-
-
-
-
type = Type.GetTypeFromHandle(Type.GetTypeHandle(instance));
-
-
-
-
this.instance = instance;
-
-
}
-
-
-
-
#region .
-
-
/// <summary>
-
-
/// Gets the instance of the managed object.
-
-
/// </summary>
-
-
#endregion
-
-
public object Instance
-
-
{
-
-
get { return instance; }
-
-
}
-
-
-
-
-
-
-
-
#region .
-
-
/// <summary>
-
-
/// Gets the value of a non-public field (member variable) of the
-
-
/// managed type.
-
-
/// </summary>
-
-
/// <param name="name">The name of the non-public field to
-
-
/// interrogate</param>
-
-
/// <returns>A value whose type is specific to the field.</returns>
-
-
#endregion
-
-
public object GetField (string name)
-
-
{
-
-
FieldInfo fi = type.GetField(name, bindingFlags);
-
-
return fi.GetValue(instance);
-
-
}
-
-
-
-
#region .
-
-
/// <summary>
-
-
/// Gets the value of a non-public property of the managed type.
-
-
/// </summary>
-
-
/// <param name="name">The name of the non-public property to
-
-
/// interrogate.</param>
-
-
/// <returns>A value whose type is specific to the property.</returns>
-
-
#endregion
-
-
public object GetProperty (string name)
-
-
{
-
-
PropertyInfo pi = type.GetProperty(name,bindingFlags);
-
-
return pi.GetValue(instance,null);
-
-
}
-
-
-
-
-
-
-
-
#region .
-
-
/// <summary>
-
-
/// Invokes the non-public method of the managed type.
-
-
/// </summary>
-
-
/// <param name="name">The name of the non-public method to invoke.</param>
-
-
/// <param name="args">And optional array of typed parameters to pass to the
-
-
/// method. If this argument is not specified then the routine searches
-
-
/// for a method with a signature that contains not parameters. Otherwise,
-
-
/// the procedure searches for a method with the number and type of parameters
-
-
/// specified.
-
-
/// </param>
-
-
/// <returns>A value who type is specific to the invoked method.</returns>
-
-
#endregion
-
-
public object Invoke (string name, params object[] args)
-
-
{
-
-
Type
[] types =
new Type
[args.
Length];
-
-
for (int i=0; i < args.Length; i++)
-
-
{
-
-
types[i] = args[i].GetType();
-
-
}
-
-
-
-
return type.GetMethod(name,bindingFlags,null,types,null).Invoke(instance,args);
-
-
}
-
-
-
-
-
-
-
-
#region .
-
-
/// <summary>
-
-
/// Sets the value of a non-public field (member variable) of the managed type.
-
-
/// </summary>
-
-
/// <param name="name">The name of the non-public field to modify.</param>
-
-
/// <param name="val">A value whose type is specific to the field.</param>
-
-
#endregion
-
-
public void SetField (string name, object val)
-
-
{
-
-
type.GetField(name,bindingFlags).SetValue(instance,val);
-
-
}
-
-
-
-
-
-
-
-
#region .
-
-
/// <summary>
-
-
/// Sets the value of a non-public property of the managed type.
-
-
/// </summary>
-
-
/// <param name="name">The name of the non-public property to modify.</param>
-
-
/// <param name="val">A value whose type is specific to the property.</param>
-
-
#endregion
-
-
public void SetProperty (string name, object val)
-
-
{
-
-
type.GetProperty(name,bindingFlags).SetValue(instance,val,null);
-
-
}
-
-
}
Je répète donc les mises en garde d’usage, n’utiliser cette classe que pour écrire les tests unitaires de bibliothèques. Dans un contexte d’application, son utilisation révèle à coup sur un problème de conception, que ce soit des tests ou des classes testés !
A titre d’exemple, le code suivant (qui n’est compilable que dans l’assembly nDumbster, les classes étant internal :
nDumbster.smtp.SmtpRequest request = new nDumbster.smtp.SmtpRequest(nDumbster.smtp.SmtpActionType.UNRECOG,
null, snDumbster.smtp.SmtpState.CONNECT);
nDumbster.smtp.SmtpResponse response = request.Execute();
Assert.AreEqual(500, response.Code);
-
nDumbster.
smtp.
SmtpRequest request =
new nDumbster.
smtp.
SmtpRequest(nDumbster.
smtp.
SmtpActionType.
UNRECOG,
-
-
null, snDumbster.smtp.SmtpState.CONNECT);
-
-
nDumbster.smtp.SmtpResponse response = request.Execute();
-
-
Assert.AreEqual(500, response.Code);
Devient :
PrivateObjectTester smtpAction =
new PrivateObjectTester("nDumbster.smtp.SmtpActionType,nDumbster", (sbyte)0);
PrivateObjectTester smtpState =
new PrivateObjectTester("nDumbster.smtp.SmtpState,nDumbster", (sbyte)0);
PrivateObjectTester request =
new PrivateObjectTester("nDumbster.smtp.SmtpRequest,nDumbster",
smtpAction.GetField("UNRECOG"), "",
smtpState.GetField("CONNECT"));
PrivateObjectTester response = new PrivateObjectTester(request.Invoke("Execute"));
Assert.AreEqual(500, response.GetProperty("Code"));
-
PrivateObjectTester smtpAction =
-
-
new PrivateObjectTester
("nDumbster.smtp.SmtpActionType,nDumbster",
(sbyte)0);
-
-
PrivateObjectTester smtpState =
-
-
new PrivateObjectTester
("nDumbster.smtp.SmtpState,nDumbster",
(sbyte)0);
-
-
PrivateObjectTester request =
-
-
new PrivateObjectTester
("nDumbster.smtp.SmtpRequest,nDumbster",
-
-
smtpAction.GetField("UNRECOG"), "",
-
-
smtpState.GetField("CONNECT"));
-
-
PrivateObjectTester response =
new PrivateObjectTester
(request.
Invoke("Execute"));
-
-
Assert.AreEqual(500, response.GetProperty("Code"));
Bons tests !