Compartir a través de


ASP.NET MVC

Realización de pruebas para ASP.NET MVC

Keith Burnell

Descargar el ejemplo de código

En el centro del patrón Controlador de vista de modelo (MVC) está la segregación de funciones de IU en tres componentes. El modelo representa los datos y el comportamiento del dominio. La vista administra la visualización del modelo y también las interacciones con los usuarios. El controlador organiza las interacciones entre la visualización y el modelo. Esta separación de lógica de IU inherentemente difícil de probar desde la lógica del negocio hace que las aplicaciones implementadas con el patrón MVC sea muy fácil de probar. En este artículo analizaré las mejores prácticas y técnicas para mejorar la capacidad de prueba de las aplicaciones ASP.NET MVC, incluido cómo estructurar la solución, diseñar el código para administrar la inserción de dependencias y e implementarla con StructureMap.

Crear la estructura de la solución para obtener una máxima capacidad de prueba

Qué mejor forma de comenzar el análisis que empezar por donde todos los desarrolladores inician un nuevo proyecto: en la creación de la solución. Analizaré algunas mejores prácticas para organizar la solución Visual Studio a partir de mi experiencia en el desarrollo de aplicaciones ASP.NET MVC para grandes empresas con el uso del desarrollo guiado por pruebas (TDD). Para comenzar, sugiero usar la plantilla de proyecto vacía al crear un proyecto de ASP.NET MVC. Las otras plantillas son excelente para experimentar o crear pruebas de conceptos, pero por lo general contienen muchas otros elementos que distraen y son innecesarios en una aplicación de empresa real.

Siempre que crea cualquier tipo de aplicación compleja, debe usar un enfoque de n niveles. Para el desarrollo de aplicaciones ASP.NET MVC, recomiendo usar el enfoque que aparece en la Figura 1 y en la Figura 2, que contienen los siguientes proyectos:

  • El proyecto web contiene todo el código específico de la IU, incluidas las visualizaciones, los modelos de visualizaciones, las secuencias de comandos, CSS, etc. Esta capa posee la capacidad de acceder solo a los proyectos Controllers, Service, Domain y Shared.
  • El proyecto Controllers contiene las clases del controlador usados por ASP.NET MVC. Esta capa se comunica con los proyectos Service, Domain y Shared.
  • El proyecto Service contiene la lógica de negocio de la aplicación. Esta capa se comunica con los proyectos DataAccess, Domain y Shared.
  • El proyecto DataAccess contiene el código usado para recuperar y manipular los datos que impulsan la aplicación. Esta capa se comunica con los proyectos Domain y Shared.
  • El proyecto Domain contiene los objetos de dominio usados por la aplicación y tiene prohibido comunicarse con cualquier otro proyecto.
  • El proyecto Shared contiene códigos que deben estar disponibles para varias otras capas, como registradores, constantes y otros códigos de utilidad comunes. Se puede comunicar únicamente con el proyecto Domain.

Interaction Among LayersFigura 1 Interacción entre capas

Example Solution StructureFigura 2 Estructura de la solución de ejemplo

Recomiendo colocar los controladores en un proyecto Visual Studio separado. Para obtener más información sobre cómo se puede realizar esto de manera fácil, consulte la publicación en bit.ly/K4mF2B. Colocar los controladores en un proyecto separado le permite desacoplar aún más la lógica que reside en los controladores del código de IU. El resultado es que el proyecto web contiene únicamente el código que realmente se relaciona con la IU.

Dónde colocar los proyectos de prueba Dónde coloca los proyectos de prueba y cómo los nombra resulta muy importante. Cuando desarrolla aplicaciones complejas en el nivel de empresa, las soluciones tienden a aumentar bastante de tamaño, lo que dificulta ubicar clases específicas o partes de código en el Explorador de soluciones. Agregar varios proyectos de prueba al código base existente solo aumenta la complejidad de la navegación en el Explorador de soluciones. Recomiendo mucho separar físicamente los proyectos de prueba del código real de la aplicación. Sugiero colocar todos los proyectos de prueba en una carpeta Tests en el nivel de la solución. Colocar todos los proyectos de prueba y las pruebas en una sola carpeta de la solución reduce considerablemente los elementos innecesarios en la visualización del Explorador de soluciones y le permite ubicar fácilmente las pruebas.

