.NET 单元测试(四):依赖注入与可测试性

写在前面

本文是 .NET 单元测试系列的第四篇,介绍如何通过依赖注入设计可测试的代码,以及如何重构不可测的代码。前置知识:Mock 与隔离(第三篇)。


一、可测试性原则

1.1 什么是可测试性

可测试性是指代码容易被自动化测试的程度。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
容易测试的代码:
✓ 依赖通过构造函数注入
✓ 方法是纯函数(相同输入 → 相同输出)
✓ 没有隐藏的静态调用
✓ 没有直接 new 具体实现
✓ 可以控制所有输入

难测试的代码:
✗ 在方法内部 new 依赖
✗ 调用 DateTime.Now、File.Read 等静态方法
✗ 依赖单例或静态状态
✗ 方法做太多事(上帝方法)
✗ 密封类、静态方法

1.2 依赖倒置原则(DIP)

1
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
27
28
29
30
31
// 不可测:直接依赖具体实现
public class OrderService
{
    private readonly SqlOrderRepository _repo = new();         // 硬编码
    private readonly SmtpEmailService _email = new();          // 硬编码

    public void CreateOrder(Order order)
    {
        _repo.Save(order);
        _email.Send(order.CustomerEmail, "订单已创建");
    }
}

// 可测:依赖抽象(接口)
public class OrderService
{
    private readonly IOrderRepository _repo;
    private readonly IEmailService _email;

    public OrderService(IOrderRepository repo, IEmailService email)
    {
        _repo = repo;
        _email = email;
    }

    public void CreateOrder(Order order)
    {
        _repo.Save(order);
        _email.Send(order.CustomerEmail, "订单已创建");
    }
}

二、接口设计

2.1 什么时候该抽接口

1
2
3
4
5
6
7
8
9
需要抽接口:
- 涉及外部系统(数据库、HTTP、文件、消息队列)
- 有多种实现的业务逻辑(不同策略、不同租户)
- 需要在测试中 Mock 的依赖

不需要抽接口:
- 纯内存的数据处理(直接用真实对象测)
- 只有一个实现且不会有第二个
- 简单的值对象和数据类

2.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
27
// 反模式:一个巨大的接口(违反接口隔离原则)
public interface IRepository
{
    User GetUser(int id);
    void SaveUser(User user);
    void DeleteUser(int id);
    Order GetOrder(int id);
    void SaveOrder(Order order);
    void DeleteOrder(int id);
    // ... 50 个方法
}

// 好的做法:小而专注的接口
public interface IUserRepository
{
    User GetById(int id);
    void Save(User user);
    void Delete(int id);
    IEnumerable<User> GetAll();
}

public interface IOrderRepository
{
    Order GetById(int id);
    void Save(Order order);
    IEnumerable<Order> GetByUserId(int userId);
}

2.3 接口命名规范

1
2
3
I + 名词              — IRepository、IUserService
I + 动词 + able       — IDisposable、IComparable
I + 描述 + Provider   — ITimeProvider、IFileProvider

三、构造函数注入

3.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
public class PaymentService
{
    private readonly IPaymentGateway _gateway;
    private readonly IOrderRepository _repo;
    private readonly ILogger<PaymentService> _logger;

    // 所有依赖通过构造函数注入
    public PaymentService(
        IPaymentGateway gateway,
        IOrderRepository repo,
        ILogger<PaymentService> logger)
    {
        _gateway = gateway ?? throw new ArgumentNullException(nameof(gateway));
        _repo = repo ?? throw new ArgumentNullException(nameof(repo));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    public PaymentResult ProcessPayment(Order order)
    {
        _logger.LogInformation("处理支付:{OrderId}", order.Id);

        var result = _gateway.Charge(order.Total, order.PaymentMethod);

        if (result.Success)
        {
            order.Status = OrderStatus.Paid;
            _repo.Save(order);
        }

        return result;
    }
}

3.2 测试时注入 Mock

 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
public class PaymentServiceTests
{
    private readonly Mock<IPaymentGateway> _mockGateway;
    private readonly Mock<IOrderRepository> _mockRepo;
    private readonly Mock<ILogger<PaymentService>> _mockLogger;
    private readonly PaymentService _service;

    public PaymentServiceTests()
    {
        _mockGateway = new Mock<IPaymentGateway>();
        _mockRepo = new Mock<IOrderRepository>();
        _mockLogger = new Mock<ILogger<PaymentService>>();
        _service = new PaymentService(_mockGateway.Object, _mockRepo.Object, _mockLogger.Object);
    }

