.NET 单元测试(五):难测场景实战

写在前面

本文是 .NET 单元测试系列的第五篇,逐个击破实际开发中最常见的难测场景:数据库、HTTP 调用、时间、文件系统、配置和静态方法。前置知识:依赖注入与可测试性(第四篇)。


一、数据库测试

1.1 测试策略选择

1
2
3
4
5
6
7
8
方案1:Mock IRepository          — 单元测试,不碰数据库
方案2:EF Core InMemory          — 集成测试,内存数据库
方案3:SQLite InMemory           — 集成测试,更接近真实 SQL
方案4:Testcontainers + 真实数据库 — 集成测试,最真实

选择建议:
- 测业务逻辑(Service 层)→ Mock Repository
- 测 Repository 实现本身  → InMemory 或真实数据库

1.2 方案一:Mock Repository(推荐)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// Repository 接口
public interface IUserRepository
{
    User GetById(int id);
    IEnumerable<User> GetAll();
    void Add(User user);
    void Update(User user);
    void Delete(int id);
    Task<User> GetByIdAsync(int id);
}

// Service 测试
public class UserServiceTests
{
    private readonly Mock<IUserRepository> _mockRepo;
    private readonly UserService _service;

    public UserServiceTests()
    {
        _mockRepo = new Mock<IUserRepository>();
        _service = new UserService(_mockRepo.Object);
    }

    [Fact]
    public void GetUser_ExistingId_ReturnsUser()
    {
        var user = new User { Id = 1, Name = "张三" };
        _mockRepo.Setup(r => r.GetById(1)).Returns(user);

        var result = _service.GetUser(1);

        Assert.Equal("张三", result.Name);
    }

    [Fact]
    public void GetUser_NonExistingId_ThrowsNotFoundException()
    {
        _mockRepo.Setup(r => r.GetById(999)).Returns((User?)null);

        Assert.Throws<NotFoundException>(() => _service.GetUser(999));
    }

    [Fact]
    public void CreateUser_ValidUser_SavesToRepo()
    {
        var user = new User { Name = "张三", Email = "zhang@test.com" };

        _service.CreateUser(user);

        _mockRepo.Verify(r => r.Add(It.Is<User>(
            u => u.Name == "张三" && u.Email == "zhang@test.com")), Times.Once);
    }
}

1.3 方案二:EF Core InMemory

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// 测试 Repository 实现本身
public class EfUserRepositoryTests : IDisposable
{
    private readonly AppDbContext _context;
    private readonly EfUserRepository _repo;

    public EfUserRepositoryTests()
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
            .Options;

        _context = new AppDbContext(options);
        _repo = new EfUserRepository(_context);
    }

    [Fact]
    public async Task AddAsync_ValidUser_SavesToDatabase()
    {
        var user = new User { Name = "张三", Email = "zhang@test.com" };

        await _repo.AddAsync(user);

        var saved = await _context.Users.FirstOrDefaultAsync(u => u.Name == "张三");
        Assert.NotNull(saved);
        Assert.Equal("zhang@test.com", saved.Email);
    }

    [Fact]
    public async Task GetByIdAsync_ExistingUser_ReturnsUser()
    {
        _context.Users.Add(new User { Id = 1, Name = "张三" });
        await _context.SaveChangesAsync();

        var result = await _repo.GetByIdAsync(1);

        Assert.Equal("张三", result!.Name);
    }

    [Fact]
    public async Task DeleteAsync_ExistingUser_RemovesFromDatabase()
    {
        var user = new User { Id = 1, Name = "张三" };
        _context.Users.Add(user);
        await _context.SaveChangesAsync();

        await _repo.DeleteAsync(1);

        Assert.Empty(_context.Users);
    }

    public void Dispose() => _context.Dispose();
}

1.4 方案三:SQLite InMemory(更接近真实)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class SqliteUserRepositoryTests : IDisposable
{
    private readonly AppDbContext _context;
    private readonly SqliteConnection _connection;

    public SqliteUserRepositoryTests()
    {
        // SQLite InMemory 需要保持连接打开
        _connection = new SqliteConnection("Filename=:memory:");
        _connection.Open();

        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlite(_connection)
            .Options;

        _context = new AppDbContext(options);
        _context.Database.EnsureCreated(); // 创建表结构
    }

    [Fact]
    public async Task AddAsync_WithConstraints_EnforcesUniqueEmail()
    {
        // SQLite 会执行真实的约束检查,InMemory 不会
        _context.Users.Add(new User { Email = "test@example.com", Name = "A" });
        await _context.SaveChangesAsync();

        _context.Users.Add(new User { Email = "test@example.com", Name = "B" });

        await Assert.ThrowsAsync<DbUpdateException>(
            () => _context.SaveChangesAsync());
    }

    public void Dispose()
    {
        _context.Dispose();
        _connection.Dispose();
    }
}

