Skip to content

Commit c228cca

Browse files
committed
Added Extension Methods to the tutorial (#132)
1 parent d333774 commit c228cca

File tree

7 files changed

+424
-3
lines changed

7 files changed

+424
-3
lines changed

docs/_docfx/toc.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,4 @@
99
- name: Troubleshoot
1010
href: troubleshoot/
1111
- name: About
12-
href: http://mytestedasp.net/core/mvc
12+
href: http://mytestedasp.net/core/mvc?r=docs
834 KB
Binary file not shown.

docs/_docfx/tutorial/exceptions.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,25 @@ public void AddToCartShouldThrowExceptionWithInvalidId()
2828

2929
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.
3030

31+
Unfortunately, if we run the above test with .NET CLI by using **"dotnet test"**, we will receive a huge fail on **"net451"** because the **"AggregateException"** exception message is just "One or more errors occurred.". Possible fixes include removing the **"WithMessage"** call or just adding a compiler directive for the different frameworks like so:
32+
33+
```c#
34+
[Fact]
35+
public void AddToCartShouldThrowExceptionWithInvalidId()
36+
=> MyController<ShoppingCartController>
37+
.Instance()
38+
.Calling(c => c.AddToCart(1))
39+
.ShouldThrow()
40+
.AggregateException()
41+
.ContainingInnerExceptionOfType<InvalidOperationException>()
42+
.WithMessage()
43+
#if NETCOREAPP1_0
44+
.Containing("Sequence contains no elements");
45+
#else
46+
.Containing("One or more errors occurred");
47+
#endif
48+
```
49+
3150
## Uncaught exceptions
3251

3352
Let's see what happens if the action throws an exception and we try to validate a normal action result, for example:
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# Extension Methods
2+
3+
In this final section of the tutorial we will learn how to extend the functionality of My Tested ASP.NET Core MVC.
4+
5+
## Extending existing test builders
6+
7+
Let's add an extension method which allows as to assert collection models like:
8+
9+
```c#
10+
.WithModelOfType<List<Album>>()
11+
.Passing(albums => albums.Count == 6)
12+
```
13+
14+
But on a single line:
15+
16+
```c#
17+
.WithCollectionModelOfType<Albums>(albums => albums.Count == 6)
18+
```
19+
20+
If we take a look at the **"WithModelOfType"** signature, we will see it is an extension method to the **"IBaseTestBuilderWithResponseModel"** interface:
21+
22+
```c#
23+
public static IAndModelDetailsTestBuilder<TModel> WithModelOfType<TModel>(this IBaseTestBuilderWithResponseModel builder);
24+
```
25+
26+
We will extend the same interface so that all action results with models are extended. Create a folder **"Extensions"** in the test project and add **"ResponseModelExtensions"** class containing the code below.
27+
28+
```c#
29+
using MyTested.AspNetCore.Mvc.Builders.Base;
30+
using MyTested.AspNetCore.Mvc.Builders.Contracts.Base;
31+
using MyTested.AspNetCore.Mvc.Exceptions;
32+
using MyTested.AspNetCore.Mvc.Utilities;
33+
using MyTested.AspNetCore.Mvc.Utilities.Extensions;
34+
using System;
35+
using System.Collections.Generic;
36+
37+
public static class ResponseModelExtensions
38+
{
39+
public static IBaseTestBuilderWithComponent WithCollectionModelOfType<TModel>(
40+
this IBaseTestBuilderWithResponseModel builder, // base test builder we are extending
41+
Func<ICollection<TModel>, bool> predicate = null) // optional predicate the model should pass
42+
{
43+
var actualBuilder = (BaseTestBuilderWithResponseModel)builder; // cast to the actual class behind the interface
44+
var modelCollection = actualBuilder.GetActualModel<ICollection<TModel>>(); // helper method validating the model type
45+
46+
if (predicate != null && !predicate(modelCollection)) // execute the predicate if exists
47+
{
48+
var testContext = actualBuilder.TestContext; // get the current test context
49+
50+
throw new ResponseModelAssertionException(string.Format( // throw exception for invalid predicate
51+
"When calling {0} in {1} expected response model collection of {2} to pass the given predicate, but it failed.",
52+
testContext.MethodName,
53+
testContext.Component.GetName(),
54+
typeof(TModel).ToFriendlyTypeName()));
55+
}
56+
57+
return actualBuilder; // return the same test builder
58+
}
59+
}
60+
```
61+
62+
Let's break it down and explain the most important parts of this extension method:
63+
64+
- **"IBaseTestBuilderWithComponent"** is a base interface containing **"ShouldPassFor"** methods allowing you to continue the fluent API.
65+
- **"actualBuilder"** is a variable holding the actual class behind the **"IBaseTestBuilderWithResponseModel"** interface. The class' name is the same as the interface but without the 'I' in front of it. After the casting you will receive more functionality you can use - methods like the **"GetActualModel"** used above. Their purpose is to help you execute the test but should not be part of the actual fluent API.
66+
- The **"TestContext"** is part of every single test builder class. It contains information about the currently executed test. For example in the scope of a controller test, the **"Component"** property will contain the controller instance and the **"MethodName"** property will contain name of the tested action.
67+
- The **"GetName"** and **"ToFriendlyTypeName"** extension methods can be used to format various display names.
68+
69+
## Using existing methods
70+
71+
Let's add new testing functionality based on existing methods. For example instead of this call:
72+
73+
```c#
74+
.ShouldHave()
75+
.Attributes(attributes => attributes
76+
.SpecifyingArea("Admin"))
77+
```
78+
79+
We remove the magic string:
80+
81+
```c#
82+
.ShouldHave()
83+
.Attributes(attributes => attributes
84+
.SpecifyingAdminArea())
85+
```
86+
87+
We need to extend the **"IControllerActionAttributesTestBuilder<TAttributesTestBuilder>"** interface. Add **"ControllerActionAttributeExtensions"** class with the following code in it:
88+
89+
```c#
90+
using MyTested.AspNetCore.Mvc.Builders.Contracts.Attributes;
91+
92+
public static class ControllerActionAttributeExtensions
93+
{
94+
public static TAttributesTestBuilder SpecifyingAdminArea<TAttributesTestBuilder>(
95+
this IControllerActionAttributesTestBuilder<TAttributesTestBuilder> builder)
96+
where TAttributesTestBuilder : IBaseAttributesTestBuilder<TAttributesTestBuilder>
97+
=> builder.SpecifyingArea("Admin");
98+
}
99+
```
100+
101+
## Final words
102+
103+
We this section we conclude the tutorial successfully! The final source code with all tests is available [HERE](https://raw.githubusercontent.com/ivaylokenov/MyTested.AspNetCore.Mvc/development/docs/_docfx/tutorial/MusicStore-Tutorial-Final.zip). But before we say goodbye let's rebuild and rerun all tests again just for the sake of it. Do the same with the CLI by running **"dotnet test"**. Everything passes? Good!
104+
105+
Hopefully you fell in love with My Tested ASP.NET Core MVC and if not - ideas and suggestions are [always welcome](https://mytestedasp.net/#contact)!
106+
107+
Thank you for reading the whole tutorial and have fun testing your web applications! :)
Lines changed: 224 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,224 @@
1-
# Organizing Tests
1+
# Organizing Tests
2+
3+
This section will cover the various ways we can organize our tests in a class.
4+
5+
## The fluent style
6+
7+
This is the test style we used so far. For example let's take a look at our **"StoreControllerTest"** class:
8+
9+
```c#
10+
namespace MusicStore.Test.Controllers
11+
{
12+
using Microsoft.Extensions.Options;
13+
using Mocks;
14+
using Models;
15+
using Moq;
16+
using MusicStore.Controllers;
17+
using MyTested.AspNetCore.Mvc;
18+
using System.Collections.Generic;
19+
using Xunit;
20+
21+
public class StoreControllerTest
22+
{
23+
[Fact]
24+
public void IndexShouldReturnViewWithGenres()
25+
=> MyController<StoreController>
26+
.Instance()
27+
.WithDbContext(dbContext => dbContext
28+
.WithEntities(entities => entities.AddRange(
29+
new Genre { Name = "FirstGenre" },
30+
new Genre { Name = "SecondGenre" })))
31+
.Calling(c => c.Index())
32+
.ShouldReturn()
33+
.View()
34+
.WithModelOfType<List<Genre>>()
35+
.Passing(model => model.Count == 2);
36+
37+
[Fact]
38+
public void BrowseShouldReturnNotFoundWithInvalidGenre()
39+
=> MyController<StoreController>
40+
.Instance(new StoreController(
41+
MockProvider.MusicStoreContext,
42+
Mock.Of<IOptions<AppSettings>>()))
43+
.Calling(c => c.Browse("Invalid"))
44+
.ShouldReturn()
45+
.NotFound();
46+
47+
[Fact]
48+
public void BrowseShouldReturnCorrectViewModelWithValidGenre()
49+
=> MyController<StoreController>
50+
.Instance()
51+
.WithServices(
52+
MockProvider.MusicStoreContext,
53+
Mock.Of<IOptions<AppSettings>>())
54+
.Calling(c => c.Browse("Rap"))
55+
.ShouldReturn()
56+
.View()
57+
.WithModelOfType<Genre>()
58+
.Passing(model => model.GenreId == 2);
59+
}
60+
}
61+
```
62+
63+
## The classic AAA style
64+
65+
You can split the fluent API in the classic Arrange-Act-Assert style:
66+
67+
```c#
68+
[Fact]
69+
public void IndexShouldReturnViewWithGenres()
70+
{
71+
// Arrange
72+
var controller = MyController<StoreController>
73+
.Instance()
74+
.WithDbContext(dbContext => dbContext
75+
.WithEntities(entities => entities.AddRange(
76+
new Genre { Name = "FirstGenre" },
77+
new Genre { Name = "SecondGenre" })));
78+
79+
// Act
80+
var execution = controller.Calling(c => c.Index());
81+
82+
// Assert
83+
execution
84+
.ShouldReturn()
85+
.View()
86+
.WithModelOfType<List<Genre>>()
87+
.Passing(model => model.Count == 2);
88+
}
89+
```
90+
91+
## The fluent AAA style
92+
93+
Or just mark the fluent API with comments:
94+
95+
```c#
96+
[Fact]
97+
public void IndexShouldReturnViewWithGenres()
98+
=> MyController<StoreController>
99+
// Arrange
100+
.Instance()
101+
.WithDbContext(dbContext => dbContext
102+
.WithEntities(entities => entities.AddRange(
103+
new Genre { Name = "FirstGenre" },
104+
new Genre { Name = "SecondGenre" })))
105+
// Act
106+
.Calling(c => c.Index())
107+
// Assert
108+
.ShouldReturn()
109+
.View()
110+
.WithModelOfType<List<Genre>>()
111+
.Passing(model => model.Count == 2);
112+
```
113+
114+
## The inheriting style
115+
116+
You may inherit the **"MyController"** class to skip writing it in every single test:
117+
118+
```c#
119+
namespace MusicStore.Test.Controllers
120+
{
121+
using Microsoft.Extensions.Options;
122+
using Mocks;
123+
using Models;
124+
using Moq;
125+
using MusicStore.Controllers;
126+
using MyTested.AspNetCore.Mvc;
127+
using System.Collections.Generic;
128+
using Xunit;
129+
130+
public class StoreControllerTest : MyController<StoreController> // <---
131+
{
132+
[Fact]
133+
public void IndexShouldReturnViewWithGenres()
134+
=> this
135+
.WithDbContext(dbContext => dbContext
136+
.WithEntities(entities => entities.AddRange(
137+
new Genre { Name = "FirstGenre" },
138+
new Genre { Name = "SecondGenre" })))
139+
.Calling(c => c.Index())
140+
.ShouldReturn()
141+
.View()
142+
.WithModelOfType<List<Genre>>()
143+
.Passing(model => model.Count == 2);
144+
145+
[Fact]
146+
public void BrowseShouldReturnNotFoundWithInvalidGenre()
147+
=> Instance(new StoreController(
148+
MockProvider.MusicStoreContext,
149+
Mock.Of<IOptions<AppSettings>>()))
150+
.Calling(c => c.Browse("Invalid"))
151+
.ShouldReturn()
152+
.NotFound();
153+
154+
[Fact]
155+
public void BrowseShouldReturnCorrectViewModelWithValidGenre()
156+
=> this
157+
.WithServices(
158+
MockProvider.MusicStoreContext,
159+
Mock.Of<IOptions<AppSettings>>())
160+
.Calling(c => c.Browse("Rap"))
161+
.ShouldReturn()
162+
.View()
163+
.WithModelOfType<Genre>()
164+
.Passing(model => model.GenreId == 2);
165+
}
166+
}
167+
```
168+
169+
**NOTE:** To run the above tests asynchronously, the test runner should instantiate the **"StoreControllerTest"** class for every single test. This is the default behaviour of xUnit so you should not experience any issues, if you do not alter its collection parallelism. You can also avoid a race condition if you replace the **"this"** keyword with **"Instance"**:
170+
171+
```c#
172+
namespace MusicStore.Test.Controllers
173+
{
174+
using Microsoft.Extensions.Options;
175+
using Mocks;
176+
using Models;
177+
using Moq;
178+
using MusicStore.Controllers;
179+
using MyTested.AspNetCore.Mvc;
180+
using System.Collections.Generic;
181+
using Xunit;
182+
183+
public class StoreControllerTest : MyController<StoreController>
184+
{
185+
[Fact]
186+
public void IndexShouldReturnViewWithGenres()
187+
=> Instance() // <---
188+
.WithDbContext(dbContext => dbContext
189+
.WithEntities(entities => entities.AddRange(
190+
new Genre { Name = "FirstGenre" },
191+
new Genre { Name = "SecondGenre" })))
192+
.Calling(c => c.Index())
193+
.ShouldReturn()
194+
.View()
195+
.WithModelOfType<List<Genre>>()
196+
.Passing(model => model.Count == 2);
197+
198+
[Fact]
199+
public void BrowseShouldReturnNotFoundWithInvalidGenre()
200+
=> Instance(new StoreController(
201+
MockProvider.MusicStoreContext,
202+
Mock.Of<IOptions<AppSettings>>()))
203+
.Calling(c => c.Browse("Invalid"))
204+
.ShouldReturn()
205+
.NotFound();
206+
207+
[Fact]
208+
public void BrowseShouldReturnCorrectViewModelWithValidGenre()
209+
=> Instance() // <---
210+
.WithServices(
211+
MockProvider.MusicStoreContext,
212+
Mock.Of<IOptions<AppSettings>>())
213+
.Calling(c => c.Browse("Rap"))
214+
.ShouldReturn()
215+
.View()
216+
.WithModelOfType<Genre>()
217+
.Passing(model => model.GenreId == 2);
218+
}
219+
}
220+
```
221+
222+
## Section summary
223+
224+
Of course, you can always combine two or more of the above style as long as your code is consistent. Now, let's take a look at the framework's [Test Configuration](/tutorial/testconfig.html)!

0 commit comments

Comments
 (0)