Skip to content

Commit e2050d8

Browse files
committed
Added Routing section to the tutorial (#132)
1 parent ede3488 commit e2050d8

14 files changed

+1007
-47
lines changed

docs/_docfx/tutorial/helpers.md

Whitespace-only changes.

docs/_docfx/tutorial/httpauthentication.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ public void CompleteShouldReturnViewWithCorrectIdWithFoundOrderForTheUser()
113113

114114
It fails. Obviously, we need an authenticated user to test this action. We can attach it to the **"HttpContext"** but let's make it easier. Head over to the **"project.json"** file again and add **"MyTested.AspNetCore.Mvc.Authentication"**:
115115

116+
```json
116117
"dependencies": {
117118
"dotnet-test-xunit": "2.2.0-*",
118119
"xunit": "2.2.0-*",
@@ -127,6 +128,7 @@ It fails. Obviously, we need an authenticated user to test this action. We can a
127128
"MyTested.AspNetCore.Mvc.ViewActionResults": "1.0.0",
128129
"MusicStore": "*"
129130
},
131+
```
130132

131133
**"WithAuthenticatedUser"** method will be added to the fluent API. You can use it to set identifier, username, roles, claims and identities. But for now call it empty like this:
132134

docs/_docfx/tutorial/routing.md

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
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+
Email = "[email protected]",
199+
Password = "123456",
200+
RememberMe = "true"
201+
}))
202+
.To<AccountController>(c => c.Login(
203+
new LoginViewModel
204+
{
205+
Email = "[email protected]",
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!

docs/_docfx/tutorial/sessioncache.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ public void AddToCartShouldSaveTheAlbumsIntoDatabaseAndSession()
8383
.ToAction(nameof(ShoppingCartController.Index));
8484
```
8585

86+
Of course, you can extract the magic strings... :)
87+
8688
## Cache
8789

8890
For the caching assertions, we will need **"MyTested.AspNetCore.Mvc.Caching"** as a dependency. Go and add it to the **"project.json"**:

docs/_docfx/tutorial/toc.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,8 @@
3030
- name: ViewBag, ViewData & TempData
3131
href: viewbagviewdatatempdata.md
3232
- name: View Components
33-
href: viewcomponents.md
33+
href: viewcomponents.md
34+
- name: Routing
35+
href: routing.md
36+
- name: Various Helpers
37+
href: helpers.md

docs/_docfx/tutorial/viewbagviewdatatempdata.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,13 @@ The **"ContainingEntries"** call is equivalent to this one:
122122
}))
123123
```
124124

125-
Both methods will validate whether the total number of entries in the **"ViewBag"** is equal to the total number you provide to the test.
125+
Both methods will validate whether the total number of entries in the **"ViewBag"** is equal to the total number you provide to the test. For validation - remove the **"ArtistId"** property from anonymous object and run the test again:
126126

127-
If you do not want the total number validation, just use:
127+
```
128+
When calling Create action in StoreManagerController expected view bag to have 1 entry, but in fact found 2.
129+
```
130+
131+
If you do not want the total number of entries validation, just use:
128132

129133
```c#
130134
.ViewBag(viewBag => viewBag // <---

0 commit comments

Comments
 (0)