1.5 InMemory vs SQLite 对比

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
EF Core InMemory:
  + 速度最快
  + 配置最简单
  - 不执行真实 SQL(没有约束检查、没有级联删除)
  - 行为可能和真实数据库不一致

SQLite InMemory:
  + 执行真实 SQL
  + 有约束检查、级联等
  + 更接近生产环境
  - 稍慢
  - SQLite 特有语法和 SQL Server/MySQL 不完全一样

建议:
  快速验证 Repository 逻辑 → InMemory
  需要验证约束和 SQL 行为 → SQLite
  需要完全真实环境 → Testcontainers

二、HTTP 调用测试

2.1 Mock HttpMessageHandler

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
// 被测服务
public class WeatherClient
{
    private readonly HttpClient _client;

    public WeatherClient(HttpClient client) => _client = client;

    public async Task<WeatherInfo> GetWeatherAsync(string city)
    {
        var response = await _client.GetAsync($"/api/weather?city={Uri.EscapeDataString(city)}");

        response.EnsureSuccessStatusCode();

        var json = await response.Content.ReadAsStringAsync();
        return JsonSerializer.Deserialize<WeatherInfo>(json)!;
    }
}

// Mock Handler
public class MockHttpMessageHandler : HttpMessageHandler
{
    private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _handler;

    public MockHttpMessageHandler(
        Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> handler)
    {
        _handler = handler;
    }

    public List<HttpRequestMessage> SentRequests { get; } = new();

    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken cancellationToken)
    {
        SentRequests.Add(request);
        return _handler(request, cancellationToken);
    }
}

// 测试
public class WeatherClientTests
{
    [Fact]
    public async Task GetWeatherAsync_ReturnsWeatherInfo()
    {
        var handler = new MockHttpMessageHandler((req, ct) =>
        {
            var json = """{"city":"北京","temperature":25,"description":""}""";
            return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
            {
                Content = new StringContent(json, Encoding.UTF8, "application/json")
            });
        });

        var client = new HttpClient(handler) { BaseAddress = new Uri("https://api.weather.com") };
        var weatherClient = new WeatherClient(client);

        var result = await weatherClient.GetWeatherAsync("北京");

        Assert.Equal("北京", result.City);
        Assert.Equal(25, result.Temperature);
    }

    [Fact]
    public async Task GetWeatherAsync_SendsCorrectRequest()
    {
        HttpRequestMessage? sentRequest = null;
        var handler = new MockHttpMessageHandler((req, ct) =>
        {
            sentRequest = req;
            return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
            {
                Content = new StringContent("{}")
            });
        });

        var client = new HttpClient(handler) { BaseAddress = new Uri("https://api.weather.com") };
        var weatherClient = new WeatherClient(client);

        await weatherClient.GetWeatherAsync("北京");

        Assert.NotNull(sentRequest);
        Assert.Equal(HttpMethod.Get, sentRequest.Method);
        Assert.Contains("/api/weather?city=", sentRequest.RequestUri!.ToString());
    }

    [Fact]
    public async Task GetWeatherAsync_ServerError_ThrowsException()
    {
        var handler = new MockHttpMessageHandler((req, ct) =>
            Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)));

        var client = new HttpClient(handler) { BaseAddress = new Uri("https://api.weather.com") };
        var weatherClient = new WeatherClient(client);

        await Assert.ThrowsAsync<HttpRequestException>(
            () => weatherClient.GetWeatherAsync("北京"));
    }
}

2.2 简化版 Mock Handler

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 通用简化版
public class StubHttpMessageHandler : HttpMessageHandler
{
    private readonly HttpStatusCode _statusCode;
    private readonly string _content;

