-->

ASP.NET MVC Custom Route Constraints, Dependency I

2020-06-03 09:35发布

问题:

About this topic, I have asked another question:

ASP.NET MVC Custom Route Constraints and Dependency Injection

Here is the current situation: on my ASP.NET MVC 3 App, I have a route constraint defined like below:

public class CountryRouteConstraint : IRouteConstraint {

    private readonly ICountryRepository<Country> _countryRepo;

    public CountryRouteConstraint(ICountryRepository<Country> countryRepo) {
        _countryRepo = countryRepo;
    }

    public bool Match(HttpContextBase httpContext, Route route, string parameterName, RouteValueDictionary values, RouteDirection routeDirection) {

        //do the database look-up here

        //return the result according the value you got from DB
        return true;
    }
}

I am using this like below:

routes.MapRoute(
    "Countries",
    "countries/{country}",
    new { 
        controller = "Countries", 
        action = "Index" 
    },
    new { 
        country = new CountryRouteConstraint(
            DependencyResolver.Current.GetService<ICountryRepository<Country>>()
        ) 
    }
);

At the unit testing part, I used the below code:

[Fact]
public void country_route_should_pass() {

    var mockContext = new Mock<HttpContextBase>();
    mockContext.Setup(c => c.Request.AppRelativeCurrentExecutionFilePath).Returns("~/countries/italy");

    var routes = new RouteCollection();
    TugberkUgurlu.ReservationHub.Web.Routes.RegisterRoutes(routes);

    RouteData routeData = routes.GetRouteData(mockContext.Object);

    Assert.NotNull(routeData);
    Assert.Equal("Countries", routeData.Values["controller"]);
    Assert.Equal("Index", routeData.Values["action"]);
    Assert.Equal("italy", routeData.Values["country"]);
}

Here, I cannot figure out how to pass the dependency. Any idea?

回答1:

Personally I try and avoid performing such validation within a route constraint as it is much harder to express your intentions this way. Instead I use constraints to ensure parameters are in the correct format/type and put such logic in my controllers.

In your example I'm assuming that if the country is not valid then you will fall back to a different route (say to a "Country Not Found" page). Relying on your routing configuration is much less reliable (and much more likely to be broken) than accepting all country parameters and checking them in your controller:

    public ActionResult Country(string country)
    {
        if (country == "france") // lookup to db here
        {
            // valid
            return View();
        }

        // invalid 
        return RedirectToAction("NotFound");
    }

That aside, what you are trying to achieve here (as has already been mentioned) is actually an integration test. When you find that parts of the framework are getting in the way of your tests then it could be time for a refactor. In your example I would want to test

  1. That Countries are validated correctly
  2. My routing configuration.

The first thing we can do is move the Country validation into a separate class:

public interface ICountryValidator
{
    bool IsValid(string country);
}

public class CountryValidator : ICountryValidator
{
    public bool IsValid(string country)
    {
        // you'll probably want to access your db here
        return true;
    }
}

We can then test this as a unit:

    [Test]
    public void Country_validator_test()
    {
        var validator = new CountryValidator();

        // Valid Country
        Assert.IsTrue(validator.IsValid("france"));

        // Invalid Country
        Assert.IsFalse(validator.IsValid("england"));
    }

Our CountryRouteConstraint then changes to:

public class CountryRouteConstraint : IRouteConstraint
{
    private readonly ICountryValidator countryValidator;

    public CountryRouteConstraint(ICountryValidator countryValidator)
    {
        this.countryValidator = countryValidator;
    }

    public bool Match(HttpContextBase httpContext, Route route, string parameterName, RouteValueDictionary values, RouteDirection routeDirection)
    {
        object country = null;

        values.TryGetValue("country", out country);

        return countryValidator.IsValid(country as string);
    }
}

We map our route like so:

routes.MapRoute(
    "Valid Country Route", 
    "countries/{country}", 
    new { controller = "Home", action = "Country" },
    new { country = new CountryRouteConstraint(new CountryValidator()) 
});

Now if you really feel it is necessary to test the RouteConstraint you can test this independently:

    [Test]
    public void RouteContraint_test()
    {
        var constraint = new CountryRouteConstraint(new CountryValidator());

        var testRoute = new Route("countries/{country}",
            new RouteValueDictionary(new { controller = "Home", action = "Country" }),
            new RouteValueDictionary(new { country = constraint }),
            new MvcRouteHandler());

        var match = constraint.Match(GetTestContext(), testRoute, "country", 
            new RouteValueDictionary(new { country = "france" }), RouteDirection.IncomingRequest);

        Assert.IsTrue(match);
    }

Personally I wouldn't bother performing this test as we've already abstracted the validation code so really this is just testing the framework.