A continuación debe separar los tipos de pruebas. Lo más probable es que la solución contenga una variedad de tipos de prueba (unidad, integración, rendimiento, IU, etc.) y resulta importante aislar y agrupar cada tipo de prueba. Esto no solo facilita la tarea de ubicar tipos específicos de prueba, sino que también le permite ejecutar fácilmente las pruebas de un tipo específico. Si usa el conjunto de herramientas de productividad más popular de Visual Studio, ReSharper (jetbrains.com/ReSharper) o CodeRush (devexpress.com/CodeRush), obtiene un menú de contexto que le permite hacer doble clic con el botón secundario sobre cualquier carpeta, proyecto o clase en el Explorador de soluciones y ejecutar todas las pruebas contenidas en dicho elemento. Para agrupar las pruebas por tipo, debe crear una carpeta para cada tipo de prueba que pretende escribir dentro de la carpeta Tests de la solución.

La Figura 3 muestra un ejemplo de carpeta Tests de la solución que contiene varias carpetas de tipos de prueba.

An Example Tests Solution FolderFigura 3 Ejemplo de una carpeta Tests de la solución

Nombrar los proyectos de prueba Cómo nombrar los proyectos de prueba es tan importante como el lugar donde los coloca. Lo que se busca es ser capaz de distinguir fácilmente qué parte de la aplicación está a prueba en cada proyecto de prueba y qué tipo de pruebas contiene el proyecto. Para esto, es una buena idea nombrar los proyectos de prueba con el uso de la siguiente convención: [Nombre completo del proyecto en prueba].Test.[Tipo de prueba]. Este le permite determinar solo con mirar, exactamente qué capa del proyecto está a prueba y qué tipo de prueba se realiza. Puede estar pensando que colocar los proyectos de prueba en carpetas específicas por tipo e incluir el tipo de prueba en el nombre del proyecto de prueba resulta redundante, pero recuerde que las carpetas de la solución se usan únicamente en el Explorador de soluciones y no se incluyen en los espacios de nombres de los archivos de proyectos. Así que aunque el proyecto de prueba de la unidad Controllers es la carpeta Tests\Unit de la solución, el espacio de nombre (TestDrivingMVC.Controllers.Test.Unit) no refleja la estructura de la carpeta. Agregar el tipo de prueba al nombrar el proyecto resulta necesario para evitar colisiones de nombres y para determinar con qué tipo de pruebas está trabajando en el editor. La Figura 4 muestra el Explorador de soluciones con proyectos de prueba.

Test Projects in Solution ExplorerFigura 4 Proyectos de prueba en el Explorador de soluciones

Presentación a la inserción de dependencia en la arquitectura

No llegará muy lejos probando las unidades en una aplicación de n niveles antes de que encuentra una dependencia en el código a prueba. Estas dependencias pueden ser otras capas de la aplicación, o pueden ser completamente externas al código (como una base de datos, sistema de archivos o servicios web). Cuando escribe pruebas de unidad, necesita abordar esta situación correctamente y usar dobles de prueba (simulacros, falsificaciones o auxiliares) cuando encuentra una dependencia externa. Para obtener más información sobre los dobles de prueba, consulte “Exploración de los distintos dobles de prueba” (msdn.microsoft.com/magazine/cc163358) que aparece en el número de septiembre de 2007 de MSDN Magazine. Sin embargo, antes de que pueda aprovechar la flexibilidad que le ofrecen los dobles de prueba,el código debe poseer una arquitectura que le permita administrar la inserción de dependencias.

Inserción de dependencias La inserción de dependencias consiste en el proceso de insertar las implementaciones concretas que requiere una clase, en vez de que la clase cree una instancia directamente para la dependencia. La clase consuming no está al tanto de una implementación concreta real de alguna de sus dependencias, sino que solo sabe de las interfaces que respaldan las dependencias; las implementaciones concretas se proporcionan mediante la clase consuming o un marco de inserción de dependencia.

