Tuesday, 25 August 2009

Validation Framework with MVC Support (Part 2) - Tests

Following on from the previous post describing a validation framework I'm planning to use on MVC projects, this post extends the development to incorporate a range of tests.

To recap, I've set up a domain model structure that consists of:
  • Entities - simple classes representing the fields and relationships of my business objects. They consist of properties and methods that act only on those properties (i.e. they have no external dependencies.
  • Repository layer - a number of data access methods that provide CRUD methods for retrieving and data from the database and instantiating the entities, and persisting their changes back.
  • Service layer - a layer consisting of methods that sit between the UI and repository layer, passing method calls and objects in between.
These classes combine to provide validation at three levels:
  • Entity validation - these are C# methods defined on the entity object itself to internally validate its fields. They will consist of checks for required fields, range checking and internal field comparisons.
  • Service validation - these are checks made against external dependencies. Examples might be user authorisation or arbitrary business rules such as "customer records can only be saved on a Friday".
  • Database validation - basically wrappers around database exceptions, such as errors caused due to deleting records where foreign key constraints are violated, or entry of duplicate key values.
In order to test the classes created for each entity type, I've set up four folders, each for different types of tests:
  • Entity tests - unit tests on the entity self-validation rules
  • Service tests - unit tests on the service layer, using a mocked repository
  • Controller tests - unit tests on the MVC controller class, using a mocked service layer
  • Integration tests - tests that hit the database, running against a known set of data
An example of each follows.

Customer Entity Test

This test prepares a customer object with a known validation error (a missing name). The rule violations are queried and asserted to check there is a single correct exception reported.


    1 private Customer PrepareCustomer()


    2 {


    3     return new Customer


    4     {


    5         Id = 1,


    6         Name = "Test Customer"


    7     };


    8 }


    9 


   10 [Description("Entity Test"), TestMethod]


   11 public void Customer_InvalidCustomerWithMissingName_GeneratesException()


   12 {


   13     //Arrange


   14     Customer customer = PrepareCustomer();


   15 


   16     //Act


   17     customer.Name = "";


   18 


   19     //Assert


   20     Assert.AreEqual(1, customer.GetRuleViolations().Count);


   21     Assert.AreEqual("Name is a required field.", customer.GetRuleViolations()[0]);


   22 }




Customer Service Test

This unit test acts on a method in the service layer. The idea here is to isolate the function and test it without dependencies - i.e. by mocking the repository layer to the test doesn't actually hit the database. In other words, by mocking the repository, we can say given a defined behaviour in the repository method, does the service method respond appropriately. Here we are checking that the correct exceptions are thrown given and valid and invalid Customer object.


    1 private CustomersService service = new CustomersService(MockCustomersRepository());


    2 


    3 static ICustomersRepository MockCustomersRepository()


    4 {


    5     //Set up test data


    6     IList<Customer> customers = new List<Customer>


    7             {


    8                 new Customer {Id = 2, Name = "Drama"},


    9                 new Customer {Id = 1, Name = "Entertainment"}


   10             };


   11 


   12     // Generate an implementor of IProductsRepository at runtime using Moq


   13     var mockCustomersRepository = new Moq.Mock<ICustomersRepository>();


   14     mockCustomersRepository.Setup(x => x.UpdateCustomer(It.IsAny<Customer>())).Verifiable();


   15 


   16     return mockCustomersRepository.Object;


   17 }


   18 


   19 private Customer PrepareCustomer()


   20 {


   21     return new Customer


   22     {


   23         Id = 1,


   24         Name = "Test Customer"


   25     };


   26 }


   27 


   28 [Description("Service Test"), TestMethod]


   29 public void CustomerService_UpdateValidCustomerWithValidAccount_GeneratesNoExceptions()


   30 {


   31     // Arrange


   32     Customer customer = PrepareCustomer();


   33     User user = new User { Id = 1, Role = new Role { Id = 1, Name = "Administrator" } };


   34 


   35     // Act: Request the update method for customer that will pass validation and with valid account


   36     service.UpdateCustomer(customer, user);


   37 


   38     // Assert: Check the results


   39 }


   40 


   41 [Description("Service Test"), TestMethod]


   42 public void CustomerService_UpdateCustomerWithMissingName_GeneratesRuleExceptions()


   43 {


   44     // Arrange


   45     Customer customer = PrepareCustomer();


   46     customer.Name = "";


   47     User user = new User { Id = 1, Role = new Role { Id = 1, Name = "Administrator" } };


   48 


   49     // Act: Request the update method for customer that will fail validation due to missing name


   50     bool ok = false;


   51     try


   52     {


   53         service.UpdateCustomer(customer, user);


   54         ok = true;


   55     }


   56     catch (RuleException ex)


   57     {


   58         // Assert: Check the results


   59         Assert.AreEqual(1, ex.Errors.Count);


   60         Assert.AreEqual("Name is a required field.", ex.Errors[0]);


   61     }


   62     Assert.IsFalse(ok);


   63 }




Customer Integration Test

This test involves the database, and confirms that the correct information is written to and retrieved from it.


    1 [Description("Integration Test"), TestMethod]


    2 public void CustomerIntegration_UpdateValidCustomerWithValidAccount_UpdatesCustomerDetails()


    3 {


    4     //Arrange           


    5     Customer customer = service.GetCustomer(1);


    6     User user = new User { Id = 1, Role = new Role { Id = 1, Name = "Administrator" } };


    7 


    8     //Act           


    9     customer.Name = "Test Customer Changed";


   10     service.UpdateCustomer(customer, user);


   11     customer = service.GetCustomer(1);


   12 


   13     //Assert


   14     Assert.AreEqual("Test Customer Changed", customer.Name);


   15 


   16     //Revert


   17     customer.Name = "Test Customer";


   18     service.UpdateCustomer(customer, user);


   19     customer = service.GetCustomer(1);


   20     Assert.AreEqual(1, customer.Id);


   21     Assert.AreEqual("Test Customer", customer.Name);


   22 }




Customer Controller Test

In this test we are mocking the service layer, and testing the behaviour of the controller. Again we can say that given a mocked behaviour of the service layer, do the controller methods respond with the appropriate ActionResults (redirects or view rendering).


    1 private ICustomersService CustomersService = MockCustomersService();


    2 


    3 static ICustomersService MockCustomersService()


    4 {


    5     //Set up test data


    6     IList<customer> customers = new List<customer>


    7     {


    8         new customer {Id = 1, Name = "Test Customer"},


    9         new customer {Id = 2, Name = "Test Customer Two"}


   10     };


   11 


   12     // Generate an implementor of ICustomersService at runtime using Moq


   13     var mockCustomersService = new Moq.Mock<ICustomersService>();


   14     mockCustomersService.Setup(x => x.GetCustomer(1).Returns(Customers[0]);


   15     mockCustomersService.Setup(x => x.UpdateCustomer(It.Is<Customer>(t => t.Name == "Invalid"), null)).Throws(new RuleException(new NameValueCollection { { "Name", "Invalid" } }));


   16     return mockCustomersService.Object;


   17 }


   18 


   19 [Description("Controller Test"), TestMethod]


   20 public void CustomersController_EditValidCustomer_ReturnsCorrectRedirectAction()


   21 {


   22     // Arrange: get controller


   23     CustomersController controller = new CustomersController(CustomersService, usersService);


   24     ContextMocks mocks = new ContextMocks(controller);


   25 


   26     // Act: Request the edit action for Customer that will pass validation


   27     var result = controller.Edit(new Customer { Id = 3, Name = "Valid" }, null, null);


   28 


   29     // Assert: Check the results


   30     Assert.IsNotNull(result);


   31     Assert.IsInstanceOfType(result, typeof(RedirectToRouteResult));


   32     Assert.AreEqual("Index", ((RedirectToRouteResult)result).RouteValues["action"]);


   33 }


   34 


   35 [Description("Controller Test"), TestMethod]


   36 public void CustomersController_EditInvalidCustomer_ReturnsViewWithErrorsInModelState()


   37 {


   38     // Arrange: get controller


   39     CustomersController controller = new CustomersController(CustomersService, usersService);


   40     ContextMocks mocks = new ContextMocks(controller);


   41 


   42     // Act: Request the edit action for Customer that will fail validation          


   43     ActionResult result = null;


   44     Customer Customer = CustomersService.GetCustomer(3, CustomerLoad.LoadCustomerOnly);


   45     Customer.Name = "Invalid";


   46     result = controller.Edit(Customer, null, null);


   47 


   48     // Assert: Check the results


   49     Assert.IsNotNull(result);


   50     Assert.IsInstanceOfType(result, typeof(ViewResult));


   51     Assert.AreEqual("Edit", ((ViewResult)result).ViewName);


   52     Assert.IsFalse(controller.ModelState.IsValid);


   53     Assert.AreEqual(1, controller.ModelState.Count);


   54 }




Conclusion

As you can see, this is a very simple, yet fairly comprehensive set of tests on the domain model. Although there's a fair bit of work involved for each set of classes, the patterns are straightforward and hence should be relatively easy for a developer or team to adhere to. Following it I hope is going to lead to a robust domain model, with a consistent and reliable means of validating business rules.

One last note on the integration tests. As they involve hitting the database, they aren't classed as unit tests - they suffer from external dependencies that may lead to brittleness, and they will be a little slower to run due to the need to connect for real to the database. They are therefore a bit more painful to maintain - they can quickly become out of synch with the data, leading to false negative results, and untrustworthy tests.

It's going to take a bit of discipline to maintain this I'm sure - but the way I'm looking to do this is that once I had the bulk of my database structure built, I used the database publishing wizard to generate a script of the full schema and data. This I amended slightly to add statements to drop and create the database, and then checked the file into the solution.

From now on, following any updates to the database schema, I must also make the change to the script. Doing this means that when I come to run the integration tests that are coded to expect particular data values in return, I just need to run the script before running the tests.

No comments:

Post a Comment