    [Fact]
    public void ProcessPayment_Success_UpdatesOrderStatus()
    {
        var order = new Order { Total = 100, PaymentMethod = "CreditCard" };
        _mockGateway.Setup(g => g.Charge(100, "CreditCard"))
                    .Returns(new PaymentResult { Success = true });

        _service.ProcessPayment(order);

        Assert.Equal(OrderStatus.Paid, order.Status);
        _mockRepo.Verify(r => r.Save(order), Times.Once);
    }
}

3.3 依赖太多怎么办

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 反模式:构造函数参数太多(通常说明类做了太多事)
public class OrderService
{
    public OrderService(
        IOrderRepository repo,
        IUserRepository userRepo,
        IProductRepository productRepo,
        IEmailService email,
        ISmsService sms,
        IPaymentGateway payment,
        ILogger logger,
        ICacheService cache,
        IConfiguration config)
    {
        // 9 个依赖 → 这个类一定做了太多事
    }
}

// 解决:拆分职责
// OrderValidationService — 验证逻辑
// OrderPricingService   — 定价计算
// OrderNotificationService — 通知
// OrderService           — 编排(只依赖上面几个服务)

四、重构不可测代码

4.1 场景一:方法内部 new 依赖

 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
// 不可测
public class ReportService
{
    public string GenerateReport(int userId)
    {
        var repo = new SqlUserRepository();    // 内部 new,测试无法替换
        var user = repo.GetById(userId);
        return $"报告:{user.Name}";
    }
}

// 重构:通过构造函数注入
public class ReportService
{
    private readonly IUserRepository _repo;

    public ReportService(IUserRepository repo)
    {
        _repo = repo;
    }

    public string GenerateReport(int userId)
    {
        var user = _repo.GetById(userId);
        return $"报告:{user.Name}";
    }
}

// 测试
[Fact]
public void GenerateReport_ReturnsUserName()
{
    var mockRepo = new Mock<IUserRepository>();
    mockRepo.Setup(r => r.GetById(1))
            .Returns(new User { Id = 1, Name = "张三" });

    var service = new ReportService(mockRepo.Object);
    var result = service.GenerateReport(1);

    Assert.Equal("报告:张三", result);
}

4.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
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
// 不可测
public class GreetingService
{
    public string GetGreeting(string name)
    {
        var hour = DateTime.Now.Hour;   // 静态调用,无法控制
        var greeting = hour switch
        {
            < 6 => "凌晨好",
            < 12 => "早上好",
            < 18 => "下午好",
            _ => "晚上好"
        };
        return $"{greeting},{name}";
    }
}

// 重构:注入 TimeProvider(.NET 8 内置)
public class GreetingService
{
    private readonly TimeProvider _timeProvider;

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

    public string GetGreeting(string name)
    {
        var hour = _timeProvider.GetLocalNow().Hour;
        var greeting = hour switch
        {
            < 6 => "凌晨好",
            < 12 => "早上好",
            < 18 => "下午好",
            _ => "晚上好"
        };
        return $"{greeting},{name}";
    }
}

// 测试
[Fact]
public void GetGreeting_Morning_ReturnsMorningGreeting()
{
    // 控制时间为早上 9 点
    var mockTime = new Mock<TimeProvider>();
    mockTime.Setup(t => t.GetLocalNow())
            .Returns(new DateTimeOffset(2026, 5, 1, 9, 0, 0, TimeSpan.FromHours(8)));

    var service = new GreetingService(mockTime.Object);
    var result = service.GetGreeting("张三");

    Assert.Equal("早上好,张三", result);
}

4.3 场景三:隐藏的文件操作

 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
// 不可测
public class ConfigLoader
{
    public AppConfig Load()
    {
        var json = File.ReadAllText("config.json");  // 硬编码文件路径
        return JsonSerializer.Deserialize<AppConfig>(json);
    }
}

// 重构方式1:注入路径,测试时指向临时文件
public class ConfigLoader
{
    private readonly string _configPath;

    public ConfigLoader(string configPath)
    {
        _configPath = configPath;
    }