El objetivo de la inserción de dependencias es crear un código de acoplamiento extremadamente flexible. El acoplamiento flexible le permite sustituir fácilmente las implementaciones dobles de prueba de las dependencias al escribir pruebas de unidad.

Existen tres formas principales para lograr la inserción de dependencias:

  • Inserción de propiedad
  • Inserción de constructor
  • Con el uso del marco de inserción de dependencias/contenedor de inversión de control (referido desde ahora como marco DI/IoC)

Con la inserción de propiedad, expone propiedades públicas en el objeto para habilitar que se configuren sus dependencias, como se muestra en la Figura 5. Este enfoque es sencillo y no requiere herramientas.

Figura 5 Inserción de propiedad

// Employee Service
public class EmployeeService : IEmployeeService {
  private ILoggingService _loggingService;
  public EmployeeService() {}
  public ILoggingService LoggingService { get; set; }
  public decimal CalculateSalary(long employeeId) {
    EnsureDependenciesSatisfied();
    _loggingService.LogDebug(string.Format(
      "Calculating Salary For Employee: {0}", employeeId));
    decimal output = 0;
    /*
    * Complex logic that needs to be performed
    * in order to determine the employee's salary
    */
    return output;
  }
  private void EnsureDependenciesSatisfied() {
    if (_loggingService == null)
      throw new InvalidOperationException(
        "Logging Service dependency must be satisfied!");
    }
  }
}
// Employee Controller (Consumer of Employee Service)
public class EmployeeController : Controller {
  public ActionResult DisplaySalary(long id) {
    EmployeeService employeeService = new EmployeeService();
    employeeService.LoggingService = new LoggingService();
    decimal salary = employeeService.CalculateSalary(id);
    return View(salary);
  }
}

Hay tres desventajas bastante considerables en este enfoque. En primer lugar, pone al consumidor a cargo de suministrar las dependencias. Después, requiere que implemente un código de protección en los objetos para asegurar que las dependencias se configuren antes de que se usen. Finalmente, como la cantidad de dependencias del objeto aumentaron, la cantidad de código requerido para crear una instancia para el objeto también aumenta.

Implementar la inserción de dependencias con el uso de la inserción de constructor involucra suministrar dependencias a una clase a través de su constructor cuando se crea una instancia para este, como se muestra en la Figura 6. Este enfoque también es simple, pero a diferencia de la inserción de propiedad, tiene la seguridad de que las dependencias de la clase siempre están configuradas.

Figura 6 Inserción del constructor

// Employee Service
public class EmployeeService : IEmployeeService {
  private ILoggingService _loggingService;
  public EmployeeService(ILoggingService loggingService) {
    _loggingService = loggingService;
  }
  public decimal CalculateSalary(long employeeId) {
    _loggingService.LogDebug(string.Format(
      "Calculating Salary For Employee: {0}", employeeId));
    decimal output = 0;
    /*
    * Complex logic that needs to be performed
    * in order to determine the employee's salary
    */
    return output;
  }
}
// Consumer of Employee Service
public class EmployeeController : Controller {
  public ActionResult DisplaySalary(long employeeId) {
    EmployeeService employeeService =
      new EmployeeService(new LoggingService());
    decimal salary = employeeService.CalculateSalary(employeeId);
    return View(salary);
  }
}

Desafortunadamente, este enfoque también requiere que el consumidor suministre las dependencias. Además, solo resulta una opción adecuada para aplicaciones pequeñas. Por lo general, las aplicaciones más grandes poseen demasiadas dependencias para que se suministren a través del constructor del objeto.

La tercera forma para implementar la inserción de dependencias es usar el marco DI/IoC. Un marco DI/IoC elimina completamente la responsabilidad del consumidor de suministrar dependencias y le permite configurar las dependencias en el tiempo de diseño y que se resuelvan en el tiempo de ejecución. Existen varios marcos DI/IoC disponibles para .NET, incluidos Unity (la oferta de Microsoft), StructureMap, Castle Windsor, Ninject y más. El concepto que subyace todos los diferentes marcos DI/IoC es el mismo y escoger uno, por lo general, se reduce a una preferencia personal. Para demostrar los marcos DI/IoC en este artículo, usaré StructureMap.

