Skip to content

Commit 5c8b0a4

Browse files
committed
Added Session & Cache section to the tutorial (#132)
1 parent 9bd05d2 commit 5c8b0a4

17 files changed

+845
-18
lines changed
180 KB
Loading

docs/_docfx/tutorial/attributes.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,4 @@ public void StoreManagerControllerShouldHaveCorrectAttributes()
105105

106106
## Section summary
107107

108-
We saw how easy it is to assert and validate all kinds of controller and action attributes. Now let's revisit our [Options](/tutorial/options.html) testing!
108+
We saw how easy it is to assert and validate all kinds of controller and action attributes. But enough about them - in the next section we will cover thrown [Exceptions](/tutorial/exceptions.html).

docs/_docfx/tutorial/debugging.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,4 @@ You know the drill from here on! :)
4545

4646
## Section summary
4747

48-
In this section we learned how helpful and developer-friendly is My Tested ASP.NET Core MVC with failed tests. But enough about failures and errors. Let's dive into the [Controllers](/tutorial/controllers.html) testing!
48+
In this section we learned how helpful and developer-friendly is My Tested ASP.NET Core MVC with failed tests. Later in the tutorial we will also see how easy it is to debug with the **"ShouldPassFor"** method. But enough about failures and errors. Let's dive into the [Controllers](/tutorial/controllers.html) testing!

docs/_docfx/tutorial/exceptions.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Exceptions
2+
3+
Normally, a controller action should not throw any exceptions but rather return some result. However, that depends on the application configuration, conventions and architecture. Let's dive deeper!
4+
5+
## Asserting thrown exceptions
6+
7+
Currently the **"Music Store"** application handles most of it potential bad requests but there is a possible exception in the **"AddToCart"** action located in the **"ShoppingCartController"**. More specifically, this code:
8+
9+
```c#
10+
var addedAlbum = await DbContext.Albums
11+
.SingleAsync(album => album.AlbumId == id);
12+
```
13+
14+
If the database does not contain the provided **"id"**, our application will throw an exception. Let's assert that. Create **"ShoppingCartControllerTest"** class, add the necessary usings and write the following test:
15+
16+
```c#
17+
[Fact]
18+
public void AddToCartShouldThrowExceptionWithInvalidId()
19+
=> MyController<ShoppingCartController>
20+
.Instance()
21+
.Calling(c => c.AddToCart(1))
22+
.ShouldThrow()
23+
.AggregateException()
24+
.ContainingInnerExceptionOfType<InvalidOperationException>()
25+
.WithMessage()
26+
.Containing("Sequence contains no elements");
27+
```
28+
29+
Since we do not add any entries to the scoped in memory database, the test should pass without any problem. With it we are validating that the action throws **"AggregateException"** with message containing **"Sequence contains no elements"** and inner **"InvalidOperationException"**. Next to the **"AggregateException"**, there is the normal **"Exception"** call, which asserts non-asynchronous errors.
30+
31+
## Uncaught exceptions
32+
33+
Let's see what happens if the action throws an exception and we try to validate a normal action result, for example:
34+
35+
```c#
36+
[Fact]
37+
public void AddToCartShouldThrowExceptionWithInvalidId()
38+
=> MyController<ShoppingCartController>
39+
.Instance()
40+
.Calling(c => c.AddToCart(1))
41+
.ShouldReturn()
42+
.View();
43+
```
44+
45+
As you may expect, we receive a nice and descriptive error message:
46+
47+
```
48+
When calling AddToCart action in ShoppingCartController expected no exception but AggregateException (containing InvalidOperationException with 'Sequence contains no elements' message) was thrown without being caught.
49+
```
50+
51+
## Section summary
52+
53+
And kids... that's how we assert thrown exceptions! :)
54+
55+
Now let's revisit our [Options](/tutorial/options.html) testing!

