|
| 1 | +# Routing |
| 2 | + |
| 3 | +If you have a single route mapping (for example the default one), it will be not hard for you to validate and assert whether than all controllers and action resolve correctly. However, when your application gets bigger and bigger and you start to map different kinds of routes and introduce various changes to them, it can be quite difficult and messy to guarantee their integrity. This is where route testing comes in handy. |
| 4 | + |
| 5 | +## Validating controllers and actions |
| 6 | + |
| 7 | +Go to the **"project.json"** file and add the **"MyTested.AspNetCore.Mvc.Routing"** dependency: |
| 8 | + |
| 9 | +```c# |
| 10 | +"dependencies": { |
| 11 | + "dotnet-test-xunit": "2.2.0-*", |
| 12 | + "xunit": "2.2.0-*", |
| 13 | + "Moq": "4.6.38-*", |
| 14 | + "MyTested.AspNetCore.Mvc.Authentication": "1.0.0", |
| 15 | + "MyTested.AspNetCore.Mvc.Caching": "1.0.0", |
| 16 | + "MyTested.AspNetCore.Mvc.Controllers": "1.0.0", |
| 17 | + "MyTested.AspNetCore.Mvc.DependencyInjection": "1.0.0", |
| 18 | + "MyTested.AspNetCore.Mvc.EntityFrameworkCore": "1.0.0", |
| 19 | + "MyTested.AspNetCore.Mvc.Http": "1.0.0", |
| 20 | + "MyTested.AspNetCore.Mvc.ModelState": "1.0.0", |
| 21 | + "MyTested.AspNetCore.Mvc.Models": "1.0.0", |
| 22 | + "MyTested.AspNetCore.Mvc.Options": "1.0.0", |
| 23 | + "MyTested.AspNetCore.Mvc.Routing": "1.0.0", // <--- |
| 24 | + "MyTested.AspNetCore.Mvc.Session": "1.0.0", |
| 25 | + "MyTested.AspNetCore.Mvc.ViewActionResults": "1.0.0", |
| 26 | + "MyTested.AspNetCore.Mvc.ViewComponents": "1.0.0", |
| 27 | + "MyTested.AspNetCore.Mvc.ViewData": "1.0.0", |
| 28 | + "MusicStore": "*" |
| 29 | +}, |
| 30 | +``` |
| 31 | + |
| 32 | +Create a **"Routing"** folder at the root of the test project and add **"HomeRouteTest"** class in it. We will start with something easy and validate the **"Error"** action in **"HomeController"**: |
| 33 | + |
| 34 | +```c# |
| 35 | +public IActionResult Error() |
| 36 | +{ |
| 37 | + // action code skipped for brevity |
| 38 | +} |
| 39 | +``` |
| 40 | + |
| 41 | +The simplest route test possible: |
| 42 | + |
| 43 | +```c# |
| 44 | +[Fact] |
| 45 | +public void GetErrorActionShouldBeRoutedSuccessfuly() |
| 46 | + => MyRouting |
| 47 | + .Configuration() |
| 48 | + .ShouldMap("/Home/Error") |
| 49 | + .To<HomeController>(c => c.Error()); |
| 50 | +``` |
| 51 | + |
| 52 | +My Tested ASP.NET Core MVC gets the routing configuration from the provided **"TestStartup"** class. Note that the route testing will not fire any application middleware components or MVC filters. It is simply validating whether the MVC router will select the correct controller and action based on the HTTP request data. Works with both conventional and attribute routing. Additionally, the testing framework uses the same services ASP.NET Core uses, so if you make any customizations to the route selection process, it will not interfere with the assertions logic and tests should still validate your mappings. |
| 53 | + |
| 54 | +## Validating route values |
| 55 | + |
| 56 | +We will now going to validate route values next to controllers and actions. The **"AddToCart"** action in the **"ShoppingCartController"** looks perfect for that purpose: |
| 57 | + |
| 58 | +```c# |
| 59 | +public async Task<IActionResult> AddToCart(int id) |
| 60 | +{ |
| 61 | + // action code skipped for brevity |
| 62 | +} |
| 63 | +``` |
| 64 | + |
| 65 | +Create **"ShoppingCartRouteTest""** class and add the following test: |
| 66 | + |
| 67 | +```c# |
| 68 | +[Fact] |
| 69 | +public void GetAddToCartActionShouldBeRoutedSuccessfuly() |
| 70 | + => MyRouting |
| 71 | + .Configuration() |
| 72 | + .ShouldMap("/ShoppingCart/AddToCart/1") |
| 73 | + .To<ShoppingCartController>(c => c.AddToCart(1)); |
| 74 | +``` |
| 75 | + |
| 76 | +Query strings are also easy. Let's test the **"Browse"** action in the **"StoreController"**: |
| 77 | + |
| 78 | +```c# |
| 79 | +public async Task<IActionResult> Browse(string genre) |
| 80 | +{ |
| 81 | + // action code skipped for brevity |
| 82 | +} |
| 83 | +``` |
| 84 | + |
| 85 | +Create **"StoreRouteTest""** class and add the following test: |
| 86 | + |
| 87 | +```c# |
| 88 | +[Fact] |
| 89 | +public void GetBrowseActionShouldBeRoutedSuccessfuly() |
| 90 | + => MyRouting |
| 91 | + .Configuration() |
| 92 | + .ShouldMap("/Store/Browse?genre=HipHop") |
| 93 | + .To<StoreController>(c => c.Browse("HipHop")); |
| 94 | +``` |
| 95 | + |
| 96 | +And if you change **"HipHop"** with **"Rock"** for example you will see the following error message: |
| 97 | + |
| 98 | +``` |
| 99 | +Expected route '/Store/Browse' to contain route value with 'genre' key and the provided value but the value was different. |
| 100 | +``` |
| 101 | + |
| 102 | +## Ignoring route values |
| 103 | + |
| 104 | +Some action parameters do not have to be tested. They come from the service provider, not the MVC routers. Let's take a look at the **"Index"** action in the **"HomeController"**: |
| 105 | + |
| 106 | +```c# |
| 107 | +public async Task<IActionResult> Index( |
| 108 | + [FromServices] MusicStoreContext dbContext, |
| 109 | + [FromServices] IMemoryCache cache) |
| 110 | +{ |
| 111 | + // action code skipped for brevity |
| 112 | +} |
| 113 | +``` |
| 114 | + |
| 115 | +We do not want to test the **"MusicStoreContext"** and the **"IMemoryCache"** action parameters. Ignoring them is a piece of cake - just use the helper method **"With.Any"** wherever you want to skip assertion: |
| 116 | + |
| 117 | +```c# |
| 118 | +[Fact] |
| 119 | +public void GetIndexActionShouldBeRoutedSuccessfuly() |
| 120 | + => MyRouting |
| 121 | + .Configuration() |
| 122 | + .ShouldMap("/Home") |
| 123 | + .To<HomeController>(c => c.Index( |
| 124 | + With.Any<MusicStoreContext>(), // <--- |
| 125 | + With.Any<IMemoryCache>())); // <--- |
| 126 | +``` |
| 127 | + |
| 128 | +**"With.Any"** can be used on any action parameter used in a route test. |
| 129 | + |
| 130 | +## More specific request |
| 131 | + |
| 132 | +All of the above examples are using HTTP Get method and the provided path as request data to test with. However, without adding more specific information, some actions cannot be routed correctly. For example **"RemoveFromCart"** in **"ShoppingCartController"**: |
| 133 | + |
| 134 | +```c# |
| 135 | +[HttpPost] |
| 136 | +public async Task<IActionResult> RemoveFromCart( |
| 137 | + int id, |
| 138 | + CancellationToken requestAborted) |
| 139 | +{ |
| 140 | + // action code skipped for brevity |
| 141 | +} |
| 142 | +``` |
| 143 | + |
| 144 | +The following test will fail right away: |
| 145 | + |
| 146 | +```c# |
| 147 | +[Fact] |
| 148 | +public void PostRemoveFromCartActionShouldBeRoutedSuccessfuly() |
| 149 | + => MyRouting |
| 150 | + .Configuration() |
| 151 | + .ShouldMap("/ShoppingCart/RemoveFromCart/1") |
| 152 | + .To<ShoppingCartController>(c => c.RemoveFromCart( |
| 153 | + 1, |
| 154 | + With.Any<CancellationToken>())); |
| 155 | +``` |
| 156 | + |
| 157 | +We are testing with HTTP Get request while the action is restricted only for HTTP Post ones. Let's fix the issue: |
| 158 | + |
| 159 | +```c# |
| 160 | +[Fact] |
| 161 | +public void PostRemoveFromCartActionShouldBeRoutedSuccessfuly() |
| 162 | + => MyRouting |
| 163 | + .Configuration() |
| 164 | + .ShouldMap(request => request // <--- |
| 165 | + .WithMethod(HttpMethod.Post) |
| 166 | + .WithLocation("/ShoppingCart/RemoveFromCart/1")) |
| 167 | + .To<ShoppingCartController>(c => c.RemoveFromCart( |
| 168 | + 1, |
| 169 | + With.Any<CancellationToken>())); |
| 170 | +``` |
| 171 | + |
| 172 | +This way we are explicitly setting the request to have HTTP Post method making the routing match the specified controller, action and route value. |
| 173 | + |
| 174 | +## Model Binding |
| 175 | + |
| 176 | +Besides route values, you can also assert that all request properties (like its body for example) are bound to the action parameters and models. For example for fields in the HTTP Post overload of the **"Login"** action in the **"AccountController"**: |
| 177 | + |
| 178 | +```c# |
| 179 | +[HttpPost] |
| 180 | +public async Task<IActionResult> Login(LoginViewModel model, string returnUrl = null) |
| 181 | +{ |
| 182 | + // action code skipped for brevity |
| 183 | +} |
| 184 | +``` |
| 185 | + |
| 186 | +The login view model should come from the request from so we may decide to ignore it by using **"With.Any"** or provide it like so: |
| 187 | + |
| 188 | +```c# |
| 189 | +[Fact] |
| 190 | +public void PostLoginShouldBeRoutedSuccessfuly() |
| 191 | + => MyRouting |
| 192 | + .Configuration() |
| 193 | + .ShouldMap(request => request |
| 194 | + .WithMethod(HttpMethod.Post) |
| 195 | + .WithLocation("/Account/Login?returnUrl=Test") |
| 196 | + .WithFormFields(new // <--- |
| 197 | + { |
| 198 | + |
| 199 | + Password = "123456", |
| 200 | + RememberMe = "true" |
| 201 | + })) |
| 202 | + .To<AccountController>(c => c.Login( |
| 203 | + new LoginViewModel |
| 204 | + { |
| 205 | + |
| 206 | + Password = "123456", |
| 207 | + RememberMe = true |
| 208 | + }, |
| 209 | + "Test")); |
| 210 | +``` |
| 211 | + |
| 212 | +Note that the **"RememberMe"** property value is provided as string. This way is correct since in the normal HTTP requests every form field is a simple text. If you by mistake provide as a C# boolean value, you will receive an error. |
| 213 | + |
| 214 | +The **"WithFormFields"** method call does some magic behind the scenes and it's just a shorter way to write: |
| 215 | + |
| 216 | +```c# |
| 217 | +. WithFormField( "Email", "[email protected]") |
| 218 | +.WithFormField("Password", "123456") |
| 219 | +.WithFormField("RememberMe", "true")) |
| 220 | +``` |
| 221 | + |
| 222 | +## JSON body |
| 223 | + |
| 224 | +The **"Music Store"** web application does not have any JSON-based model binding but it is not hard to test with one: |
| 225 | + |
| 226 | +```c# |
| 227 | +MyRouting |
| 228 | + .Configuration() |
| 229 | + .ShouldMap(request => request |
| 230 | + .WithMethod(HttpMethod.Post) |
| 231 | + .WithLocation("/My/Action") |
| 232 | + .WithJsonBody(@"{""MyNumber"":1,""MyString"":""MyText""}")) |
| 233 | + .To<MyController>(c => c.Action( |
| 234 | + new MyModel |
| 235 | + { |
| 236 | + MyNumber = 1, |
| 237 | + MyString = "MyText" |
| 238 | + })); |
| 239 | +``` |
| 240 | + |
| 241 | +There is also an anonymous object overload: |
| 242 | + |
| 243 | +```c# |
| 244 | +MyRouting |
| 245 | + .Configuration() |
| 246 | + .ShouldMap(request => request |
| 247 | + .WithMethod(HttpMethod.Post) |
| 248 | + .WithLocation("/My/Action") |
| 249 | + .WithJsonBody(new |
| 250 | + { |
| 251 | + MyNumber = 1, |
| 252 | + MyString = "MyText" |
| 253 | + })) |
| 254 | + .To<MyController>(c => c.Action( |
| 255 | + new MyModel |
| 256 | + { |
| 257 | + MyNumber = 1, |
| 258 | + MyString = "MyText" |
| 259 | + })); |
| 260 | +``` |
| 261 | + |
| 262 | +It may seems a bit strange at first but My Tested ASP.NET Core MVC serializes the anonymous object to JSON string, attach it to the HTTP request body as a stream and pass it to the routing system. |
| 263 | + |
| 264 | +Of course, you can always ignore model binding and just assert controllers and actions: |
| 265 | + |
| 266 | +```c# |
| 267 | +MyRouting |
| 268 | + .Configuration() |
| 269 | + .ShouldMap(request => request |
| 270 | + .WithMethod(HttpMethod.Post) |
| 271 | + .WithLocation("/My/Action")) |
| 272 | + .To<MyController>(c => c.Action(With.Any<MyModel>())); |
| 273 | +``` |
| 274 | + |
| 275 | +## Section summary |
| 276 | + |
| 277 | +Still not convinced about route testing and its capabilities? Check this [ultimate crazy model binding test](https://github.com/ivaylokenov/MyTested.AspNetCore.Mvc/blob/development/test/MyTested.AspNetCore.Mvc.Routing.Test/BuildersTests/RoutingTests/RouteTestBuilderTests.cs#L766) which asserts JSON body, route values, query string parameters, form fields and headers at the same time. I hope no one writes such actions though... |
| 278 | + |
| 279 | +We are almost at the finish line. Next section will cover various test [Helpers](/tutorial/helpers.html) which do not fall within a particular tutorial section! |
0 commit comments