Cómo llevar la inserción de dependencias al nivel siguiente con StructureMap

StructureMap (structuremap.net) es un marco de inserción de dependencias ampliamente adoptado. Puede instalarlo a través de NuGet con la Consola del administrador de paquetes (StructureMap de paquete de instalación) o con la GUI del administrador de paquetes de NuGet (haga clic con el botón secundario en la carpeta de referencias del proyecto y seleccione Administrar paquetes NuGet).

Configurar dependencias con StructureMap El primer paso para implementar StructureMap en ASP.NET MVC es configurar las dependencias para que StructureMap sepa como resolverlas. Esto lo logra en el método Application_Start de Global.asax por medio de una de dos opciones.

La primera consiste en decirle manualmente a StructureMap que para una implementación abstracta específica debe usar una implementación concreta específica:

ObjectFactory.Initialize(register => {
  register.For<ILoggingService>().Use<LoggingService>();
  register.For<IEmployeeService>().Use<EmployeeService>();
});

Un inconveniente de este enfoque es que debe registrar manualmente cada una de las dependencias en la aplicación y en grande aplicaciones esto se puede volver tedioso. Además y debido a que registra las dependencias en Application_Start del sitio ASP.NET MVC, la capa web debe poseer un conocimiento directo de cada una de las capas de la aplicación que tiene dependencias a conectar.

También puede usar el registro automático de StructureMap y las características de exploración para inspeccionar los ensambles y conectar las dependencias automáticamente. Con este enfoque, StructureMap explora los ensambles y cuando encuentra una interfaz, busca una implementación concreta asociada (basado en la noción de que una interfaz llamada IFoo será el mapa de convención hacia la implementación concreta Foo):

ObjectFactory.Initialize(registry => registry.Scan(x => {
  x.AssembliesFromApplicationBaseDirectory();
  x.WithDefaultConventions();
}));

Resolución de la dependencia StructureMap Cuando tenga las dependencias configuradas, debe ser capaz de acceder a ellas desde el código base. Esto se logra al crear una resolución de dependencia y al ubicarla en el proyecto Shared (porque necesitará que todas las capas de la aplicación que poseen dependencias puedan acceder a ella): 

public static class Resolver {
  public static T GetConcreteInstanceOf<T>() {
    return ObjectFactory.GetInstance<T>();
  }
}

La clase Resolver (como me gusta llamarla, porque Microsoft presentó una clase DependencyResolver con ASP.NET MVC 3, que analizaré a continuación) es una clase estática sencilla que contiene una función. La función acepta un parámetro genérico T que representa la interfaz para la que busca una implementación concreta y devuelve T, que corresponde a la implementación de la interfaz ingresada.

Antes de analizar cómo usar la nueva clase Resolver en el código, quiero explicar por qué escribí mi propia resolución de dependencia en vez de crear una clase que implemente la interfaz IDependencyResolver presentada con ASP.NET MVC 3. La inclusión de la funcionalidad IDependencyResolver es una excelente adición a ASP.NET MVC y un gran avance para la promoción de las buenas prácticas con relación al software. Desafortunadamente, reside en el DLL System.Web.MVC y no quiere tener referencias a una biblioteca específica de tecnología web en las capas no relacionadas con la Web de la arquitectura de mi aplicación.

Resolver las dependencias en el código Ahora que todo el trabajo duro está hecho, resolver las dependencias en el código es una tarea sencilla. Todo lo que debe hacer es solicitar la función estática GetConcreteInstanceOf de la clase Resolver y pasarla a la interfaz para la que busca una implementación concreta, como se muestra en la Figura 7.

Figura 7 Resolver las dependencias en el código

public class EmployeeService : IEmployeeService {
  private ILoggingService _loggingService;
  public EmployeeService() {
    _loggingService = 
      Resolver.GetConcreteInstanceOf<ILoggingService>();
  }
  public decimal CalculateSalary(long employeeId) {
    _loggingService.LogDebug(string.Format(
      "Calculating Salary For Employee: {0}", employeeId));
    decimal output = 0;
    /*
    * Complex logic that needs to be performed
    * in order to determine the employee's salary
    */
    return output;
  }
}