    public StubHttpMessageHandler(HttpStatusCode statusCode, string content)
    {
        _statusCode = statusCode;
        _content = content;
    }

    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken cancellationToken)
    {
        return Task.FromResult(new HttpResponseMessage(_statusCode)
        {
            Content = new StringContent(_content, Encoding.UTF8, "application/json")
        });
    }
}

// 使用
var handler = new StubHttpMessageHandler(HttpStatusCode.OK, """{"id":1,"name":"张三"}""");
var client = new HttpClient(handler) { BaseAddress = new Uri("https://api.example.com") };

三、时间相关测试

3.1 问题:DateTime.Now 不可控

1
2
3
4
5
6
7
8
// 不可测
public class SubscriptionService
{
    public bool IsExpired(Subscription sub)
    {
        return sub.ExpiryDate < DateTime.UtcNow;  // 每次运行结果不同
    }
}

3.2 方案一:TimeProvider(.NET 8+)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class SubscriptionService
{
    private readonly TimeProvider _timeProvider;

    public SubscriptionService(TimeProvider timeProvider)
    {
        _timeProvider = timeProvider;
    }

    public bool IsExpired(Subscription sub)
    {
        return sub.ExpiryDate < _timeProvider.GetUtcNow();
    }
}

// 测试
[Fact]
public void IsExpired_ExpiredDate_ReturnsTrue()
{
    // 用 FakeTimeProvider 控制时间
    var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 5, 1, 12, 0, 0, TimeSpan.Zero));
    var service = new SubscriptionService(fakeTime);

    var sub = new Subscription { ExpiryDate = new DateTime(2026, 4, 30) };

    Assert.True(service.IsExpired(sub));
}

[Fact]
public void IsExpired_FutureDate_ReturnsFalse()
{
    var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 5, 1, 12, 0, 0, TimeSpan.Zero));
    var service = new SubscriptionService(fakeTime);

    var sub = new Subscription { ExpiryDate = new DateTime(2026, 6, 1) };

    Assert.False(service.IsExpired(sub));
}

// 推进时间
[Fact]
public void Subscription_ExpiresAfterTimeAdvance()
{
    var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 5, 1, 12, 0, 0, TimeSpan.Zero));
    var service = new SubscriptionService(fakeTime);

    var sub = new Subscription { ExpiryDate = new DateTime(2026, 5, 10) };
    Assert.False(service.IsExpired(sub));

    // 推进时间到过期之后
    fakeTime.Advance(TimeSpan.FromDays(15));
    Assert.True(service.IsExpired(sub));
}

FakeTimeProvider 在 Microsoft.Extensions.TimeProvider.Testing 包中。

3.3 方案二:自定义接口

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 适用于 .NET 8 以下版本
public interface IDateTimeProvider
{
    DateTime UtcNow { get; }
    DateTime Now { get; }
}

public class SystemDateTimeProvider : IDateTimeProvider
{
    public DateTime UtcNow => DateTime.UtcNow;
    public DateTime Now => DateTime.Now;
}

// 测试时注入可控实现
public class TestDateTimeProvider : IDateTimeProvider
{
    public DateTime UtcNowValue { get; set; }
    public DateTime NowValue { get; set; }

    public DateTime UtcNow => UtcNowValue;
    public DateTime Now => NowValue;
}

四、文件系统测试

4.1 方案一:注入路径 + 临时目录

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
public class ReportGenerator
{
    private readonly string _outputDir;

    public ReportGenerator(string outputDir)
    {
        _outputDir = outputDir;
        Directory.CreateDirectory(outputDir);
    }

    public string GenerateReport(string content)
    {
        var fileName = $"report_{Guid.NewGuid():N}.txt";
        var filePath = Path.Combine(_outputDir, fileName);
        File.WriteAllText(filePath, content);
        return filePath;
    }

    public string ReadReport(string filePath) => File.ReadAllText(filePath);
}

// 测试
public class ReportGeneratorTests : IDisposable
{
    private readonly string _tempDir;
    private readonly ReportGenerator _generator;

    public ReportGeneratorTests()
    {
        _tempDir = Path.Combine(Path.GetTempPath(), $"test_{Guid.NewGuid():N}");
        _generator = new ReportGenerator(_tempDir);
    }

    [Fact]
    public void GenerateReport_CreatesFile()
    {
        var path = _generator.GenerateReport("测试内容");

        Assert.True(File.Exists(path));
        Assert.Equal("测试内容", File.ReadAllText(path));
    }