    public AppConfig Load()
    {
        var json = File.ReadAllText(_configPath);
        return JsonSerializer.Deserialize<AppConfig>(json)!;
    }
}

// 重构方式2:注入 Stream(更灵活)
public class ConfigLoader
{
    public AppConfig Load(Stream stream)
    {
        using var reader = new StreamReader(stream);
        var json = reader.ReadToEnd();
        return JsonSerializer.Deserialize<AppConfig>(json)!;
    }
}

// 测试
[Fact]
public void Load_ValidJson_ReturnsConfig()
{
    var json = """{"AppName":"TestApp","Version":"1.0"}""";
    var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
    var loader = new ConfigLoader();

    var config = loader.Load(stream);

    Assert.Equal("TestApp", config.AppName);
    Assert.Equal("1.0", config.Version);
}

4.4 场景四:静态 HttpClient

 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
// 不可测
public class WeatherService
{
    private static readonly HttpClient _client = new();

    public async Task<string> GetWeatherAsync(string city)
    {
        return await _client.GetStringAsync($"https://api.weather.com?city={city}");
    }
}

// 重构:注入 HttpClient(通过 IHttpClientFactory)
public class WeatherService
{
    private readonly HttpClient _client;

    public WeatherService(HttpClient client)
    {
        _client = client;
    }

    public async Task<string> GetWeatherAsync(string city)
    {
        return await _client.GetStringAsync($"/weather?city={city}");
    }
}

// 测试:用 HttpMessageHandler Mock
public class WeatherServiceTests
{
    [Fact]
    public async Task GetWeatherAsync_ReturnsWeatherData()
    {
        var handler = new MockHttpMessageHandler(
            """{"city":"北京","temp":25}""");
        var client = new HttpClient(handler)
        {
            BaseAddress = new Uri("https://api.weather.com")
        };

        var service = new WeatherService(client);
        var result = await service.GetWeatherAsync("北京");

        Assert.Contains("北京", result);
    }
}

// 辅助类:Mock HttpMessageHandler
public class MockHttpMessageHandler : HttpMessageHandler
{
    private readonly string _response;

    public MockHttpMessageHandler(string response) => _response = response;

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

    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken cancellationToken)
    {
        SentRequests.Add(request);
        var response = new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = new StringContent(_response, Encoding.UTF8, "application/json")
        };
        return Task.FromResult(response);
    }
}

五、工厂模式与多实现选择

5.1 问题:运行时选择不同实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 场景:不同租户使用不同的短信服务商
public interface ISmsProvider
{
    string Name { get; }
    Task SendAsync(string phone, string message);
}

public class AliyunSmsProvider : ISmsProvider
{
    public string Name => "aliyun";
    public Task SendAsync(string phone, string message) { /* ... */ }
}

public class TencentSmsProvider : ISmsProvider
{
    public string Name => "tencent";
    public Task SendAsync(string phone, string message) { /* ... */ }
}

5.2 工厂模式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
public class SmsProviderFactory
{
    private readonly Dictionary<string, ISmsProvider> _providers;

    public SmsProviderFactory(IEnumerable<ISmsProvider> providers)
    {
        _providers = providers.ToDictionary(p => p.Name);
    }

    public ISmsProvider GetProvider(string name)
    {
        return _providers.TryGetValue(name, out var provider)
            ? provider
            : throw new ArgumentException($"未知的短信服务商:{name}");
    }
}

// 注册
// builder.Services.AddTransient<ISmsProvider, AliyunSmsProvider>();
// builder.Services.AddTransient<ISmsProvider, TencentSmsProvider>();
// builder.Services.AddTransient<SmsProviderFactory>();

5.3 使用和测试

 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
public class SmsService
{
    private readonly SmsProviderFactory _factory;

    public SmsService(SmsProviderFactory factory)
    {
        _factory = factory;
    }

    public Task SendAsync(string tenantId, string phone, string message)
    {
        var providerName = GetProviderNameForTenant(tenantId);
        var provider = _factory.GetProvider(providerName);
        return provider.SendAsync(phone, message);
    }

    private string GetProviderNameForTenant(string tenantId) => /* 从配置读取 */;
}

