C# 测试与工程化
xUnit/NUnit/Moq、集成测试、BenchmarkDotNet、Source Generator、Roslyn Analyzer、CI/CD、代码规范
1. 单元测试
1.1 xUnit
// 安装: dotnet add package xunit
public class CalculatorTests
{
private readonly Calculator _calc = new();
[Fact]
public void Add_TwoNumbers_ReturnsSum()
{
// Arrange
int a = 3, b = 5;
// Act
int result = _calc.Add(a, b);
// Assert
Assert.Equal(8, result);
}
// 参数化测试
[Theory]
[InlineData(1, 2, 3)]
[InlineData(-1, 1, 0)]
[InlineData(0, 0, 0)]
[InlineData(100, 200, 300)]
public void Add_VariousInputs_ReturnsExpected(int a, int b, int expected)
{
Assert.Equal(expected, _calc.Add(a, b));
}
// 使用 MemberData
public static TheoryData<int, int, int> AddData => new()
{
{ 1, 2, 3 },
{ -1, 1, 0 },
{ 100, -50, 50 }
};
[Theory]
[MemberData(nameof(AddData))]
public void Add_MemberData_ReturnsExpected(int a, int b, int expected)
{
Assert.Equal(expected, _calc.Add(a, b));
}
// 异常测试
[Fact]
public void Divide_ByZero_ThrowsDivideByZeroException()
{
Assert.Throws<DivideByZeroException>(() => _calc.Divide(10, 0));
}
// 异步测试
[Fact]
public async Task FetchDataAsync_ReturnsExpected()
{
var result = await _service.FetchDataAsync();
Assert.NotNull(result);
Assert.NotEmpty(result);
}
}
1.2 NUnit
// 安装: dotnet add package NUnit
[TestFixture]
public class CalculatorTests
{
private Calculator _calc = null!;
[SetUp]
public void Setup() => _calc = new Calculator();
[TearDown]
public void Cleanup() => _calc = null!;
[Test]
public void Add_TwoNumbers_ReturnsSum()
{
Assert.That(_calc.Add(3, 5), Is.EqualTo(8));
}
// 参数化
[TestCase(1, 2, 3)]
[TestCase(-1, 1, 0)]
public void Add_VariousInputs(int a, int b, int expected)
{
Assert.That(_calc.Add(a, b), Is.EqualTo(expected));
}
// 范围测试
[Test]
public void RandomValue_InRange()
{
var value = _calc.GetRandom();
Assert.That(value, Is.InRange(0, 100));
}
// 集合断言
[Test]
public void GetItems_ContainsExpected()
{
var items = _calc.GetItems();
Assert.That(items, Has.Count.EqualTo(3));
Assert.That(items, Does.Contain("A"));
}
}
1.3 Moq - 模拟框架
// 安装: dotnet add package Moq
public class OrderServiceTests
{
private readonly Mock<IOrderRepository> _repoMock;
private readonly Mock<IEmailService> _emailMock;
private readonly OrderService _service;
public OrderServiceTests()
{
_repoMock = new Mock<IOrderRepository>();
_emailMock = new Mock<IEmailService>();
_service = new OrderService(_repoMock.Object, _emailMock.Object);
}
[Fact]
public async Task CreateOrder_ValidOrder_SavesAndNotifies()
{
// Arrange
var order = new Order { Id = 1, CustomerEmail = "test@test.com" };
_repoMock.Setup(r => r.SaveAsync(It.IsAny<Order>()))
.ReturnsAsync(order);
// Act
var result = await _service.CreateOrderAsync(order);
// Assert
Assert.NotNull(result);
_repoMock.Verify(r => r.SaveAsync(It.IsAny<Order>()), Times.Once);
_emailMock.Verify(
e => e.SendAsync(order.CustomerEmail, It.IsAny<string>()),
Times.Once);
}
// 验证属性
[Fact]
public void GetConfig_ReturnsConfiguredValue()
{
var mockConfig = new Mock<IConfiguration>();
mockConfig.Setup(c => c["ApiKey"]).Returns("test-key");
var value = mockConfig.Object["ApiKey"];
Assert.Equal("test-key", value);
}
// 抛出异常
[Fact]
public async Task CreateOrder_RepoThrows_PropagatesException()
{
_repoMock.Setup(r => r.SaveAsync(It.IsAny<Order>()))
.ThrowsAsync(new DbUpdateException());
await Assert.ThrowsAsync<DbUpdateException>(
() => _service.CreateOrderAsync(new Order()));
}
// 回调
[Fact]
public async Task CreateOrder_AssignsId()
{
Order? saved = null;
_repoMock.Setup(r => r.SaveAsync(It.IsAny<Order>()))
.Callback<Order>(o => { o.Id = 42; saved = o; })
.ReturnsAsync((Order o) => o);
var result = await _service.CreateOrderAsync(new Order());
Assert.Equal(42, result.Id);
}
}
2. 集成测试
2.1 WebApplicationFactory
// ASP.NET Core 集成测试
public class ApiIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly HttpClient _client;
public ApiIntegrationTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// 替换真实数据库为内存数据库
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase("TestDb"));
});
});
_client = _factory.CreateClient();
}
[Fact]
public async Task GetUsers_ReturnsSuccessAndUsers()
{
// Act
var response = await _client.GetAsync("/api/users");
// Assert
response.EnsureSuccessStatusCode();
var users = await response.Content.ReadFromJsonAsync<List<UserDto>>();
Assert.NotNull(users);
}
[Fact]
public async Task CreateUser_ValidData_ReturnsCreated()
{
// Arrange
var request = new CreateUserRequest { Name = "测试用户", Email = "test@test.com" };
// Act
var response = await _client.PostAsJsonAsync("/api/users", request);
// Assert
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
}
}
2.2 测试容器
// 安装: dotnet add package Testcontainers.MsSql
public class DatabaseIntegrationTests : IAsyncLifetime
{
private readonly MsSqlContainer _container = new MsSqlBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.Build();
private AppDbContext _dbContext = null!;
public async Task InitializeAsync()
{
await _container.StartAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer(_container.GetConnectionString())
.Options;
_dbContext = new AppDbContext(options);
await _dbContext.Database.EnsureCreatedAsync();
}
public async Task DisposeAsync()
{
await _dbContext.DisposeAsync();
await _container.DisposeAsync();
}
[Fact]
public async Task SaveAndRetrieve_WorksCorrectly()
{
var user = new User { Name = "测试", Email = "test@test.com" };
_dbContext.Users.Add(user);
await _dbContext.SaveChangesAsync();
var retrieved = await _dbContext.Users.FindAsync(user.Id);
Assert.NotNull(retrieved);
Assert.Equal("测试", retrieved.Name);
}
}
3. BenchmarkDotNet
// 安装: dotnet add package BenchmarkDotNet
[MemoryDiagnoser]
[RankColumn]
public class StringConcatBenchmarks
{
private const int Iterations = 1000;
private readonly string[] _parts = Enumerable.Range(0, Iterations)
.Select(i => $"part{i}").ToArray();
[Benchmark(Baseline = true)]
public string StringConcat()
{
var result = "";
foreach (var part in _parts)
result += part;
return result;
}
[Benchmark]
public string StringBuilder()
{
var sb = new StringBuilder();
foreach (var part in _parts)
sb.Append(part);
return sb.ToString();
}
[Benchmark]
public string StringJoin() => string.Join("", _parts);
[Benchmark]
public string StringConcat2() => string.Concat(_parts);
}
// 运行
BenchmarkRunner.Run<StringConcatBenchmarks>();
// dotnet run -c Release // 必须使用 Release 模式
4. Source Generator
4.1 概述
Source Generator 在编译期生成代码,替代运行时反射:
// 项目文件 (.csproj)
// <Project Sdk="Microsoft.NET.Sdk">
// <PropertyGroup>
// <TargetFramework>netstandard2.0</TargetFramework>
// <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
// </PropertyGroup>
// <ItemGroup>
// <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="*" />
// <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="*" />
// </ItemGroup>
// </Project>
[Generator]
public class AutoNotifyGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var provider = context.SyntaxProvider
.CreateSyntaxProvider(
predicate: (node, _) => node is FieldDeclarationSyntax,
transform: (ctx, _) => GetFieldInfo(ctx))
.Where(f => f is not null);
context.RegisterSourceOutput(provider, (spc, field) =>
{
if (field is null) return;
var source = GenerateProperty(field.Value);
spc.AddSource($"{field.Value.ClassName}_{field.Value.FieldName}.g.cs", source);
});
}
}
4.2 常用 Source Generator
| Generator | 用途 |
|---|---|
| System.Text.Json | 编译期 JSON 序列化代码生成 |
| Microsoft.Extensions.Logging | 高性能日志源生成 |
| CommunityToolkit.Mvvm | MVVM 属性/命令生成 |
| Refit | 声明式 HTTP 客户端生成 |
| MapTo | 对象映射代码生成 |
5. Roslyn Analyzer
5.1 内置分析器
// 常见分析器警告
// CA1062: 验证公共方法的参数
// CA1303: 不要将文字作为本地化参数传递
// CA2007: 考虑对等待任务调用 ConfigureAwait
// CA2016: 将 CancellationToken 传递给方法
// CA1859: 对性能使用具体类型
// csproj 中配置分析器严重级别
// <NoWarn>CA1062</NoWarn> // 禁用
// <WarningsAsErrors>CA2007</WarningsAsErrors> // 视为错误
// .editorconfig 配置
// [*.cs]
// dotnet_diagnostic.CA2007.severity = error
// dotnet_diagnostic.CA1062.severity = warning
// dotnet_diagnostic.IDE0003.severity = suggestion
5.2 自定义分析器
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class AsyncVoidMethodAnalyzer : DiagnosticAnalyzer
{
private static readonly DiagnosticDescriptor Rule = new(
"FANDEX001",
"避免使用 async void 方法",
"方法 '{0}' 使用了 async void,建议返回 Task",
"设计",
DiagnosticSeverity.Warning,
true);
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
[Rule];
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterSyntaxNodeAction(AnalyzeMethod, SyntaxKind.MethodDeclaration);
}
private void AnalyzeMethod(SyntaxNodeAnalysisContext ctx)
{
var method = (MethodDeclarationSyntax)ctx.Node;
if (method.Modifiers.Any(m => m.IsKind(SyntaxKind.AsyncKeyword)) &&
method.ReturnType is PredefinedTypeSyntax { Keyword.ValueText: "void" })
{
ctx.ReportDiagnostic(Diagnostic.Create(
Rule, method.GetLocation(), method.Identifier.Text));
}
}
}
6. CI/CD
6.1 GitHub Actions
# .github/workflows/dotnet.yml
name: .NET CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
- name: Restore
run: dotnet restore
- name: Build
run: dotnet build --no-restore -c Release
- name: Test
run: dotnet test --no-build -c Release --logger "trx" --collect:"XPlat Code Coverage"
- name: Publish Coverage
uses: codecov/codecov-action@v4
with:
files: ./**/coverage.cobertura.xml
- name: Publish
run: dotnet publish -c Release -r linux-x64 --self-contained -o ./publish
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: app
path: ./publish
6.2 Docker 部署
# Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY *.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
COPY --from=build /app .
EXPOSE 8080
ENTRYPOINT ["dotnet", "MyApp.dll"]
7. 代码规范
7.1 .editorconfig
# .editorconfig
root = true
[*.cs]
# 命名规范
dotnet_naming_rule.interface_should_begin_with_i.severity = warning
dotnet_naming_rule.interface_should_begin_with_i.symbols = interface
dotnet_naming_rule.interface_should_begin_with_i.style = begins_with_i
dotnet_naming_rule.private_fields_should_begin_with_underscore.severity = warning
dotnet_naming_rule.private_fields_should_begin_with_underscore.symbols = private_fields
dotnet_naming_rule.private_fields_should_begin_with_underscore.style = underscore_prefix
# 代码风格
csharp_style_var_for_built_in_types = false:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
csharp_style_expression_bodied_properties = true:suggestion
csharp_style_expression_bodied_accessors = true:suggestion
# 空引用类型
dotnet_diagnostic.CS8600.severity = warning
dotnet_diagnostic.CS8602.severity = warning
dotnet_diagnostic.CS8604.severity = warning
# 格式化
csharp_new_line_before_open_brace = all
csharp_indent_case_contents = true
csharp_indent_switch_labels = true
7.2 dotnet format
# 格式化代码
dotnet format
# 仅检查格式
dotnet format --verify-no-changes
# 仅分析器
dotnet format analyzers
# 仅样式
dotnet format style