Introducción y puesta en marcha
Servicios para todos
Servicios para unos pocos
¿Y el resto?
Bien, estamos en el supuesto que queremos cambiar solo la implementación de un método concreto de una interfaz, un caso de uso concreto podría ser querer cambiar la implementación del método SendNotificationAsync(…) de la interfaz INotificationHubClient del paquete Microsoft.Azure.NotificationHubs.
Es verdad que algunos servicios de Azure se pueden emular con Azurite o en caso de bases de datos se puede crear una, pero no siempre es posible, no todos los servicios tienen una versión para test o no queremos lidiar con infrastructura compleja pero si puede interesar que el método final al que no tenemos acceso SendNotificationAsync(…) no intente nada raro, es decir, no queremos que se ejecute y lance una notificación o lo intente, queremos que tenga un comportamiento concreto.
Afortunadamente la mayoría de los métodos de las librerías de Azure implementan algún tipo de interfaz y muchas veces se puede inyectar esa librería en el propio host lo que también da opción a que se puede trabajar con ella a nivel de prueba unitaria.
Esto último es importante porque no nos interesa solo conque devuelva unos ciertos valores fijos de salida en base a unos valores de entrada como sería un caso habitual, ni queremos usar el método comentado anteriormente sobrescribiendo la interfaz con una implementación personalizada, esto nos condicionaría el resto de las pruebas y solo nos interesa en un test concreto.
Bien, esto… es bastante complicado, porque interesa hacerlo de una forma demasiado genérica y dinámica, queremos poder implementar cualquier método de una interfaz cualquiera sin tener que crear la clase expresamente -lo recalco en este punto para que cada cual se sienta libre de pensar en como solucionar esto-.
Llegados a este punto posiblemente la mayoría ha pensado en usar reflection, no es una tecnología que suela usar porque casi siempre hay una forma mejor y más sencilla de hacer las cosas, -lo siento por los fans de reflection-.
Pero en este caso es inevitable, de hecho incluso el uso habitual de reflection se queda corto, porque reflection permite hacer muchas cosas pero la mayoría son de modo lectura, por ejemplo podemos sacar mucha información de clases, métodos, etc… incluso ejecutar algunos, pero no podemos modificarlos a nivel de estructura y en este caso nos interesa, porque queremos implementar una interfaz I en una clase C cuando esta clase no la implementa de serie ni queremos hacerlo de manera habitual por código.
A partir de aquí el campo imaginativo se expande con inmensa felicidad para aquellos que gustan de reinventar la rueda (Dinamyc Proxy, Source Generators, Expression Trees…) y cualquier otra idea… creativa. Todas tienen sus pros y sus contras pero en este caso vamos a rizar el rizo y vamos a usar la librería Emit (que también pertenece a reflection) -no hay miedo-.
Esta librería casualmente -o no- también es utilizada por los principales paquetes de pruebas unitarias y no sin razón porque permite realizar casi cualquier cosa sin saber muy bien de donde vienen los tiros, se pueden crear tipos dinámicos con métodos dinámicos sacando la información para crearlos mediante reflection en otro tipo que tampoco sabemos en tiempo de compilación que estructura tiene -una fiesta-.
Pero en este caso es muy útil, porque vamos a hacer lo siguiente, vamos a crear una clase dinámicamente en tiempo de ejecución y le vamos a decir que implemente al vuelo una interfaz cualquiera, y además le vamos a indicar que para un método concreto de esa interfaz va a ejecutar con unos parámetros específicos un código que hemos diseñado a tal efecto, es decir, algo como esto:
public async void A_Cool_Test_Returns_Always()
{ … Code
var notificationAsync = (Notification notification, string tagExpression) =>
{
if (String.IsNullOrEmpty(tagExpression))
throw new Exception("test failed!!");
return new NotificationOutcome()
{
Success = 0,
Failure = 1,
NotificationId = "Good morning with id 1",
};
};
Scenario
.WithService<INotificationHubClient>()
.For(svm => svm.SendNotificationAsync(
n,
$"user:{userId}"))
.Use(notificationAsync)
.Build();
... More code
}
En esta prueba estamos haciendo exactamente eso:
- (WithService<T>) queremos que el servicio T INotificationHubClient de Azure…
- (For(…)) para el método SendNotificationAsync con los parámetros dados…
- (Use()) use la implementación que le pasamos en el delegado que hemos creado.
Para hacer esto vamos guardando toda la información tanto de las firmas como de los delegados, pero también falta la parte más importante, hacer que funcione.
public class ScenarioFixtureBuilder
{
public ServiceFixtureBuilder<TS> WithService<TS>()
where TS: class
{
var service = factory.Services.GetService<TS>();
ServiceFixtureBuilder<TS> sf = new ServiceFixtureBuilder<TS>(this);
return serviceFixture;
}
public ScenarioFixtureBuilder Use(Delegate method)
{
this.Body = method;
serviceFixture.scenarioFixture.AddService<T>(this.serviceFixture.Service);
return serviceFixture.scenarioFixture;
}
public MethodFixtureBuilder For(Expression<Action<T>> expression)
{
MethodFixtureBuilder method = new MethodFixtureBuilder(this, expression);
this.Service.AddMethod(method);
return method;
}
protected internal void Execute([CallerMemberName] string callerName = "")
{
var del = this.Methods.Where(m => m.MethodName == callerName).First();
if (del == null)
{
return;
}
var act = del.Signature;
if (act.Body is MethodCallExpression lambdaBody)
{
// Obtener los parámetros de la expresión lambda
var parameters = lambdaBody.Arguments.ToArray();
var pars = new List<object>(parameters.Length);
foreach (var param in parameters)
{
pars.Add(GetValueFromExpression(param));
}
var a = del.Body.DynamicInvoke(pars.ToArray());
}
}
}
Este método Execute() usa el parámetro callerName que contiene el nombre del método invocador (el método de la interfaz) para recuperar la firma del método que hemos indicado en For() y va a usar estos parámetros para invocar al código que hemos pasado al método Use(), esto hará que cuando el servicio que tengamos que lanza notificaciones (siendo originales un INotificationService) acceda al método de INotificationHubClient lance el código del delegado con los parámetros que le hemos indicado y no use el código de la librería de Azure.
¿Cómo se hace esto? La idea es la siguiente, con Emit vamos a coger una clase que tenemos ya creada (siempre la misma para todas las pruebas) y dinámicamente le vamos a añadir una interfaz (la de Azure) esto claramente implica implementar todos sus métodos pero como no vamos a crear código para cada método porque no interesa ni tiene sentido vamos a hacer que todos los métodos llamen a un método común Execute() que como hemos indicado solo acepta un parámetro, el nombre del método que lo va a invocar (sí, el método de la interfaz) -bueno, en realidad necesitamos dos métodos Execute() uno que devuelve valor y otro que no ,pero para simplificar hablaremos solo de uno-.
Ahora viene la parte de Emit, es un tanto complicada y es de muy bajo nivel pero aquí estarían las partes más destacadas.
private DynamicServiceFixture CreateDynamicClass()
{
// Crear un nuevo ensamblado dinámico
var nombreEnsamblado = new AssemblyName("DynamicAssembly");
var ensambladoBuilder = AssemblyBuilder.DefineDynamicAssembly(nombreEnsamblado, AssemblyBuilderAccess.Run);
// Crear un nuevo módulo en el ensamblado
var moduloBuilder = ensambladoBuilder.DefineDynamicModule("DynamicModule");
// Crear un nuevo tipo dinámico que hereda de la clase e implementa la interfaz
var tipoBuilder = moduloBuilder.DefineType(
"DynamicClassWithInterface",
TypeAttributes.Public | TypeAttributes.Class,
typeof(DynamicServiceFixture),
new Type[] { typeof(T) }
);
// Información del método ejecutar
var executeMethod = typeof(DynamicServiceFixture)
.GetMethod(
nameof(ExecuteNoReturn),
BindingFlags.NonPublic | BindingFlags.Instance);
var methods = typeof(T).GetMethods();
foreach (var method in methods)
{
MethodBuilder method = null;
// Implementar el método de la interfaz
method = tipoBuilder.DefineMethod(
method.Name,
MethodAttributes.Public | MethodAttributes.Virtual,
method.ReturnType,
method.GetParameters().Select(m => m.ParameterType).ToArray()
);
var generadorIL = method.GetILGenerator();
// Cargar la instancia en la pila
generadorIL.Emit(OpCodes.Ldarg_0);
// Nombre del método invocador
generadorIL.Emit(OpCodes.Ldstr, method.Name);
if (method.ReturnType != typeof(void))
{ // El método tiene valor de retorno
// Variable local donde guardar el resultado
var local = generadorIL.DeclareLocal(method.ReturnType);
// Crear el genérico con el mismo tipo de retorno que el llamador
generadorIL.Emit(
OpCodes.Call,
executeMethod.MakeGenericMethod(method.ReturnType));
}
generadorIL.Emit(OpCodes.Ret);
tipoBuilder.DefineMethodOverride(method, method);
// Crear el tipo y devolver
dynamic instance = Activator.CreateInstance(tipoBuilder.CreateType());
return instance;
}
La primera parte es fácil, simplemente crea un tipo dinámico DynamicClassWithInterface, para eso necesitamos crear un ensamblado y un módulo que lo contengan, y vamos a hacer que herede de la clase que hemos comentado que hemos creado a tal efecto DynamicServiceFixture (que sirve para almacenar la información de las firmas y delegados y que usará Execute()) y también vamos a hacer que implemente la interfaz que nos interesa.
Ahora viene la parte interesante, de esta interfaz obtenemos todos los métodos de la misma y en el tipo que hemos definido anterior DynamicClassWithInterface vamos creando uno por uno cada uno de estos métodos, el código aquí implica bastantes más cosas sobre todo relacionadas con genéricos y valores de retorno, pero el código es ilustrativo de lo que se pretende.
Como un método implementado en una clase vía interfaz tiene que tener código -añeja pregunta de entrevista de trabajo- creamos ese código al vuelo, un código muy sencillo, lo único que va a hacer es invocar al método Execute() con el parámetro que hemos comentado que es el nombre del propio método de la interfaz que será el que se utilice para encontrar la información que necesita para ejecutar el delegado como hemos comentado antes.
Con esto lo tendríamos, inyectamos esta interfaz usando el objeto creado de esta forma y cuando se vaya a ejecutar el método SendNotificationAsync() ejecutará realmente el método Execute(«SendNotificacionAsync») y con los parámeros ejecutará el delegado que hemos indicado y devolverá el resultado al código que se está probando.
Este sistema es totalmente compatible con los anteriores ya que se ejecuta de una forma muy local al test y al servicio concreto, otras pruebas no se verán afectadas y podrán usar cualquiera de los métodos anteriores.
Obviamente este sistema vale tanto para métodos de interfaces de terceros como propias aunque requiriendo que la interfaz se puede inyectar a través del host, normalmente es lo habitual y en caso de que no lo sea no es mala idea envolverlo en una interfaz sobre todo sin son librerías de terceros.
