FIRST Principles
Good unit tests follow the principles below, because these principles ensure the reliability and maintainability of tests:
F - Fast
Test execution time should be in the millisecond range and should not depend on external resources.
[Fact] // Fast: 不依賴外部資源,執行快速
public void Add_輸入1和2_應回傳3()
{
// 純記憶體運算,無 I/O 或網路延遲
var calculator = new Calculator();
var result = calculator.Add(1, 2);
Assert.Equal(3, result);
}
I - Independent
There should be no dependencies between tests; each test creates a new instance.
[Fact] // Independent: 每個測試都建立新的實例
public void Increment_從0開始_應回傳1()
{
var counter = new Counter(); // 每個測試都建立新的實例,不受其他測試影響
counter.Increment();
Assert.Equal(1, counter.Value);
}
R - Repeatable
The same result can be obtained in any environment, without depending on external state.
[Fact] // Repeatable: 每次執行都得到相同結果
public void Increment_多次執行_應產生一致結果()
{
var counter = new Counter();
counter.Increment();
counter.Increment();
counter.Increment();
// 每次執行這個測試都會得到相同結果
Assert.Equal(3, counter.Value);
}
S - Self-Validating
The test result should be a clear pass or fail, using clear assertions.
[Fact] // Self-Validating: 明確的驗證
public void IsValidEmail_輸入有效Email_應回傳True()
{
var emailHelper = new EmailHelper();
var result = emailHelper.IsValidEmail("test@example.com");
Assert.True(result); // 明確的通過或失敗
}
T - Timely
Tests should be written before or at the same time as the production code, ensuring the testability of the code.
3A Pattern Structure
Each test method follows the Arrange-Act-Assert pattern; this structure makes the intent of the test clear at a glance:
[Fact]
public void Add_輸入負數和正數_應回傳正確結果()
{
// Arrange - 準備測試資料與相依物件
var calculator = new Calculator();
const int a = -5;
const int b = 3;
const int expected = -2;
// Act - 執行被測試的方法
var result = calculator.Add(a, b);
// Assert - 驗證結果是否符合預期
Assert.Equal(expected, result);
}
Responsibilities of Each Block
| Block | Responsibility | Notes |
|---|---|---|
| Arrange | Prepare the objects, data, and Mocks needed for the test | Use const to declare constant values and improve readability |
| Act | Execute the method under test | Usually only one line, calling the method under test |
| Assert | Verify the result | Each test verifies only one behavior |
Test Naming Conventions
Name test methods using the following format:
[被測試方法名稱]_[測試情境]_[預期行為]
Naming Examples
| Method Name | Description |
|---|---|
Add_輸入1和2_應回傳3 |
Test normal input |
Add_輸入負數和正數_應回傳正確結果 |
Test boundary conditions |
Divide_輸入10和0_應拋出DivideByZeroException |
Test exception cases |
IsValidEmail_輸入null值_應回傳False |
Test invalid input |
GetDomain_輸入有效Email_應回傳網域名稱 |
Test return values |
Tip: Using Chinese names can make test reports easier to read, especially during team communication.
xUnit Test Attributes
[Fact] - Single Test Case
Used to test a single scenario:
[Fact]
public void Add_輸入0和0_應回傳0()
{
var calculator = new Calculator();
var result = calculator.Add(0, 0);
Assert.Equal(0, result);
}
[Theory] + [InlineData] - Parameterized Tests
Used to test multiple input combinations:
[Theory]
[InlineData(1, 2, 3)]
[InlineData(-1, 1, 0)]
[InlineData(0, 0, 0)]
[InlineData(100, -50, 50)]
public void Add_輸入各種數值組合_應回傳正確結果(int a, int b, int expected)
{
var calculator = new Calculator();
var result = calculator.Add(a, b);
Assert.Equal(expected, result);
}
Test multiple invalid inputs
[Theory]
[InlineData("invalid-email")]
[InlineData("@example.com")]
[InlineData("test@")]
[InlineData("test.example.com")]
public void IsValidEmail_輸入無效Email格式_應回傳False(string invalidEmail)
{
var emailHelper = new EmailHelper();
var result = emailHelper.IsValidEmail(invalidEmail);
Assert.False(result);
}
Exception Tests
Test cases where an exception is expected to be thrown:
[Fact]
public void Divide_輸入10和0_應拋出DivideByZeroException()
{
// Arrange
var calculator = new Calculator();
const decimal dividend = 10m;
const decimal divisor = 0m;
// Act & Assert
var exception = Assert.Throws<DivideByZeroException>(
() => calculator.Divide(dividend, divisor)
);
// 驗證例外訊息
Assert.Equal("除數不能為零", exception.Message);
}
Test Project Structure
Recommended project structure:
Solution/
├── src/
│ └── MyProject/
│ ├── Calculator.cs
│ └── MyProject.csproj
└── tests/
└── MyProject.Tests/
├── CalculatorTests.cs
└── MyProject.Tests.csproj
Test Project Template (.csproj)
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="8.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MyProject\MyProject.csproj" />
</ItemGroup>
</Project>
Common Assertion Methods
| Assertion Method | Purpose |
|---|---|
Assert.Equal(expected, actual) |
Verify equality |
Assert.NotEqual(expected, actual) |
Verify inequality |
Assert.True(condition) |
Verify that a condition is true |
Assert.False(condition) |
Verify that a condition is false |
Assert.Null(object) |
Verify that it is null |
Assert.NotNull(object) |
Verify that it is not null |
Assert.Throws<T>(action) |
Verify that a specific exception is thrown |
Assert.Empty(collection) |
Verify that a collection is empty |
Assert.Contains(item, collection) |
Verify that a collection contains an item |
Checklist for Generating Tests
When generating tests for a method, make sure to cover:
- Normal path - Standard input produces the expected output
- Boundary conditions - minimum, maximum, zero, empty string
- Invalid input - null, negative numbers, malformed
- Exception cases - scenarios where an exception is expected to be thrown
Output format
- Generate an xUnit test class (.cs file) that complies with the FIRST principles
- Each test method follows the AAA Pattern (Arrange-Act-Assert)
- Use three-part naming (Method_Scenario_Expected)
- Cover the happy path, boundary conditions, invalid input, and exception cases
Reference resources
Original article
The content of this skill is distilled from the "Old-School Software Engineer's Testing Practice - 30-Day Challenge" article series:
- Day 01 - An Old-School Engineer's Testing Enlightenment
- Ironman contest article:https://ithelp.ithome.com.tw/articles/10373888
- Sample code:https://github.com/kevintsengtw/30Days_in_Testing_Samples/tree/main/day01
Related skills
dotnet-testing-test-naming-conventions- Test naming conventionsdotnet-testing-xunit-project-setup- xUnit project setupdotnet-testing-awesome-assertions-guide- Fluent assertions