|
| 1 | +# Various Helpers |
| 2 | + |
| 3 | +This section will cover various test helpers which make writing tests faster and easier! :) |
| 4 | + |
| 5 | +## The Helpers package |
| 6 | + |
| 7 | +You may add the **"MyTested.AspNetCore.Mvc.Helpers"** package which adds extension methods to the fluent API. But let's do something else. I believe you got tired of all these packages so we will delete them and add only the one that rules them all: |
| 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.Universe": "1.0.0", // <--- |
| 15 | + "MusicStore": "*" |
| 16 | +}, |
| 17 | +``` |
| 18 | + |
| 19 | +The **"Universe"** package combines all other packages. |
| 20 | + |
| 21 | +One of the helpers is allowing us to assert controller action results with a single method call instead of multiple ones. For example we may have these lines of code: |
| 22 | + |
| 23 | +```c# |
| 24 | +.ShouldReturn() |
| 25 | +.View() |
| 26 | +.WithModel(model); |
| 27 | +``` |
| 28 | + |
| 29 | +They can be written like this: |
| 30 | + |
| 31 | +```c# |
| 32 | +.ShouldReturn() |
| 33 | +.View(model) |
| 34 | +``` |
| 35 | + |
| 36 | +We have such test in **"AccountControllerTest"**: |
| 37 | + |
| 38 | +```c# |
| 39 | +[Fact] |
| 40 | +public void LoginShouldReturnViewWithSameModelWithInvalidLoginViewModel() |
| 41 | +{ |
| 42 | + var model = new LoginViewModel |
| 43 | + { |
| 44 | + |
| 45 | + Password = "invalid" |
| 46 | + }; |
| 47 | + |
| 48 | + var redirectUrl = "/Test/Url"; |
| 49 | + |
| 50 | + MyController<AccountController> |
| 51 | + .Instance() |
| 52 | + .Calling(c => c.Login(model, redirectUrl)) |
| 53 | + .ShouldHave() |
| 54 | + .ModelState(modelState => modelState |
| 55 | + .ContainingError(string.Empty) |
| 56 | + .ThatEquals("Invalid login attempt.")) |
| 57 | + .AndAlso() |
| 58 | + .ShouldReturn() |
| 59 | + .View(model); // <--- |
| 60 | +} |
| 61 | +``` |
| 62 | + |
| 63 | +## Global MyMvc class |
| 64 | + |
| 65 | +The **"MyTested.AspNetCore.Mvc"** package introduced another static class named **"MyMvc"**. Instead of these: |
| 66 | + |
| 67 | +```c# |
| 68 | +MyController<TestController>.Instance() |
| 69 | +MyViewComponent<TestViewComponent>.Instance() |
| 70 | +MyRouting.Configuration() |
| 71 | +``` |
| 72 | + |
| 73 | +You can use: |
| 74 | + |
| 75 | +```c# |
| 76 | +MyMvc.Controller<TestController>() |
| 77 | +MyMvc.ViewComponent<TestViewComponent>() |
| 78 | +MyMvc.Routing() |
| 79 | +``` |
| 80 | + |
| 81 | +It is up to you! |
| 82 | + |
| 83 | +## Model state expression-based assertions |
| 84 | + |
| 85 | +Instead of string-based model state assertions line in the **"ManageControllerTest"**: |
| 86 | + |
| 87 | +```c# |
| 88 | +.ShouldHave() |
| 89 | +.ModelState(modelState => modelState |
| 90 | + .ContainingError(nameof(ChangePasswordViewModel.OldPassword)) |
| 91 | + .ThatEquals("The Current password field is required.") |
| 92 | + .AndAlso() |
| 93 | + .ContainingError(nameof(ChangePasswordViewModel.NewPassword)) |
| 94 | + .ThatEquals("The New password field is required.") |
| 95 | + .AndAlso() |
| 96 | + .ContainingNoError(nameof(ChangePasswordViewModel.ConfirmPassword))) |
| 97 | +``` |
| 98 | + |
| 99 | +You may use expression-based ones: |
| 100 | + |
| 101 | +```c# |
| 102 | +.ShouldHave() |
| 103 | +.ModelState(modelState => modelState |
| 104 | + .For<ChangePasswordViewModel>() |
| 105 | + .ContainingErrorFor(m => m.OldPassword) |
| 106 | + .ThatEquals("The Current password field is required.") |
| 107 | + .AndAlso() |
| 108 | + .ContainingErrorFor(m => m.NewPassword) |
| 109 | + .ThatEquals("The New password field is required.") |
| 110 | + .AndAlso() |
| 111 | + .ContainingNoErrorFor(m => m.ConfirmPassword)) |
| 112 | +``` |
| 113 | + |
| 114 | +## Expression-based route values |
| 115 | + |
| 116 | +Instead of testing for redirects by using multiple method calls like in the **"ManageControllerTest"**: |
| 117 | + |
| 118 | +```c# |
| 119 | +.ShouldReturn() |
| 120 | +.Redirect() |
| 121 | +.ToAction(nameof(ManageController.ManageLogins)) |
| 122 | +.ContainingRouteValues(new { Message = ManageController.ManageMessageId.Error }); |
| 123 | +``` |
| 124 | + |
| 125 | +You may use a single expression-based assertion call: |
| 126 | + |
| 127 | +```c# |
| 128 | +.ShouldReturn() |
| 129 | +.Redirect() |
| 130 | +.To<ManageController>(c => c.ManageLogins(ManageController.ManageMessageId.Error)) |
| 131 | +``` |
| 132 | + |
| 133 | +## Resolving route data |
| 134 | + |
| 135 | +Let's test the **"AddressAndPayment"** action in the **"AccountController"**. We will validate for correct redirection: |
| 136 | + |
| 137 | +```c# |
| 138 | +[Fact] |
| 139 | +public void AddressAndPaymentShouldRerurnRedirectWithValidData() |
| 140 | + => MyController<CheckoutController> |
| 141 | + .Instance() |
| 142 | + .WithHttpRequest(request => request |
| 143 | + .WithFormField("PromoCode", "FREE")) |
| 144 | + .WithSession(session => session |
| 145 | + .WithEntry("Session", "TestCart")) |
| 146 | + .WithAuthenticatedUser() |
| 147 | + .WithDbContext(db => db |
| 148 | + .WithEntities(entities => |
| 149 | + { |
| 150 | + var album = new Album { AlbumId = 1, Price = 10 }; |
| 151 | + |
| 152 | + var cartItem = new CartItem |
| 153 | + { |
| 154 | + Count = 1, |
| 155 | + CartId = "TestCart", |
| 156 | + AlbumId = 1, |
| 157 | + Album = album |
| 158 | + }; |
| 159 | + |
| 160 | + entities.Add(album); |
| 161 | + entities.Add(cartItem); |
| 162 | + })) |
| 163 | + .WithoutValidation() |
| 164 | + .Calling(c => c.AddressAndPayment( |
| 165 | + From.Services<MusicStoreContext>(), |
| 166 | + new Order { OrderId = 1 }, |
| 167 | + With.No<CancellationToken>())) |
| 168 | + .ShouldReturn() |
| 169 | + .Redirect() |
| 170 | + .To<CheckoutController>(c => c.Complete(With.Any<MusicStoreContext>(), 1)); |
| 171 | +``` |
| 172 | + |
| 173 | +Running this test will give us the following strange error message: |
| 174 | + |
| 175 | +``` |
| 176 | +When calling AddressAndPayment action in CheckoutController expected redirect result to have resolved location to '/Checkout/Complete/1', but in fact received '/Home/Complete/1'. |
| 177 | +``` |
| 178 | + |
| 179 | +The problem is that the request path is empty which results to the action route data being invalid. For that reason we are receiving wrong redirection location. The fix is easy - just call **"WithRouteData"**: |
| 180 | + |
| 181 | +```c# |
| 182 | +[Fact] |
| 183 | +public void AddressAndPaymentShouldRerurnRedirectWithValidData() |
| 184 | + => MyController<CheckoutController> |
| 185 | + .Instance() |
| 186 | + .WithHttpRequest(request => request |
| 187 | + .WithFormField("PromoCode", "FREE")) |
| 188 | + .WithSession(session => session |
| 189 | + .WithEntry("Session", "TestCart")) |
| 190 | + .WithAuthenticatedUser() |
| 191 | + .WithRouteData() // <--- |
| 192 | + .WithDbContext(db => db |
| 193 | + .WithEntities(entities => |
| 194 | + { |
| 195 | + var album = new Album { AlbumId = 1, Price = 10 }; |
| 196 | + |
| 197 | + var cartItem = new CartItem |
| 198 | + { |
| 199 | + Count = 1, |
| 200 | + CartId = "TestCart", |
| 201 | + AlbumId = 1, |
| 202 | + Album = album |
| 203 | + }; |
| 204 | + |
| 205 | + entities.Add(album); |
| 206 | + entities.Add(cartItem); |
| 207 | + })) |
| 208 | + .WithoutValidation() |
| 209 | + .Calling(c => c.AddressAndPayment( |
| 210 | + From.Services<MusicStoreContext>(), |
| 211 | + new Order { OrderId = 1 }, |
| 212 | + With.No<CancellationToken>())) |
| 213 | + .ShouldReturn() |
| 214 | + .Redirect() |
| 215 | + .To<CheckoutController>(c => c.Complete(With.Any<MusicStoreContext>(), 1)); |
| 216 | +``` |
| 217 | + |
| 218 | +The method call will resolve all the route values for you. The reason it is not done by default is because of performance considerations. You may manually provide route data values, if you need: |
| 219 | + |
| 220 | +```c# |
| 221 | +.WithRouteData(new { controller = "Checkout" }) |
| 222 | +``` |
| 223 | + |
| 224 | +The above issue may show when testing for the **"Created"** and **"Redirect"** action results. In some cases, the testing framework may catch the error and suggest you the following error: |
| 225 | + |
| 226 | +``` |
| 227 | +Route values are not present in the method call but are needed for successful pass of this test case. Consider calling 'WithRouteData' on the component builder to resolve them from the provided lambda expression or set the HTTP request path by using 'WithHttpRequest'. |
| 228 | +``` |
| 229 | + |
| 230 | +For example the test bellow with show the above message, if **"WithRouteData"** is not called because the **"ExternalLogin"** action uses **"IUrlHelper"**. |
| 231 | + |
| 232 | +```c# |
| 233 | +[Fact] |
| 234 | +public void ExternalLoginShouldReturnCorrectResult() |
| 235 | + => MyController<AccountController> |
| 236 | + .Instance() |
| 237 | + .WithRouteData() |
| 238 | + .Calling(c => c.ExternalLogin("TestProvider", "TestReturnUrl")) |
| 239 | + .ShouldReturn() |
| 240 | + .Challenge(); |
| 241 | +``` |
| 242 | + |
| 243 | +## Additional attribute validations |
| 244 | + |
| 245 | +Some packages expose additional attribute validations. For example adding the **"Microsoft.AspNetCore.Mvc.ViewFeatures"**, will add the option to test the **"AntiForgeryTokenAttribute"**. Instead of: |
| 246 | + |
| 247 | +```c# |
| 248 | +.ContainingAttributeOfType<ValidateAntiForgeryTokenAttribute>() |
| 249 | +``` |
| 250 | + |
| 251 | +You can use: |
| 252 | + |
| 253 | +```c# |
| 254 | +.ValidatingAntiForgeryToken() |
| 255 | +``` |
| 256 | + |
| 257 | +## Section summary |
| 258 | + |
| 259 | +With this section we finished with the most important parts of the fluent assertion API. Few non-syntax related topics left and you are free to go. Go to the [Organizing Tests](/tutorial/organizingtests.html) section to see the various different ways you can write your tests! |
0 commit comments