.NET Core如何对服务进行单元测试?

14

我已经构建了一个WebAPI,并希望创建一个单元测试项目,以便自动测试我的服务。

我的WebAPI的流程很简单:

控制器(DI服务)->服务(DI存储库)->_repo CRUD

假设我有一个这样的服务:

public int Cancel(string id) //change status filed to 'n'
{
    var item = _repo.Find(id);
    item.status = "n";
    _repo.Update(item);
    return _repo.SaveChanges();
}

我想创建一个单元测试,只使用InMemoryDatabase。

public void Cancel_StatusShouldBeN() //Testing Cancel() method of a service
{
    _service.Insert(item); 

    int rs = _service.Cancel(item.Id);
    Assert.Equal(1, rs);

    item = _service.GetByid(item.Id);
    Assert.Equal("n", item.status);
}

我已经搜索了其他相关问题,发现

您无法在测试类上使用依赖项注入。

我只想知道是否有其他解决方案来实现我的单元测试想法?


你不应该使用“单元测试”这个术语来描述这种类型的测试。这是一种集成测试,因为在你的测试中,你依赖于一个具体的DbContext实现。一个单元测试可以在没有外部依赖的情况下进行测试(例如通过模拟接口)。这是当你在服务中使用EF Core而没有将其抽象到存储库或通过CQRS时的缺点之一。 - Tseng
1个回答

25

在单元测试时,你应该明确地提供正在测试的类的所有依赖项。那就是依赖注入;不是让服务自己构造其依赖项,而是让它依赖外部组件来提供它们。当你在依赖注入容器之外,在一个单元测试中手动创建正在测试的类时,你需要负责提供依赖项。

实践上,这意味着你要么提供模拟对象,要么提供实际对象给构造函数。例如,你可能想提供一个真正的记录器但没有目标,一个具有连接的内存数据库的真正数据库上下文,或者一些模拟的服务。

假设在这个例子中,你正在测试的服务看起来像这样:

public class ExampleService
{
    public ExampleService(ILogger<ExampleService> logger,
        MyDbContext databaseContext,
        UtilityService utilityService)
    {
        // …
    }
    // …
}

因此,为了测试ExampleService,我们需要提供这三个对象。在这种情况下,我们将为每个对象执行以下操作:

  • ILogger<ExampleService> - 我们将使用一个真正的日志记录器,没有任何附加的目标。因此,任何对记录器的调用都将正常工作,而无需提供某些模拟对象,但我们不需要测试日志输出,因此我们不需要一个真正的目标。
  • MyDbContext - 在这里,我们将使用带有内存数据库的真实数据库上下文。
  • UtilityService - 对于这个,我们将创建一个只设置我们想要测试的方法内部需要的实用方法的模拟对象。

因此,一个单元测试可能看起来像这样:

[Fact]
public async Task TestExampleMethod()
{
    var logger = new LoggerFactory().CreateLogger<ExampleService>();
    var dbOptionsBuilder = new DbContextOptionsBuilder().UseInMemoryDatabase();

    // using Moq as the mocking library
    var utilityServiceMock = new Mock<UtilityService>();
    utilityServiceMock.Setup(u => u.GetRandomNumber()).Returns(4);

    // arrange
    using (var db = new MyDbContext(dbOptionsBuilder.Options))
    {
        // fix up some data
        db.Set<Customer>().Add(new Customer()
        {
            Id = 2,
            Name = "Foo bar"
        });
        await db.SaveChangesAsync();
    }

    using (var db = new MyDbContext(dbOptionsBuilder.Options))
    {
        // create the service
        var service = new ExampleService(logger, db, utilityServiceMock.Object);

        // act
        var result = service.DoSomethingWithCustomer(2);

        // assert
        Assert.NotNull(result);
        Assert.Equal(2, result.CustomerId);
        Assert.Equal("Foo bar", result.CustomerName);
        Assert.Equal(4, result.SomeRandomNumber);
    }
}

在您特定的Cancel情况下,您希望避免使用任何当前未测试的服务方法。因此,如果您想测试Cancel,则应该从您的服务中调用的唯一方法是Cancel。一个测试可能会像这样(只是猜测这里的依赖关系):

[Fact]
public async Task Cancel_StatusShouldBeN()
{
    var logger = new LoggerFactory().CreateLogger<ExampleService>();
    var dbOptionsBuilder = new DbContextOptionsBuilder().UseInMemoryDatabase();

    // arrange
    using (var db = new MyDbContext(dbOptionsBuilder.Options))
    {
        // fix up some data
        db.Set<SomeItem>().Add(new SomeItem()
        {
            Id = 5,
            Status = "Not N"
        });
        await db.SaveChangesAsync();
    }

    using (var db = new MyDbContext(dbOptionsBuilder.Options))
    {
        // create the service
        var service = new YourService(logger, db);

        // act
        var result = service.Cancel(5);

        // assert
        Assert.Equal(1, result);
    }

    using (var db = new MyDbContext(dbOptionsBuilder.Options))
    {
        var item = db.Set<SomeItem>().Find(5);
        Assert.Equal(5, item.Id);
        Assert.Equal("n", item.Status);
    }
}

顺带提一下,我总是打开一个新的数据库上下文来避免从缓存实体中获取结果。通过打开一个新的上下文,我可以验证更改是否已完全传输到数据库中。


很好的解释,不过我个人会避免使用内存数据库。我认为我只需要测试是否使用了正确的参数调用了db context。也许可以在一个专门的单元测试上测试确保db context映射到正确的表格,这与“取消”方法无关。 - Fabio Salvalai
2
如果不使用显式的存储库模式,测试上下文是否调用了“正确”的参数就非常困难,因为您经常与例如DbSet对象交互,然后还必须进行模拟。使用内存数据库(在EF Core中非常好,并且专门用于此目的)使其更易于维护。但总的来说,我同意您应该尽可能少地使用“真实”对象,以避免其他实现可能导致的回归。 - poke
非常有用的解释! - wtf512
通常编写测试方法进行测试是正确的吗?单元测试是软件测试的一种级别,其中测试软件的单个单元/组件。我认为从技术上讲,像这样与数据库连接的方法不适合作为单元测试。 - sajadre
5
@sajadre 这有点灰色地带。你是正确的,这不是纯粹的单元测试,因为它涉及到了被测试的单元(YourService.Cancel方法)之外的其他部分。然而,当你的单元与EF数据库上下文进行交互时,你会处理IQueryables。适当地模拟它们非常复杂,因此使用内存数据库可以使这个过程变得更容易。否则,对模拟查询结果的测试最终只会重复你在单元中调用的确切查询语句,从而使测试变得无用。 - poke

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接