To test the route mapping we can use MvcContrib's TestHelper.

    [Test]
    public void Valid_country_maps_to_country_route()
    {
        "~/countries/france".ShouldMapTo<HomeController>(x => x.Country("france"));
    }

    [Test]
    public void Invalid_country_falls_back_to_default_route()
    {
        "~/countries/england".ShouldMapTo<HomeController>(x => x.Index());
    }

Based on our routing configuration we can verify that a valid country maps to the country route and an invalid country maps to the fallback route.

However, the main point of your question was how to handle the dependencies of route constraints. The test above is actually testing a number of things - our routing configuration, route constraint, validator and probably access to a repository/database.

If you're relying on a IoC tool to inject these for you, you're going to have to mock your validator and repository/db and register these with your IoC tool in the set up of your tests.

It would be better if we could control how constraints are created:

public interface IRouteConstraintFactory
{
    IRouteConstraint Create<TRouteConstraint>() 
        where TRouteConstraint : IRouteConstraint;
}

Your "real" implementation can just use your IoC tool to create the IRouteConstraint instance.

I like to put my routing configuration in a separate class like so:

public interface IRouteRegistry
{
    void RegisterRoutes(RouteCollection routes);
}

public class MyRouteRegistry : IRouteRegistry
{
    private readonly IRouteConstraintFactory routeConstraintFactory;

    public MyRouteRegistry(IRouteConstraintFactory routeConstraintFactory)
    {
        this.routeConstraintFactory = routeConstraintFactory;
    }

    public void RegisterRoutes(RouteCollection routes)
    {
        routes.MapRoute(
            "Valid Country", 
            "countries/{country}", 
            new { controller = "Home", action = "Country" },
            new { country = routeConstraintFactory.Create<CountryRouteConstraint>() });

        routes.MapRoute("Invalid Country", 
            "countries/{country}", 
            new { controller = "Home", action = "index" });
    }
}

Constraints with external dependencies can be created using the factory.

This makes testing much easier. Since we're only interested in testing the country routes we can create a test factory that does only what we need:

    private class TestRouteConstraintFactory : IRouteConstraintFactory
    {
        public IRouteConstraint Create<TRouteConstraint>() where TRouteConstraint : IRouteConstraint
        {
            return new CountryRouteConstraint(new FakeCountryValidator());
        }
    }

Note that this time we're using a FakeCountryValidator that contains just enough logic for us to test our routes:

public class FakeCountryValidator : ICountryValidator
{
    public bool IsValid(string country)
    {
        return country.Equals("france", StringComparison.InvariantCultureIgnoreCase);
    }
}

When we set up our tests we pass the TestRouteFactoryConstraint to our route registry:

    [SetUp]
    public void SetUp()
    {
        new MyRouteRegistry(new TestRouteConstraintFactory()).RegisterRoutes(RouteTable.Routes);
    }

This time when we run our routing tests we're not testing our validation logic or database access. Instead we are unit testing our routing configuration when either a valid or invalid country is provided.



回答2:

What are you testing? It seems to me that you only need to unit test your constraint, not the routing engine. In that case, you should be instantiating your constraint and testing it's Match method. Once you know that your constraint works you can do some manual testing to ensure that your route is mapped correctly. That will probably be necessary to ensure the proper ordering of your routes so that you don't match too early (or late) in the set anyway.



回答3:

Now with the information you've provided, the actual dependency you're concerned about is the dependency to DependencyResolver (anyone else find some irony in that?).

You will want to do something like

var mockContext2 = new Mock<IDependencyResolver>();
    mockContext2.Setup(c => 
        c.GetService(It.Is.Any<ICountryRepository<Country>>())
    .Returns(____ whatever you want);

DependencyResolver.SetResolver(mockContext2.Object);

Prior to your usage of the routing setup.

Added information:

Arguably your code be cleaner if you changed

new CountryRouteConstraint(DependencyResolver.Current
                            .GetService<ICountryRepository<Country>>()

To have that be contained in the class itself

public CountryRouteConstraint() : 
    this(DependencyResolver.Current.GetService<ICountryRepository<Country>>()) {}

public CountryRouteConstraint(ICountryRepository<Country> repository) {}

As then you would just new up the CountryRouteConstraint. This is generally the traditional implementation of Poorman's DI. While it does obscure the dependency to DependencyResolver 1 step further I feel that's pretty fine. It keeps with the convention for Poorman's DI and it would give you more expected behaviour.

If you had the class constructed as such above, when you had reached your unit test you would most likely have gotten an exception that DependencyResolver does not know how to activate ICountryRepository<Country> which would push you in the obvious direction of fixing that. Although I suppose you probably got the same exception since you directly called the DependencyResolver, still very noisey to need to write DependencyResolver.Current.GetService<ICountryRepository<Country>>() more than once.