    [Fact]
    public void ReadReport_ReturnsContent()
    {
        var path = _generator.GenerateReport("Hello");
        var content = _generator.ReadReport(path);

        Assert.Equal("Hello", content);
    }

    public void Dispose()
    {
        if (Directory.Exists(_tempDir))
            Directory.Delete(_tempDir, true);
    }
}

4.2 方案二:注入 Stream 抽象

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class CsvExporter
{
    public void Export(IEnumerable<User> users, Stream output)
    {
        using var writer = new StreamWriter(output, leaveOpen: true);
        writer.WriteLine("Id,Name,Email");

        foreach (var user in users)
        {
            writer.WriteLine($"{user.Id},{user.Name},{user.Email}");
        }
    }
}

// 测试:用 MemoryStream,不需要真实文件
[Fact]
public void Export_WritesCsvFormat()
{
    var users = new List<User>
    {
        new() { Id = 1, Name = "张三", Email = "zhang@test.com" },
        new() { Id = 2, Name = "李四", Email = "li@test.com" },
    };
    var exporter = new CsvExporter();

    using var stream = new MemoryStream();
    exporter.Export(users, stream);

    stream.Position = 0;
    using var reader = new StreamReader(stream);
    var csv = reader.ReadToEnd();

    Assert.Contains("Id,Name,Email", csv);
    Assert.Contains("1,张三,zhang@test.com", csv);
    Assert.Contains("2,李四,li@test.com", csv);
}

4.3 方案三:System.IO.Abstractions(全面抽象)

1
2
dotnet add package System.IO.Abstractions
dotnet add package System.IO.Abstractions.TestingHelpers
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// 生产代码:注入 IFileSystem
public class ConfigManager
{
    private readonly IFileSystem _fs;
    private readonly string _configPath;

    public ConfigManager(IFileSystem fs, string configPath)
    {
        _fs = fs;
        _configPath = configPath;
    }

    public bool ConfigExists() => _fs.File.Exists(_configPath);

    public string ReadConfig() => _fs.File.ReadAllText(_configPath);

    public void WriteConfig(string content)
    {
        var dir = _fs.Path.GetDirectoryName(_configPath)!;
        _fs.Directory.CreateDirectory(dir);
        _fs.File.WriteAllText(_configPath, content);
    }
}

// 测试:用 MockFileSystem,不需要真实磁盘
[Fact]
public void ConfigExists_FileExists_ReturnsTrue()
{
    var fs = new MockFileSystem();
    fs.AddFile("/app/config.json", new MockFileData("""{"key":"value"}"""));

    var manager = new ConfigManager(fs, "/app/config.json");

    Assert.True(manager.ConfigExists());
}

[Fact]
public void WriteConfig_CreatesFile()
{
    var fs = new MockFileSystem();
    var manager = new ConfigManager(fs, "/app/config.json");

    manager.WriteConfig("""{"key":"new_value"}""");

    Assert.True(fs.File.Exists("/app/config.json"));
    Assert.Contains("new_value", fs.File.ReadAllText("/app/config.json"));
}

[Fact]
public void ReadConfig_FileNotFound_Throws()
{
    var fs = new MockFileSystem();  // 空文件系统
    var manager = new ConfigManager(fs, "/app/not-exist.json");

    Assert.Throws<FileNotFoundException>(() => manager.ReadConfig());
}

五、配置测试

5.1 Mock IOptions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class DatabaseService
{
    private readonly DatabaseSettings _settings;

    public DatabaseService(IOptions<DatabaseSettings> options)
    {
        _settings = options.Value;
    }

    public string GetConnectionString() => _settings.ConnectionString;
}

// 测试
[Fact]
public void GetConnectionString_ReturnsConfiguredValue()
{
    var mockOptions = new Mock<IOptions<DatabaseSettings>>();
    mockOptions.Setup(o => o.Value).Returns(new DatabaseSettings
    {
        ConnectionString = "Server=test;Database=testdb"
    });

    var service = new DatabaseService(mockOptions.Object);
    Assert.Equal("Server=test;Database=testdb", service.GetConnectionString());
}

// 更简洁的方式:不用 Mock,直接创建 OptionsWrapper
[Fact]
public void GetConnectionString_WithOptionsWrapper()
{
    var options = new OptionsWrapper<DatabaseSettings>(new DatabaseSettings
    {
        ConnectionString = "Server=test;Database=testdb"
    });

    var service = new DatabaseService(options);
    Assert.Equal("Server=test;Database=testdb", service.GetConnectionString());
}

