|
| 1 | +# Writing Blazor Component tests in C |
| 2 | + |
| 3 | +In the following examples, the terminology **component under test** (abbreviated CUT) is used to mean the component that is the target of the test. The examples below use the `Shouldly` assertion library as well. If you prefer not to use that just replace the assertions with the ones from your own favorite assertion library. |
| 4 | + |
| 5 | +All examples can be found in the [CodeOnlyTests](../sample/tests/CodeOnlyTests) folder in the [Sample project](../sample/). |
| 6 | + |
| 7 | +1. [Creating new test classes](creating-new-test-classes) |
| 8 | +2. [Testing components without parameters](testing-components-without-parameters) |
| 9 | +3. [Testing components with regular parameters](testing-components-with-regular-parameters) |
| 10 | +4. [Testing components with child content](testing-components-with-child-content) |
| 11 | + |
| 12 | +## Creating new test classes |
| 13 | + |
| 14 | +All test classes are currently expected to inherit from `ComponentTestFixture`, which contains all the logic for rendering components and correctly dispose of renderers and HTML parsers after each test. For example: |
| 15 | + |
| 16 | +```csharp |
| 17 | +public class MyComponentTest : ComponentTestFixture |
| 18 | +{ |
| 19 | + [Fact] |
| 20 | + public void MyFirstTest() |
| 21 | + { |
| 22 | + // ... |
| 23 | + } |
| 24 | +} |
| 25 | +``` |
| 26 | + |
| 27 | +## Testing components without parameters |
| 28 | + |
| 29 | +The following unit-tests verifies that the [Counter.razor](../sample/src/Pages/Counter.razor) component behaves correctly. Here is the source for the Counter component: |
| 30 | + |
| 31 | +```razor |
| 32 | +@page "/counter" |
| 33 | +
|
| 34 | +<h1>Counter</h1> |
| 35 | +
|
| 36 | +<p> |
| 37 | + Current count: @currentCount |
| 38 | +</p> |
| 39 | +
|
| 40 | +<button class="btn btn-primary" @onclick="IncrementCount">Click me</button> |
| 41 | +
|
| 42 | +@code { |
| 43 | + int currentCount = 0; |
| 44 | +
|
| 45 | + void IncrementCount() |
| 46 | + { |
| 47 | + currentCount++; |
| 48 | + } |
| 49 | +} |
| 50 | +``` |
| 51 | + |
| 52 | +These are the unit tests: |
| 53 | + |
| 54 | +```csharp |
| 55 | +public class CounterTest : ComponentTestFixture |
| 56 | +{ |
| 57 | + [Fact] |
| 58 | + public void InitialHtmlIsCorrect() |
| 59 | + { |
| 60 | + // Arrange - renders the Counter component |
| 61 | + var cut = RenderComponent<Counter>(); |
| 62 | + |
| 63 | + // Assert |
| 64 | + // Here we specify expected HTML from CUT. |
| 65 | + var expectedHtml = @"<h1>Counter</h1> |
| 66 | + <p>Current count: 0</p> |
| 67 | + <button class=""btn-primary btn"">Click me</button>"; |
| 68 | + |
| 69 | + // Here we use the HTML diffing library to assert that the rendered HTML |
| 70 | + // from CUT is semantically the same as the expected HTML string above. |
| 71 | + cut.ShouldBe(expectedHtml); |
| 72 | + } |
| 73 | + |
| 74 | + [Fact] |
| 75 | + public void ClickingButtonIncreasesCountStrict() |
| 76 | + { |
| 77 | + // Arrange - renders the Counter component |
| 78 | + var cut = RenderComponent<Counter>(); |
| 79 | + |
| 80 | + // Act |
| 81 | + // Use a Find to query the rendered DOM tree and find the button element |
| 82 | + // and trigger the @onclick event handler by calling Click |
| 83 | + cut.Find("button").Click(); |
| 84 | + |
| 85 | + // Assert |
| 86 | + // GetChangesSinceFirstRender returns list of differences since the first render, |
| 87 | + // in which we assert that there should only be one change, a text change where |
| 88 | + // the new value is provided to the ShouldHaveSingleTextChange assert method. |
| 89 | + cut.GetChangesSinceFirstRender().ShouldHaveSingleTextChange("Current count: 1"); |
| 90 | + |
| 91 | + // Repeat the above steps to ensure that counter works for multiple clicks |
| 92 | + cut.Find("button").Click(); |
| 93 | + cut.GetChangesSinceFirstRender().ShouldHaveSingleTextChange("Current count: 2"); |
| 94 | + } |
| 95 | + |
| 96 | + [Fact] |
| 97 | + public void ClickingButtonIncreasesCountTargeted() |
| 98 | + { |
| 99 | + // Arrange - renders the Counter component |
| 100 | + var cut = RenderComponent<Counter>(); |
| 101 | + |
| 102 | + // Act |
| 103 | + // Use a Find to query the rendered DOM tree and find the button element |
| 104 | + // and trigger the @onclick event handler by calling Click |
| 105 | + cut.Find("button").Click(); |
| 106 | + |
| 107 | + // Assert |
| 108 | + // Use a Find to query the rendered DOM tree and find the paragraph element |
| 109 | + // and assert that its text content is the expected (calling Trim first to remove insignificant whitespace) |
| 110 | + cut.Find("p").TextContent.Trim().ShouldBe("Current count: 1"); |
| 111 | + |
| 112 | + // Repeat the above steps to ensure that counter works for multiple clicks |
| 113 | + cut.Find("button").Click(); |
| 114 | + cut.Find("p").TextContent.Trim().ShouldBe("Current count: 2"); |
| 115 | + } |
| 116 | +} |
| 117 | +``` |
| 118 | + |
| 119 | +A few things worth noting about the tests above: |
| 120 | + |
| 121 | +1. `InitialHtmlIsCorrect` uses the `ShouldBe` method that performs a semantic comparison of the generated HTML from CUT and the expected HTML string. That ensures that insignificant whitespace doesn't give false positives, among other things. |
| 122 | + |
| 123 | +2. The "**strict**" test (`ClickingButtonIncreasesCountStrict`) and the "**targeted**" test (`ClickingButtonIncreasesCountTargeted`) takes two different approaches to verifying CUT renders the expected output: |
| 124 | + |
| 125 | + - The **strict** version generates a diff between the initial rendered HTML and the rendered HTML after the button click, and then asserts that the compare result only contains the expected change. |
| 126 | + - The **targeted** version finds the `<p>` element expect to have changed, and asserts against its text content. |
| 127 | + |
| 128 | + With the _targeted_ version, we cannot guarantee that there are not other changes in other places of the rendered HTML, if that is a concern, use the strict style. If it is not, then the targeted style can lead to simpler test. |
| 129 | + |
| 130 | +## Testing components with regular parameters |
| 131 | + |
| 132 | +In the following tests we will pass regular parameters to a component under test, e.g. `[Parameter] public SomeType PropName { get; set; }` style properties, where `SomeType` **is not** a `RenderFragment` or a `EventCallback` type. |
| 133 | + |
| 134 | +The component under test will be the [Aside.razor](../sample/src/Components/Aside.razor) component, which looks like this: |
| 135 | + |
| 136 | +```cshtml |
| 137 | +<aside @attributes="Attributes"> |
| 138 | + @if (Header is { }) |
| 139 | + { |
| 140 | + <header>@Header</header> |
| 141 | + } |
| 142 | + @ChildContent |
| 143 | +</aside> |
| 144 | +@code { |
| 145 | + [Parameter(CaptureUnmatchedValues = true)] |
| 146 | + public IReadOnlyDictionary<string, object>? Attributes { get; set; } |
| 147 | +
|
| 148 | + [Parameter] public string? Header { get; set; } |
| 149 | +
|
| 150 | + [Parameter] public RenderFragment? ChildContent { get; set; } |
| 151 | +} |
| 152 | +``` |
| 153 | + |
| 154 | +Here is a test: |
| 155 | + |
| 156 | +```csharp |
| 157 | +public class AsideTest : ComponentTestFixture |
| 158 | +{ |
| 159 | + [Fact(DisplayName = "Aside should render header and additional parameters correctly")] |
| 160 | + public void Test001() |
| 161 | + { |
| 162 | + // Arrange |
| 163 | + var header = "Hello testers"; |
| 164 | + var cssClass = "some-class"; |
| 165 | + |
| 166 | + // Act - render the Aside component with two parameters (passed as pairs of name, value tuples). |
| 167 | + // Note the use of the nameof operator to get the name of the Header parameter. This |
| 168 | + // helps keeps the test passing if the name of the parameter is refactored. |
| 169 | + // |
| 170 | + // This is equivalent to the follow Razor code: |
| 171 | + // |
| 172 | + // <Aside Header="Hello testers" class="some-class"> |
| 173 | + // </Aside> |
| 174 | + var cut = RenderComponent<Aside>( |
| 175 | + (nameof(Aside.Header), header), |
| 176 | + ("class", cssClass) |
| 177 | + ); |
| 178 | + |
| 179 | + // Assert - verify that the rendered HTML from the Aside component matches the expected output. |
| 180 | + cut.ShouldBe($@"<aside class=""{cssClass}""><header>{header}</header></aside>"); |
| 181 | + } |
| 182 | +} |
| 183 | +``` |
| 184 | + |
| 185 | +In the test above, we use an overload of the `RenderComponent<TComponent>()` method, that allow us to pass regular parameters as pairs of `(string name, object? value)`. |
| 186 | + |
| 187 | +As highlighted in the code, I recommend using the [`nameof`](https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/operators/nameof) to get the name of declared parameters from the component, so any changes to the name through refactoring automatically updates the test. |
| 188 | + |
| 189 | +The second parameter, `class` is explicitly declared in the `Aside` class. It is instead `Attributes` parameter, that captures all unmatched parameters. |
| 190 | + |
| 191 | +## Testing components with child content |
| 192 | + |
| 193 | +The `Aside` component listed in the previous section also has a `ChildContent` parameter, so lets add a few tests that passes markup and components to it through that. |
| 194 | + |
| 195 | +```csharp |
| 196 | +public class AsideTest : ComponentTestFixture |
| 197 | +{ |
| 198 | + [Fact(DisplayName = "Aside should render child markup content correctly")] |
| 199 | + public void Test002() |
| 200 | + { |
| 201 | + // Arrange |
| 202 | + var content = "<p>I like simple tests and I cannot lie</p>"; |
| 203 | + |
| 204 | + // Act |
| 205 | + // Act - render the Aside component with a child content parameter, |
| 206 | + // which is constructed through the ChildContent helper method. |
| 207 | + // |
| 208 | + // This is equivalent to the follow Razor code: |
| 209 | + // |
| 210 | + // <Aside> |
| 211 | + // <p>I like simple tests and I cannot lie</p> |
| 212 | + // </Aside> |
| 213 | + var cut = RenderComponent<Aside>( |
| 214 | + ChildContent(content) |
| 215 | + ); |
| 216 | + |
| 217 | + // Assert - verify that the rendered HTML from the Aside component matches the expected output. |
| 218 | + cut.ShouldBe($@"<aside>{content}</aside>"); |
| 219 | + } |
| 220 | + |
| 221 | + [Fact(DisplayName = "Aside should render a child component correctly")] |
| 222 | + public void Test003() |
| 223 | + { |
| 224 | + // Arrange - set up test data |
| 225 | + var outerAsideHeader = "Hello outside"; |
| 226 | + var nestedAsideHeader = "Hello inside"; |
| 227 | + |
| 228 | + // Act - render the Aside component, passing a header to it |
| 229 | + // and a component to its child content. The ChildContent helper |
| 230 | + // method will pass the parameters it is given to the nested Aside |
| 231 | + // component. |
| 232 | + // |
| 233 | + // This is equivalent to the follow Razor code: |
| 234 | + // |
| 235 | + // <Aside Header="Hello outside"> |
| 236 | + // <Aside Header="Hello inside"></Aside> |
| 237 | + // </Aside> |
| 238 | + var cut = RenderComponent<Aside>( |
| 239 | + (nameof(Aside.Header), outerAsideHeader), |
| 240 | + ChildContent<Aside>( |
| 241 | + (nameof(Aside.Header), nestedAsideHeader) |
| 242 | + ) |
| 243 | + ); |
| 244 | + |
| 245 | + // Assert - verify that the rendered HTML from the Aside component matches the expected output. |
| 246 | + cut.ShouldBe($@"<aside> |
| 247 | + <header>{outerAsideHeader}</header> |
| 248 | + <aside> |
| 249 | + <header>{nestedAsideHeader}</header> |
| 250 | + </aside> |
| 251 | + </aside>"); |
| 252 | + } |
| 253 | +} |
| 254 | +``` |
| 255 | + |
| 256 | +In `Test002` above we use the `ChildContent(...)` helper method to create a ChildContent parameter and pass that to the `Aside` component. The overload, `ChildContent<TComponent>(component params)`, used in `Test003`, allows us to create render fragment that will render a component (of type `TComponent`) with the specified parameters. The `ChildContent<TComponent>(...)` has the same parameter options as the `RenderComponent<TComponent>` method has. |
0 commit comments