Aprovechar StructureMap para insertar dobles de prueba en las pruebas de unidad Ahora que el código posee una arquitectura para que pueda insertar dependencias sin la intervención del consumidor, volvamos a la tarea original de administrar correctamente las dependencias en las pruebas de unidad. Este es el escenario:

  • La tarea consiste en escribir una lógica con el uso de TDD que genere la valor del salario para devolver desde el método CalculateSalary de EmployeeService. (Encontrará las funciones EmployeeService CalculateSalary en la Figura 7.)
  • Existe un requisito que todas las solicitudes a la función CalculateSalary debe registrar.
  • La interfaz para el servicio de registro está definido, pero la implementación no está completa. Solicitar el servicio de registro actualmente arroja una excepción.
  • La tarea se debe completar antes de que el trabajo en el servicio de registro se programe para comenzar.

Es muy probable que ya se haya encontrado en este escenario. Sin embargo, ahora posee la arquitectura apropiada ya implementada para terminar con la igualdad con la dependencia al colocar un doble de prueba en su lugar. Me gusta crear mis dobles de prueba en un proyecto que se puede compartir entre todos los proyectos de prueba. Como puede ver en la Figura 8, creé un proyecto Shared en mi carpeta Tests de la solución. Dentro del proyecto agregué una carpeta Fakes porque para completar mi prueba necesito una implementación falsa de ILoggingService.

Project for Shared Test Code and Fakes
Figura 8 Proyecto para códigos de prueba compartidos y falsos

Crear una implementación falsa para el servicio de registro es fácil. En primer lugar, creo una clase dentro de la carpeta Fakes llamada LoggingServiceFake. LoggingServiceFake debe cumplir con el contrato que EmployeeService está esperando, lo que significa que debe implementar ILoggingService y sus métodos. Por definición, una falsa es un sustituto que contiene la cantidad necesaria de código para cumplir con lo que requiere la interfaz. Generalmente, esto quiere decir que posee implementaciones vacías de métodos nulos y las implementaciones de función contienen una instrucción de devolución que devuelve un valor codificado, así:

public class LoggingServiceFake : ILoggingService {
  public void LogError(string message, Exception ex) {}
  public void LogDebug(string message) {}
  public bool IsOnline() {
    return true;
  }
}

Ahora que la falsa está implementada, puedo escribir mi prueba. Para comenzar, crearé una clase de prueba en el proyecto de prueba de unidad TestDrivingMVC.Service.Test.Unit y de acuerdo con las convenciones de nombres analizadas anteriormente, la llamaré EmployeeServiceTest, como aparece en la Figura 9.

Figura 9 La clase de prueba EmployeeServiceTest

[TestClass]
public class EmployeeServiceTest {
  private ILoggingService _loggingServiceFake;
  private IEmployeeService _employeeService;
  [TestInitialize]
  public void TestSetup() {
    _loggingServiceFake = new LoggingServiceFake();
    ObjectFactory.Initialize(x => 
      x.For<ILoggingService>().Use(_loggingServiceFake));
    _employeeService = new EmployeeService();
  }
  [TestMethod]
  public void CalculateSalary_ShouldReturn_Decimal() {
    // Arrange
    long employeeId = 12345;
    // Act
    var result = 
      _employeeService.CalculateSalary(employeeId);
    // Assert
    result.ShouldBeType<decimal>();
  }
}

En la mayoría de los casos, el código de la clase de prueba es bastante sencillo. La línea a la que le debe prestar mayor atención es:

ObjectFactory.Initialize(x =>
    x.For<ILoggingService>().Use(
    _loggingService));

Este es el código que le dice a StructureMap que debe usar LoggingServiceFake cuando la clase Resolver que creamos anteriormente intente resolver ILoggingService. Puse este código en un método marcado con TestInitialize, lo que le dice al marco de prueba de unidad que debe ejecutar este método antes de ejecutar cada prueba en la clase de prueba.