// 测试
[Fact]
public async Task SendAsync_UsesCorrectProvider()
{
    var mockAliyun = new Mock<ISmsProvider>();
    mockAliyun.SetupGet(p => p.Name).Returns("aliyun");

    var mockTencent = new Mock<ISmsProvider>();
    mockTencent.SetupGet(p => p.Name).Returns("tencent");

    var factory = new SmsProviderFactory(new[] { mockAliyun.Object, mockTencent.Object });
    var service = new SmsService(factory);

    await service.SendAsync("tenant-aliyun", "13800138000", "验证码");

    mockAliyun.Verify(p => p.SendAsync("13800138000", "验证码"), Times.Once);
    mockTencent.Verify(p => p.SendAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}

六、DI 容器在测试中的角色

6.1 单元测试不用 DI 容器

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 单元测试:手动创建和注入
[Fact]
public void Test()
{
    var mockRepo = new Mock<IUserRepository>();
    var mockEmail = new Mock<IEmailService>();
    var service = new UserService(mockRepo.Object, mockEmail.Object);

    // 直接测试
}

// 不要在单元测试中用 ServiceCollection
// 那是集成测试的事

6.2 集成测试可以用 DI 容器

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 集成测试:用真实的 DI 容器,替换部分依赖
public class IntegrationTests
{
    private readonly IServiceProvider _services;

    public IntegrationTests()
    {
        var services = new ServiceCollection();
        services.AddMyAppServices(); // 注册生产代码的服务

        // 替换外部依赖为 Mock
        services.AddScoped<IEmailService>(_ =>
        {
            var mock = new Mock<IEmailService>();
            return mock.Object;
        });

        _services = services.BuildServiceProvider();
    }
}

七、设计模式与可测试性

7.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
// 不同折扣策略
public interface IDiscountStrategy
{
    decimal Calculate(decimal total);
}

public class NoDiscount : IDiscountStrategy
{
    public decimal Calculate(decimal total) => 0;
}

public class PercentDiscount : IDiscountStrategy
{
    private readonly decimal _percent;
    public PercentDiscount(decimal percent) => _percent = percent;
    public decimal Calculate(decimal total) => total * _percent;
}

public class FixedDiscount : IDiscountStrategy
{
    private readonly decimal _amount;
    public FixedDiscount(decimal amount) => _amount = amount;
    public decimal Calculate(decimal total) => Math.Min(_amount, total);
}

// 使用
public class PricingService
{
    private readonly IDiscountStrategy _discount;

    public PricingService(IDiscountStrategy discount) => _discount = discount;

    public decimal GetFinalPrice(decimal total) => total - _discount.Calculate(total);
}

// 测试
[Fact]
public void GetFinalPrice_WithPercentDiscount()
{
    var service = new PricingService(new PercentDiscount(0.2m));
    Assert.Equal(80m, service.GetFinalPrice(100m));
}

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
public abstract class DataProcessor
{
    public ProcessResult Process(string input)
    {
        var validated = Validate(input);
        var transformed = Transform(validated);
        var result = Save(transformed);
        return result;
    }

    protected abstract string Validate(string input);
    protected abstract string Transform(string input);
    protected abstract ProcessResult Save(string data);
}

// 测试具体实现
public class TestableDataProcessor : DataProcessor
{
    public Func<string, string> ValidateFn { get; set; } = s => s;
    public Func<string, string> TransformFn { get; set; } = s => s;
    public Func<string, ProcessResult> SaveFn { get; set; } = _ => new ProcessResult { Success = true };

    protected override string Validate(string input) => ValidateFn(input);
    protected override string Transform(string input) => TransformFn(input);
    protected override ProcessResult Save(string data) => SaveFn(data);
}

八、可测试性检查清单

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
依赖注入:
  □ 所有外部依赖通过构造函数注入
  □ 不在方法内部 new 具体实现
  □ 依赖接口不依赖具体类

静态依赖:
  □ DateTime.Now → TimeProvider
  □ File/Directory → 注入路径或 Stream
  □ HttpClient → IHttpClientFactory 或注入 HttpClient
  □ Console → 注入 TextWriter
  □ Environment → 注入或包装

设计原则:
  □ 单一职责(一个类做一件事)
  □ 方法短小(一个方法做一件事)
  □ 接口小而专注
  □ 避免上帝类和上帝方法

可测试代码特征:
  □ 可以控制所有输入
  □ 可以观察所有输出
  □ 可以隔离外部依赖
  □ 每次运行结果一致

九、小结

本文学习了依赖注入与可测试性:

  • 可测试性原则和依赖倒置
  • 接口设计和命名
  • 构造函数注入模式
  • 重构不可测代码(4 个实战场景)
  • 工厂模式与多实现选择
  • DI 容器在测试中的角色
  • 设计模式提升可测试性

下一篇将逐个击破常见的难测场景:数据库、HTTP、时间、文件系统等。