docs/_docfx/tutorial/options.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ Go to the **"project.json"** file and add **"MyTested.AspNetCore.Mvc.Options"**
3232
},
3333
```
3434

35+
Adding this package will automatically make all the options related services scoped.
36+
3537
Go to the unit test asserting the **"Details"** action in the **"StoreManagerControllerTest"** controller and change the following code:
3638

3739
```c#
Lines changed: 291 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,291 @@
1-
# Session & Cache
1+
# Session & Cache
2+
3+
In this section we will cover two of the most commonly used modules for data persistence between different requests - session and cache.
4+
5+
## Session
6+
7+
To use the built-in session capabilities of My Tested ASP.NET Core MVC, we need to add **"MyTested.AspNetCore.Mvc.Session"** as a dependency:
8+
9+
```json
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.Controllers": "1.0.0",
16+
"MyTested.AspNetCore.Mvc.DependencyInjection": "1.0.0",
17+
"MyTested.AspNetCore.Mvc.EntityFrameworkCore": "1.0.0",
18+
"MyTested.AspNetCore.Mvc.Http": "1.0.0",
19+
"MyTested.AspNetCore.Mvc.ModelState": "1.0.0",
20+
"MyTested.AspNetCore.Mvc.Models": "1.0.0",
21+
"MyTested.AspNetCore.Mvc.Options": "1.0.0",
22+
"MyTested.AspNetCore.Mvc.Session": "1.0.0", // <---
23+
"MyTested.AspNetCore.Mvc.ViewActionResults": "1.0.0",
24+
"MusicStore": "*"
25+
},
26+
```
27+
28+
Adding this package will replace the default session services with scoped mocks, which are empty at the beginning of each test. It's quite easy to test with them. Let's see! :)
29+
30+
We will test the **"AddToCart"** action in the **"ShoppingCartController"**. If you examine the method, you will see it calls **"ShoppingCart.GetCart"**, which creates a session entry containing the cart ID:
31+
32+
```c#
33+
// code skipped for brevity
34+
35+
var cartId = context.Session.GetString("Session");
36+
37+
if (cartId == null)
38+
{
39+
cartId = Guid.NewGuid().ToString();
40+
context.Session.SetString("Session", cartId);
41+
}
42+
43+
return cartId;
44+
45+
// code skipped for brevity
46+
```
47+
48+
Let's assert that if the session is initially empty, an entry with **"Session"** key should be added after the action invocation. Go to the **"ShoppingCartControllerTest"** class and add the following test:
49+
50+
```c#
51+
[Fact]
52+
public void AddToCartShouldPopulateSessionCartIfMissing()
53+
=> MyController<ShoppingCartController>
54+
.Instance()
55+
.WithDbContext(db => db
56+
.WithEntities(entities => entities
57+
.Add(new Album { AlbumId = 1 })))
58+
.Calling(c => c.AddToCart(1))
59+
.ShouldHave()
60+
.Session(session => session
61+
.ContainingEntryWithKey("Session"));
62+
```
63+
64+
Next, let's assert that the cart item is actually saved into the database. We will need to provide a custom shopping cart ID by using the **"WithSession"** method:
65+
66+
```c#
67+
[Fact]
68+
public void AddToCartShouldSaveTheAlbumsIntoDatabaseAndSession()
69+
=> MyController<ShoppingCartController>
70+
.Instance()
71+
.WithSession(session => session.WithEntry("Session", "TestCart")) // <---
72+
.WithDbContext(db => db
73+
.WithEntities(entities => entities
74+
.Add(new Album { AlbumId = 1 })))
75+
.Calling(c => c.AddToCart(1))
76+
.ShouldHave()
77+
.DbContext(db => db // <---
78+
.WithSet<CartItem>(cartItems => cartItems
79+
.Any(a => a.AlbumId == 1 && a.CartId == "TestCart")))
80+
.AndAlso()
81+
.ShouldReturn()
82+
.Redirect()
83+
.ToAction(nameof(ShoppingCartController.Index));
84+
```
85+
86+
## Cache
87+
88+
For the caching assertions, we will need **"MyTested.AspNetCore.Mvc.Caching"** as a dependency. Go and add it to the **"project.json"**:
89+
90+
```json
91+
"dependencies": {
92+
"dotnet-test-xunit": "2.2.0-*",
93+
"xunit": "2.2.0-*",
94+
"Moq": "4.6.38-*",
95+
"MyTested.AspNetCore.Mvc.Authentication": "1.0.0",
96+
"MyTested.AspNetCore.Mvc.Caching": "1.0.0", // <---
97+
"MyTested.AspNetCore.Mvc.Controllers": "1.0.0",
98+
"MyTested.AspNetCore.Mvc.DependencyInjection": "1.0.0",
99+
"MyTested.AspNetCore.Mvc.EntityFrameworkCore": "1.0.0",
100+
"MyTested.AspNetCore.Mvc.Http": "1.0.0",
101+
"MyTested.AspNetCore.Mvc.ModelState": "1.0.0",
102+
"MyTested.AspNetCore.Mvc.Models": "1.0.0",
103+
"MyTested.AspNetCore.Mvc.Options": "1.0.0",
104+
"MyTested.AspNetCore.Mvc.Session": "1.0.0",
105+
"MyTested.AspNetCore.Mvc.ViewActionResults": "1.0.0",
106+
"MusicStore": "*"
107+
},
108+
```
109+
110+
Since the package automatically replaces the default memory cache services with scoped mocks, we no longer need this code in the **"TestStartup"** class:
111+
112+
```c#
113+
services.ReplaceLifetime<IMemoryCache>(ServiceLifetime.Scoped);
114+
```
115+
116+
Remove the above line and rerun all tests to see them pass again. Remember! All scoped services reset their state for each test. The cache ones are not an exception.
117+
118+
Now, we are going to write three tests against the **"Index"** action in the **"HomeController"**:
119+
120+
```c#
121+
// code skipped for brevity
122+
123+
var cacheKey = "topselling";
124+
List<Album> albums;
125+
if (!cache.TryGetValue(cacheKey, out albums))
126+
{
127+
albums = await GetTopSellingAlbumsAsync(dbContext, 6);
128+
129+
if (albums != null && albums.Count > 0)
130+
{
131+
if (_appSettings.CacheDbResults)
132+
{
133+
cache.Set(cacheKey, albums, new MemoryCacheEntryOptions()
134+
.SetAbsoluteExpiration(TimeSpan.FromMinutes(10))
135+
.SetPriority(CacheItemPriority.High));
136+
}
137+
}
138+
}
139+
140+
return View(albums);
141+
142+
// code skipped for brevity
143+
```
144+
145+
Before we begin, add this helper method to the **"HomeControllerTest"** class:
146+
147+
```c#
148+
private static Album[] Albums
149+
{
150+
get
151+
{
152+
var genres = Enumerable.Range(1, 10).Select(n =>
153+
new Genre()
154+
{
155+
GenreId = n,
156+
Name = "Genre Name " + n,
157+
}).ToArray();
158+
159+
var artists = Enumerable.Range(1, 10).Select(n =>
160+
new Artist()
161+
{
162+
ArtistId = n + 1,
163+
Name = "Artist Name " + n,
164+
}).ToArray();
165+
166+
var albums = Enumerable.Range(1, 10).Select(n =>
167+
new Album()
168+
{
169+
Artist = artists[n - 1],
170+
ArtistId = n,
171+
Genre = genres[n - 1],
172+
GenreId = n,
173+
}).ToArray();
174+
175+
return albums;
176+
}
177+
}
178+
```
179+
180+
OK, let's assert! :)
181+
182+
First, we should test that no cache entries are saved if the **"CacheDbResults"** setting is set to **"false"**:
183+
184+
```c#
185+
[Fact]
186+
public void IndexShouldNotUseCacheIfOptionsDisableIt()
187+
=> MyController<HomeController>
188+
.Instance()
189+
.WithOptions(options => options
190+
.For<AppSettings>(settings => settings.CacheDbResults = false))
191+
.WithDbContext(db => db
192+
.WithEntities(entities => entities.AddRange(Albums)))
193+
.Calling(c => c.Index(
194+
From.Services<MusicStoreContext>(),
195+
From.Services<IMemoryCache>()))
196+
.ShouldHave()
197+
.NoMemoryCache(); // <---
198+
```
199+
200+
Unfortunately, the **"NoMemoryCache"** call will not work:
201+
202+
```c#
203+
When calling Index action in HomeController expected to have memory cache with no entries, but in fact it had some.
204+
```
205+
206+
Unfortunately with straightforward action debugging we may not see what exactly is going on because the **"CacheDbResults"** is indeed **"false"**. The reason of the error lies in [Entity Framework Core's code](https://github.com/aspnet/EntityFramework/blob/f9adcb64fdf668163377beb14251e67d17f60fa0/src/Microsoft.EntityFrameworkCore/EntityFrameworkServiceCollectionExtensions.cs#L150). It uses the same memory cache service as the web application and guess what! It caches the database query call. But how to debug such issues?
207+
208+
Easy! Add these lines:
209+
210+
```c#
211+
.WithDbContext(db => db
212+
.WithEntities(entities => entities.AddRange(Albums)))
213+
.Calling(c => c.Index(
214+
From.Services<MusicStoreContext>(),
215+
From.Services<IMemoryCache>()))
216+
.ShouldPassForThe<IServiceProvider>(services => // <--- add these instead of NoMemoryCache
217+
{
218+
var memoryCache = services.GetService<IMemoryCache>();
219+
}); // <--- and put a breakpoint here
220+
```
221+
222+
Running the debugger will allow you to examine the actual values in the cache.
223+
224+
<img src="/images/tutorial/nomemorycachedebug.jpn" alt="Debugging memory cache" />
225+
226+
One of the possible fixes is:
227+
228+
```c#
229+
.Calling(c => c.Index(
230+
From.Services<MusicStoreContext>(),
231+
From.Services<IMemoryCache>()))
232+
.ShouldPassForThe<IServiceProvider>(services => Assert.Null(services // <---
233+
.GetRequiredService<IMemoryCache>().Get("topselling")));
234+
```
235+
236+
You may use custom mocks too but it is not necessary.
237+
238+
Next, we should assert that with the **"CacheDbResults"** set to **"true"**, we should have saved cache entries from the database query:
239+
240+
```c#
241+
[Fact]
242+
public void IndexShouldSaveCorrectCacheEntriesIfOptionsEnableIt()
243+
=> MyController<HomeController>
244+
.Instance()
245+
.WithOptions(options => options
246+
.For<AppSettings>(settings => settings.CacheDbResults = true))
247+
.WithDbContext(db => db
248+
.WithEntities(entities => entities.AddRange(Albums)))
249+
.Calling(c => c.Index(
250+
From.Services<MusicStoreContext>(),
251+
From.Services<IMemoryCache>()))
252+
.ShouldHave()
253+
.MemoryCache(cache => cache // <---
254+
.ContainingEntry(entry => entry
255+
.WithKey("topselling")
256+
.WithPriority(CacheItemPriority.High)
257+
.WithAbsoluteExpirationRelativeToNow(TimeSpan.FromMinutes(10))
258+
.WithValueOfType<List<Album>>()
259+
.Passing(albums => albums.Count == 6)))
260+
.AndAlso()
261+
.ShouldReturn()
262+
.View()
263+
.WithModelOfType<List<Album>>()
264+
.Passing(albums => albums.Count == 6);
265+
```
266+
267+
Finally, we should validate that if the cache contains the albums entry, no database query should be called. We will use an empty database and assert the view model:
268+
269+
```c#
270+
[Fact]
271+
public void IndexShouldGetAlbumsFromCacheIfEntryExists()
272+
=> MyController<HomeController>
273+
.Instance()
274+
.WithOptions(options => options
275+
.For<AppSettings>(settings => settings.CacheDbResults = true))
276+
.WithMemoryCache(cache => cache
277+
.WithEntry("topselling", Albums.Take(6).ToList()))
278+
.Calling(c => c.Index(
279+
From.Services<MusicStoreContext>(),
280+
From.Services<IMemoryCache>()))
281+
.ShouldReturn()
282+
.View()
283+
.WithModelOfType<List<Album>>()
284+
.Passing(albums => albums.Count == 6);
285+
```
286+
287+
This way we validate entries are retrieved from cache and from the actual database (which is empty for this particular test).
288+
289+
## Section summary
290+
291+
Session and cache are fun. By using the **"WithSession"** and **"WithMemoryCache"** methods, you prepare the values to be available during the action call. On the other side, the **"ShouldHave().MemoryCache()"** and **"ShouldHave().Session()"** extensions allows you to assert their values after the action call. The same principle applies to the [ViewBag, ViewData & TempData](/tutorial/viewbagviewdatatempdata.html).

docs/_docfx/tutorial/toc.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@
2121
href: licensing.md
2222
- name: Attributes
2323
href: attributes.md
24+
- name: Exceptions
25+
href: exceptions.md
2426
- name: Options
2527
href: options.md
2628
- name: Session & Cache
27-
href: sessioncache.md
29+
href: sessioncache.md
30+
- name: ViewBag, ViewData & TempData
31+
href: viewbagviewdatatempdata.md

docs/_docfx/tutorial/viewbagviewdatatempdata.md

Whitespace-only changes.
180 KB
Loading

docs/manifest.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)