在单元测试中模拟域实体 class' 属性:方法、抽象 classes 或虚拟属性?

Mocking domain entity class' properties in unit tests: methods, abstract classes or virtual properties?

我有一个代表域实体的 class,这个 class 实现任何接口。让我们考虑一些简单的事情:

public class DomainEntity
{
    public DomainEntity(string name) { Name = name; }
    public string Name { get; private set; }
}

我还有其他一些 class 正在测试。它有一个接受我的 DomainEntity 作为参数的方法,这个方法访问 Name 属性。例如:

public class EntityNameChecker : IEntityNameChecker
{
    public bool IsDomainEntityNameValid(DomainEntity entity)
    {
        if (entity.Name == "Valid") { return true; }

        return false;
    }
}

我必须为我的测试模拟我的 DomainEntity。我正在使用 NSubstitute 作为我的模拟库(它不允许模拟 non-virtual/non-abstract 属性)。

因此,在不添加接口 (a-la IDomainEntity) 的情况下,我有三个选项来模拟剩余的 属性 值。

  1. 使 Name 属性 虚拟:

    public class DomainEntity
    {
        public DomainEntity(string name) { Name = name; }
        public virtual string Name { get; }
    }
    

    这里的缺点是我不能再让我的DomainEntityclasssealed,这意味着任何消费者都可以继承它并覆盖我的Name属性.

  2. 创建抽象"base" class并使用基础class类型作为参数类型:

    public abstract class DomainEntityBase
    {
        protected abstract string Name { get; private protected set; }
    }
    
    public sealed class DomainEntity : DomainEntityBase
    {
        public DomainEntity(string name) { Name = name; }
        protected override string Name { get; private protected set; }
    }
    
    public class EntityNameChecker : IEntityNameChecker
    {
        public bool IsDomainEntityNameValid(DomainEntityBase entity)
        {
            if (entity.Name == "Valid") { return true; }
    
            return false;
        }
    }
    

    这里的缺点是过于复杂。这基本上将抽象 class 变成了某种接口。

  3. 不是直接访问Name属性,而是将Namegetter变成方法调用来获取值(我们甚至可以去至于使方法 internal 并使用 InternalsVisibleTo 属性使该方法对我们的测试程序集可见):

    [assembly: InternalsVisibleToAttribute("TestAssembly")]
    public sealed class DomainEntity
    {
        private string _name;
    
        public DomainEntity(string name) { _name = name; }
    
        public string Name => GetName();
    
        internal string GetName()
        {
            return _name;
        }
    }
    

    这里的缺点是......嗯,更多的代码、方法、更复杂的东西(而且为什么这样编码并不是很明显)。

我的问题是:是否有 "preferred" 方法可以做到这一点?为什么它是首选?

编辑:

  1. 我不想在我的测试中简单地使用 class 的实例的原因是构造函数中可能有额外的逻辑。如果我破坏构造函数,它将破坏所有相关测试(但它应该只破坏测试 DomainEntity 的测试)。

  2. 我可以提取一个接口并完成它。但我更喜欢使用接口来定义行为。而这些 class 有 none。

I could extract an interface and be done with it. But I prefer to use interfaces to define behaviors. And these classes have none.

如果避免使用此处的接口,您可能会给自己带来不必要的困难。如果你真的想要 "mock" 域实体(意思是,用特定于测试的行为替换生产行为),我认为接口是可行的方法。但是,您明确表示这些 类 没有任何行为,因此请继续阅读...

The reason I don’t want to simply use the instance of the class in my tests is the fact that there might be additional logic in the constructor. If I break the constructor it’s going to break all dependent tests (but it should break only the tests testing DomainEntity).

听起来你并不真的需要模拟(正如我在上面定义的那样)——你只需要一种可维护的方法来实例化测试实例。

要解决该问题,您可以引入 builder 来构造 DomainEntity 的实例。构建器将充当测试和实体构造函数之间的缓冲区或抽象。它可以为特定测试不关心的任何构造函数参数提供合理的默认值。

使用您在问题中定义的 类 作为起点,假设您有这样的测试(使用 xUnit 语法):

[Fact]
public void Test1() {
    var entity = new DomainEntity("Valid");

    var nameChecker = new EntityNameChecker();

    Assert.True(nameChecker.IsDomainEntityNameValid(entity));
}

现在,也许我们想向域实体添加一个新的必需 属性:

public sealed class DomainEntity {
    public string Name { get; private set; }
    public DateTimeOffset Date { get; private set; }

    public DomainEntity(string name, DateTimeOffset date) {
        Name = name;
        Date = date;
    }   
}

新的构造函数参数破坏了测试(可能还有很多其他测试)。

所以我们介绍一个构建器:

public sealed class DomainEntityBuilder {
    public string Name { get; set; } = "Default Name";
    public DateTimeOffset Date { get; set; } = DateTimeOffset.Now;

    public DomainEntity Build() => new DomainEntity(Name, Date);
}

并稍微修改我们的测试:

[Fact]
public void Test1()
{
    // Instead of calling EntityBuilder's constructor, use DomainEntityBuilder
    var entity = new DomainEntityBuilder{ Name = "Valid" }.Build();

    var nameChecker = new EntityNameChecker();

    Assert.True(nameChecker.IsDomainEntityNameValid(entity));
}

测试不再与实体的构造函数紧密耦合。构建器为所有属性提供合理的默认值,并且每个测试仅提供与该特定测试相关的值。作为奖励,可以将方法(或扩展方法)添加到构建器中以帮助设置复杂的场景。

有些库可以帮助解决这类问题。我用过 Bogus in a few different projects. I think AutoFixture 是一个流行的选项,但我自己没有用过。一个简单的构建器很容易实现,所以我建议从自制实现开始,只有当自制实现变得过于乏味或复杂而无法维护时才添加第 3 方库。因为构建器本身就是一个抽象,所以很容易用基于库的实现替换它的实现 if/when 时机成熟。

I have to mock my DomainEntity for my test.

为什么?
这个,你的方法在 "difficult" 方向上消失了。

DomainEntity 有一个接受名称的构造函数,因此您可以使用它来设置测试实例。

[Theory]
[InlineData("Valid", true)]
[InlineData("Not valid", false)]
public void ShouldValidateName(string name, bool expected) 
{
    var entity = new DomainEntity(name);

    var isValid = new EntityNameChecker().IsDomainEntityNameValid(entity);

    isValid.Should().Be(expected); // Pass
}

仅模拟使测试变慢或设置起来非常非常复杂的依赖项。
例如,慢速测试通常是涉及外部资源(网络服务、数据库、文件系统等)的测试。