Skip to content

Commit 52362e0

Browse files
committed
Updated docs
1 parent 88e73b2 commit 52362e0

File tree

8 files changed

+452
-48
lines changed

8 files changed

+452
-48
lines changed

docs/csharp-examples.md

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
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.

docs/readme.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ Examples are split into three sections, one for each style/declaration type.
5353

5454
## References
5555

56+
See source code documentation for now.
57+
58+
1. [Semantic HTML diffing options](html-diffing-options.md)
59+
5660
## Contribute
5761

5862
To get in touch, ask questions or provide feedback, you can:

sample/src/Components/Aside.razor

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<aside @attributes="Attributes">
2+
@if (Header is { })
3+
{
4+
<header>@Header</header>
5+
}
6+
@ChildContent
7+
</aside>
8+
@code {
9+
[Parameter(CaptureUnmatchedValues = true)]
10+
public IReadOnlyDictionary<string, object>? Attributes { get; set; }
11+
12+
[Parameter] public string? Header { get; set; }
13+
14+
[Parameter] public RenderFragment? ChildContent { get; set; }
15+
}

sample/src/Components/NoArgs.razor

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
1-
<h1>Hello world</h1>
1+
<h1 @attributes="Attributes">Hello world</h1>
2+
@code {
3+
[Parameter(CaptureUnmatchedValues = true)]
4+
public IReadOnlyDictionary<string, object>? Attributes { get; set; }
5+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Text;
5+
using System.Threading.Tasks;
6+
using Egil.RazorComponents.Testing.SampleApp.Components;
7+
using Xunit;
8+
9+
namespace Egil.RazorComponents.Testing.SampleApp.CodeOnlyTests.Components
10+
{
11+
public class AsideTest : ComponentTestFixture
12+
{
13+
[Fact(DisplayName = "Aside should render header and additional parameters correctly")]
14+
public void Test001()
15+
{
16+
// Arrange
17+
var header = "Hello testers";
18+
var cssClass = "some-class";
19+
20+
// Act - render the Aside component with two parameters (passed as pairs of name, value tuples).
21+
// Note the use of the nameof operator to get the name of the Header parameter. This
22+
// helps keeps the test passing if the name of the parameter is refactored.
23+
//
24+
// This is equivalent to the follow Razor code:
25+
//
26+
// <Aside Header="Hello testers" class="some-class">
27+
// </Aside>
28+
var cut = RenderComponent<Aside>(
29+
(nameof(Aside.Header), header),
30+
("class", cssClass)
31+
);
32+
33+
// Assert - verify that the rendered HTML from the Aside component matches the expected output.
34+
cut.ShouldBe($@"<aside class=""{cssClass}""><header>{header}</header></aside>");
35+
}
36+
37+
[Fact(DisplayName = "Aside should render child markup content correctly")]
38+
public void Test002()
39+
{
40+
// Arrange
41+
var content = "<p>I like simple tests and I cannot lie</p>";
42+
43+
// Act
44+
// Act - render the Aside component with a child content parameter,
45+
// which is constructed through the ChildContent helper method.
46+
//
47+
// This is equivalent to the follow Razor code:
48+
//
49+
// <Aside>
50+
// <p>I like simple tests and I cannot lie</p>
51+
// </Aside>
52+
var cut = RenderComponent<Aside>(
53+
ChildContent(content)
54+
);
55+
56+
// Assert - verify that the rendered HTML from the Aside component matches the expected output.
57+
cut.ShouldBe($@"<aside>{content}</aside>");
58+
}
59+
60+
[Fact(DisplayName = "Aside should render a child component correctly")]
61+
public void Test003()
62+
{
63+
// Arrange - set up test data
64+
var outerAsideHeader = "Hello outside";
65+
var nestedAsideHeader = "Hello inside";
66+
67+
// Act - render the Aside component, passing a header to it
68+
// and a component to its child content. The ChildContent helper
69+
// method will pass the parameters it is given to the nested Aside
70+
// component.
71+
//
72+
// This is equivalent to the follow Razor code:
73+
//
74+
// <Aside Header="Hello outside">
75+
// <Aside Header="Hello inside"></Aside>
76+
// </Aside>
77+
var cut = RenderComponent<Aside>(
78+
(nameof(Aside.Header), outerAsideHeader),
79+
ChildContent<Aside>(
80+
(nameof(Aside.Header), nestedAsideHeader)
81+
)
82+
);
83+
84+
// Assert - verify that the rendered HTML from the Aside component matches the expected output.
85+
cut.ShouldBe($@"<aside>
86+
<header>{outerAsideHeader}</header>
87+
<aside>
88+
<header>{nestedAsideHeader}</header>
89+
</aside>
90+
</aside>");
91+
}
92+
}
93+
}

0 commit comments

Comments
 (0)