C# 测试与工程化

00:00
2 min Advanced 2026/6/14

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.MvvmMVVM 属性/命令生成
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

知识检测

学习进度

-- 已学文档
--% 知识覆盖率

学习推荐

专注模式