5.2 Mock IConfiguration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
[Fact]
public void GetConfigValue_FromConfiguration()
{
    var config = new ConfigurationBuilder()
        .AddInMemoryCollection(new Dictionary<string, string?>
        {
            ["App:Name"] = "TestApp",
            ["App:Version"] = "2.0"
        })
        .Build();

    Assert.Equal("TestApp", config["App:Name"]);
    Assert.Equal("2.0", config["App:Version"]);
}

六、多线程和并发测试

6.1 基本并发测试

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
[Fact]
public async Task ConcurrentAccess_ThreadSafe()
{
    var counter = new ThreadSafeCounter();
    var tasks = Enumerable.Range(0, 1000)
        .Select(_ => Task.Run(() => counter.Increment()));

    await Task.WhenAll(tasks);

    Assert.Equal(1000, counter.Count);
}

[Fact]
public async Task ConcurrentAccess_NotThreadSafe_DetectsIssue()
{
    var counter = new Counter(); // 非线程安全
    var tasks = Enumerable.Range(0, 1000)
        .Select(_ => Task.Run(() => counter.Increment()));

    await Task.WhenAll(tasks);

    // 大概率不等于 1000(竞态条件)
    // 注意:这个测试不稳定,仅用于演示
    Assert.NotEqual(1000, counter.Count);
}

6.2 AsyncLocal 测试

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
public class TenantContext
{
    private static readonly AsyncLocal<string?> _tenantId = new();

    public static string? CurrentTenant
    {
        get => _tenantId.Value;
        set => _tenantId.Value = value;
    }
}

[Fact]
public async Task AsyncLocal_FlowsAcrossAsyncCalls()
{
    TenantContext.CurrentTenant = "tenant-1";

    await Task.Yield();

    Assert.Equal("tenant-1", TenantContext.CurrentTenant);
}

七、私有方法测试

7.1 不建议直接测私有方法

1
2
私有方法应该通过公共方法间接测试。
如果私有方法复杂到需要单独测,说明应该提取为独立的类。

7.2 如果必须测

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 方案一:internal + InternalsVisibleTo
// 生产代码
internal decimal CalculateTax(decimal amount) => amount * 0.13m;

// AssemblyInfo.cs 或 csproj
[assembly: InternalsVisibleTo("MyApp.Services.Tests")]

// 测试代码(可以访问 internal 方法)
[Fact]
public void CalculateTax_ReturnsCorrectAmount()
{
    var service = new OrderService(repo, email);
    Assert.Equal(13m, service.CalculateTax(100m));
}

// 方案二:通过反射(最后手段)
[Fact]
public void PrivateMethod_Reflection()
{
    var service = new OrderService(repo, email);
    var method = typeof(OrderService).GetMethod("CalculateTax",
        BindingFlags.NonPublic | BindingFlags.Instance);

    var result = (decimal)method!.Invoke(service, new object[] { 100m })!;
    Assert.Equal(13m, result);
}

八、场景选择速查

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
数据库      → Mock Repository(单元测试)
                EF Core InMemory(Repository 集成测试)
                SQLite InMemory(约束验证)

HTTP 调用   → Mock HttpMessageHandler

时间        → TimeProvider / FakeTimeProvider(.NET 8+)
                自定义 IDateTimeProvider(旧版本)

文件系统    → 临时目录 + IDisposable
                Stream 注入
                System.IO.Abstractions

配置        → OptionsWrapper<T>
                ConfigurationBuilder + AddInMemoryCollection

多线程      → Task.WhenAll 并发执行

私有方法    → 通过公共方法间接测试
                internal + InternalsVisibleTo

九、小结

本文学习了常见难测场景的解决方案:

  • 数据库:Mock Repository、EF Core InMemory、SQLite InMemory
  • HTTP 调用:Mock HttpMessageHandler
  • 时间:TimeProvider + FakeTimeProvider
  • 文件系统:临时目录、Stream 注入、System.IO.Abstractions
  • 配置:OptionsWrapper、ConfigurationBuilder
  • 多线程和并发测试
  • 私有方法测试策略

下一篇将学习集成测试:WebApplicationFactory、真实数据库和中间件测试。