一键导入
csharp-unit-tests
Best practices for C# unit and integration testing with NUnit, Shouldly and FakeItEasy. Use this when writing or reviewing C# tests.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Best practices for C# unit and integration testing with NUnit, Shouldly and FakeItEasy. Use this when writing or reviewing C# tests.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
| name | csharp-unit-tests |
| description | Best practices for C# unit and integration testing with NUnit, Shouldly and FakeItEasy. Use this when writing or reviewing C# tests. |
[Test] / [TestCase] / [TestCaseSource])Assert.* (result.ShouldBe(...), collection.ShouldContain(...))A.Fake<T>() in [SetUp]Microsoft.AspNetCore.Mvc.Testing (WebApplicationFactory<T>)Fixture suffix: Section.cs → SectionFixture.csFixture suffix do not need [TestFixture] — NUnit discovers them automatically via their [Test] methodspublic class; integration test classes that contain internal test infrastructure are internal classTest method names use descriptive camelCase — no underscores, no prefixes like Constructor_ or ToString_:
// ✅ correct
WhenEndExceedsMaxLengthThrows
ToStringReturnsEndValue
MissingSectionsLineThrows
WithValidRangeCreatesSection
// ❌ avoid
Constructor_WhenEndExceedsMaxLength_ThrowsArgumentOutOfRangeException
Parse_MissingSectionsLine_ThrowsFormatException
Separate Arrange / Act / Assert with a blank line only — never write // Arrange, // Act, or // Assert comments:
[Test]
public void WhenStartEqualsEndThrows()
{
Should.Throw<ArgumentException>(() => new Section(1000, 1000));
}
[Test]
public void ToStringReturnsEndValue()
{
var section = new Section(0, 2500);
var result = section.ToString();
result.ShouldBe("2500");
}
Always scan for duplication before writing a new [Test] method. If two or more tests share identical body structure and differ only in one or more literal values (strings, numbers, enum values), they MUST be collapsed into a single [TestCase]-parameterized method.
// ❌ avoid — identical structure, only the expected string differs
[Test] public void ActiveStatusHasCorrectName() { ... status.Name.ShouldBe("Active"); }
[Test] public void InactiveStatusHasCorrectName() { ... status.Name.ShouldBe("Inactive"); }
// ✅ correct — collapsed into one parameterized test
[TestCase(UserStatusId.Active, "Active")]
[TestCase(UserStatusId.Inactive, "Inactive")]
public void StatusHasCorrectName(UserStatusId id, string expected)
{
var status = UserStatus.FromValue(id.Value);
status.Name.ShouldBe(expected);
}
Use [TestCase] whenever the same assertion logic applies to multiple input values:
[TestCase("")]
[TestCase(" ")]
[TestCase(null)]
public void CreateUserWithEmptyNameThrows(string? name)
{
Should.Throw<ArgumentException>(() => new UserBuilder().WithFullName(name!).Build());
}
Use [TestCaseSource] when test data is more complex or reused across multiple tests:
private static readonly object[][] InvalidEmails =
[
["not-an-email"],
["missing@domain"],
["@nodomain.com"],
];
[TestCaseSource(nameof(InvalidEmails))]
public void CreateUserWithInvalidEmailThrows(string email)
{
Should.Throw<ArgumentException>(() => new UserBuilder().WithEmailAddress(email).Build());
}
Create fakes in [SetUp]; never share mutable fake state across tests:
private IMyService _myService = null!;
[SetUp]
public void SetUp() => _myService = A.Fake<IMyService>();
Configure return values and verify calls with A.CallTo:
A.CallTo(() => _myService.GetAsync(id)).Returns(expected);
A.CallTo(() => _myService.SaveAsync(A<MyEntity>._)).MustHaveHappenedOnceExactly();
Use Should.Throw<T> for synchronous code and Should.ThrowAsync<T> for async — never Assert.Throws:
Should.Throw<ArgumentException>(() => new Section(500, 100));
await Should.ThrowAsync<InvalidOperationException>(async () => await _service.ProcessAsync(null));
Use Verify for integration tests and any test that validates complex output (multi-line strings, JSON responses, serialized objects). Verify stores approved snapshots in .verified.txt files next to the test source.
[Test]
public async Task GetConfigurationMatchesSnapshot()
{
var response = await _client.GetAsync("/configuration");
var body = await response.Content.ReadAsStringAsync();
var settings = new VerifySettings();
settings.ScrubLinesContaining("time-dependent content");
await Verify(body, settings);
}
.received.txt file — review it and rename/copy to .verified.txt to approve..verified.txt files alongside the tests.WebApplicationFactory<Program>, sets up HttpClient in [SetUp], and disposes everything in [TearDown].WithWebHostBuilder(builder => builder.ConfigureServices(...)) to substitute real infrastructure with test doubles.Client.GetAsync(url) / Client.PostAsync(url, content) directly on the HttpClient.response.StatusCode.ShouldBe(HttpStatusCode.OK).await Verify(body) (Verify.NUnit snapshot files) for complex outputs.[Category("integration")] so they can be filtered in CI: dotnet test --filter "TestCategory=integration".[Category("integration")]
internal class WeatherForecastControllerFixture : SampleAppFixtureBase
{
[Test]
public async Task GetForecastReturnsOk()
{
var response = await Client.GetAsync("WeatherForecast");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
[Test]
public async Task GetForecastMatchesSnapshot()
{
var response = await Client.GetAsync("WeatherForecast");
var body = await response.Content.ReadAsStringAsync();
await Verify(body);
}
}
[TestFixture] to classes whose names end with Fixture.// Arrange, // Act, // Assert comments.Assert.That — use Shouldly only.[Test] methods for cases that differ only in input values — use [TestCase] or [TestCaseSource] instead. Always check for this before writing any new [Test] method.