Con el uso de las capacidades de DI/IoC y de la herramienta StructureMap, puedo terminar con la igualdad del servicio de registro. Al hacer esto puedo completar mi código y la prueba de unidad sin que me afecte el estado del servicio de registro y codificar pruebas de unidad verdaderas que no dependen de ninguna dependencia.

Usar StructureMap como la fábrica de controladores predeterminada ASP.NET MVC proporciona un punto de extensibilidad que le permite agregar una implementación personalizada de cómo se crean instancias para los controladores en la aplicación. Al crear una clase que hereda de DefaultControllerFactory (consulte la Figura 10), puede controlar cómo se crean los controladores.

Figura 10 Fábrica de controladores personalizada

public class ControllerFactory : DefaultControllerFactory {
  private const string ControllerNotFound = 
  "The controller for path '{0}' could not be found or it does not implement IController.";
  private const string NotAController = "Type requested is not a controller: {0}";
  private const string UnableToResolveController = 
    "Unable to resolve controller: {0}";
  public ControllerFactory() {
    Container = ObjectFactory.Container;
  }
  public IContainer Container { get; set; }
  protected override IController GetControllerInstance(
    RequestContext context, Type controllerType) {
    IController controller;
    if (controllerType == null)
      throw new HttpException(404, String.Format(ControllerNotFound,
      context.HttpContext.Request.Path));
    if (!typeof (IController).IsAssignableFrom(controllerType))
      throw new ArgumentException(string.Format(NotAController,
      controllerType.Name), "controllerType");
    try {
      controller = Container.GetInstance(controllerType) 
        as IController;
    }
    catch (Exception ex) {
      throw new InvalidOperationException(
      String.Format(UnableToResolveController, 
        controllerType.Name), ex);
    }
    return controller;
  }
}

En la nueva fábrica de controladores, tengo una propiedad pública de StructureMap que se configura a partir de ObjectFactory de StructureMap (que se configura en la Figura 10 en Global.asax).

A continuación, tengo una anulación del método GetControllerInstance que realiza cierta comprobación de tipo y luego usa el contenedor de StructureMap para resolver el controlador actual a partir del parámetro de tipo de controlador suministrado. Debido a que usé el registro automático de StructureMap y las características de exploración cuando configuré inicialmente StructureMap, no hay nada más que tenga que hacer.

La ventaja de crear una fábrica de controladores personalizada es que ya no está limitado a los constructores sin parámetros en los controladores. En este punto es posible que se esté preguntando "¿Cómo voy a suministrar los parámetros a un constructor del controlador?" Gracias a la extensibilidad de DefaultControllerFactory y StructureMap, no es necesario que lo haga. Cuando declara un constructor parametrizado para los controladores, las dependencias se resuelven automáticamente cuando el controlador se resuelve en la nueva fábrica de controladores.

Como puede ver en la Figura 11, agregué un parámetro IEmployeeService al constructor de HomeController. Cuando el controlador se resuelve en la nueva fábrica de controladores, cualquier parámetro requerido por el constructor del controlador se resuelve automáticamente. Esto significa que no necesita agregar el código para resolver las dependencias del controlador de forma manual, pero todavía puede usar falsas, como lo analizamos anteriormente.

Figura 11 Resolución del controlador

public class HomeController : Controller {
  private readonly IEmployeeService _employeeService;
  public HomeController(IEmployeeService employeeService) {
    _employeeService = employeeService;
  }
  public ActionResult Index() {
    return View();
  }
  public ActionResult DisplaySalary(long id) {
    decimal salary = _employeeService.CalculateSalary(id);
    return View(salary);
  }
}

Al usar estas prácticas y técnicas en sus aplicaciones ASP.NET MVC, podrá lograr un proceso TDD más fácil y limpio.

Keith Burnell es ingeniero de software jefe en Skyline Technologies. Ha desarrollado software durante más de 10 años, con una especialización en el desarrollo de sitios web ASP.NET y ASP.NET MVC a gran escala. Burnell es un miembro activo de la comunidad de desarrolladores y se puede encontrar en su blog (dotnetdevdude.com) y en Twitter en twitter.com/keburnell.

Gracias a los siguientes expertos técnicos por su ayuda en la revisión de este artículo: John Ptacek y Clark Sell