65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 NUnit 测试深度克隆

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.11/5 (4投票s)

2016 年 5 月 13 日

CPOL

8分钟阅读

viewsIcon

24917

downloadIcon

76

为深度克隆方法编写单元测试

引言

在我工作的领域,.NET类中只包含信息是很常见的。这些类通常会实现自己的Object.Equals方法,因此也需要实现Object.GetHashCode。它们也偶尔需要被克隆。对于只包含值类型对象的简单对象,使用Object.MemberwiseClone就能很好地完成工作(将类定义为struct也能达到目的)。当处理包含其他引用类型的更复杂对象时,Object.MemberwiseClone不适合执行深度克隆。相反,必须编写自定义的深度克隆方法。为了确保深度克隆被正确实现,可以精心构造许多单元测试来确保执行的是真正的深度克隆。

背景

本文假定读者了解NUnit单元测试的基础知识。其中一些示例还包含NSubstitute的调用片段。读者了解浅克隆和深克隆的区别非常重要。关于这两种概念的区别,我强烈建议阅读这篇文章

待测试代码

IDeepCloneable

建议程序员避免使用.NET Framework中定义的ICloneable接口(请参见这篇文章)。该接口含糊不清,因为使用ICloneable实例的代码无法知道克隆产生的将是深克隆还是浅克隆。与其依赖ICloneable,不如定义一个新接口IDeepCloneable

public interface IDeepCloneable<T> where T: class
{
    T DeepClone();
}

该接口是基于泛型类实现的。它被限制为类,因为深克隆struct意义不大。每次struct被赋给新变量时,它都有效地被浅克隆(按值传递)。包含引用的struct可以被深克隆,但我认为在struct中存储引用类型是不好的做法。

本文将考虑两个类:CatCatOwner。两者都实现了IDeepCloneable<T>接口。

CatOwner

public class CatOwner : IDeepCloneable<CatOwner>

CatOwner对象为例,CatOwner有一个NameAge属性。Name是引用类型对象(一个string),而Age是值类型(一个integer)。还有一个用于设置CatOwner对象的构造函数。

public virtual string Name
{
    get;
    private set;
}

public virtual int Age
{
    get;
    private set;
}
public CatOwner(string name, int number)
{
    if (string.IsNullOrEmpty(name))
        throw new ArgumentNullException("name");

    this.Name = name;
    this.Age = number;
}

关于CatOwner的一个重要说明是,它重写了System.ObjectEqualsGetHashCode方法。这在稍后测试代码时会有影响。

public override bool Equals(object obj)
{
    if (obj == null || !(obj is CatOwner))
        return false;

    return this.Equals(obj as CatOwner);
}

protected virtual bool Equals(CatOwner catOwner)
{
    if (!(catOwner.Age == this.Age) || !this.Name.Equals(catOwner.Name))
        return false;

    return true;
}

public override int GetHashCode()
{
    return Age.GetHashCode() ^ Name.GetHashCode();
}

在此,DeepClone是使用副本构造函数实现的。请注意,String.Copy用于复制Name属性。这确保了深度克隆接收的是原始对象名称的*值*,而不是原始对象名称的引用。

protected CatOwner(CatOwner catOwner)
{
    if (catOwner == null)
        throw new ArgumentNullException("catOwner");

    this.Name = String.Copy(catOwner.Name);
    this.Age = catOwner.Age;
}

public virtual CatOwner DeepClone()
{
    return new CatOwner(this);
}

我选择避免使用序列化技术进行克隆,因为这种方法速度更快。这样做的权衡是,任何继承自CatOwnerCat的类都负责确保在DeepClone方法中使用时,任何添加的引用类型都得到正确克隆。如果使用序列化执行深克隆,下面的测试仍然可以工作,但是用NSubstitute创建的模拟对象无法序列化。某些测试需要调整以适应这一点。

Cat

public class Cat : IDeepCloneable<Cat>

下一个要考虑的类是Cat类。它与CatOwner类似,只是它有一个CatOwner属性。

CatOwner一样,Cat类也以类似的方式重写了EqualsGetHashCode

public override bool Equals(object obj)
{
    if (obj == null || !(obj is Cat))
        return false;

    return this.Equals(obj as Cat);
}

protected virtual bool Equals(Cat cat)
{
    if (!(cat.Age == this.Age) || !this.Name.Equals(cat.Name) || !this.CatOwner.Equals(cat.CatOwner))
        return false;

    return true;
}

public override int GetHashCode()
{
    return CatOwner.GetHashCode() ^ Age.GetHashCode() ^ Name.GetHashCode();
}

DeepClone方法也通过副本构造函数实现。

protected Cat(Cat cat)
{
    if (cat == null)
        throw new ArgumentNullException("cat");

    this.CatOwner = cat.CatOwner.DeepClone();
    this.Name = String.Copy(cat.Name);
    this.Age = cat.Age;
}

public virtual Cat DeepClone()
{
    return new Cat(this);
}

它与CatOwner类的DeepClone方法非常相似。请注意,它利用了CatOwner对象的DeepClone方法。

测试代码

测试使用NUnit和NSubstitute实现。定义了一个名为IDeepCloneableTestsabstract测试类。

public abstract class IDeepCloneableTests<T> where T : class, IDeepCloneable<T>

该类实现了一个名为GetCloneableObjectabstract方法,该方法将由IDeepCloneable的具体实现的任何测试夹具来实现。

protected abstract T GetCloneableObject();

为了确保DeepClone已正确实现,必须定义多个测试。第一个测试是确保从DeepClone方法返回的引用不指向被克隆的原始对象。如果原始对象和克隆对象的引用指向内存的同一区域,那么我们就知道DeepClone没有正确实现。

通常,如果Equals方法没有被重写,那么简单地调用Assert.AreNotEqual(original, clone)就足够了。这将默认检查两个引用是否指向内存中的同一地址。由于CatCatOwner的实现能够打破这种行为,因此应该使用Object.ReferenceEqual(Object, Object)方法。即使对象没有重写EqualsGetHashCode,这也是一个好习惯,因为正在测试的具体类的一个或多个实现的后续可能会发生变化。

[Test]
public void DeepClone_ClonedObject_HasDifferntMemoryAddressThanOriginal()
{
    T objectUnderTest = this.GetCloneableObject();
    T clonedObject = objectUnderTest.DeepClone();

    bool referenceEquals = Object.ReferenceEquals(objectUnderTest, clonedObject);

    Assert.False(referenceEquals);
}

要查看此测试如何在具体实现上工作,请查看CatOwnerTests类。

[TestFixture]
public class CatOwnerTests : IDeepCloneableTests<CatOwner>
{
    protected override CatOwner GetCloneableObject()
    {
        return new CatOwner("Danny", 23);
    }
}

运行此测试时,您会看到它通过了。同样重要的是要看到它失败。最直观的方法是像这样调整CatOwner.DeepClone方法。这里测试失败是因为Clone返回了内存中同一对象的引用。

public virtual CatOwner DeepClone()
{
    //return new CatOwner(this);
    return this;
}

可以在Cat类中编写相同的测试。

protected override Cat GetCloneableObject()
{
    // Normally I would stub this out with NSubstitute
    // But NSubstitute fake objects cannot be serialized.
    CatOwner stubCatOwner = new CatOwner("Danny", 23);

    return new Cat(stubCatOwner, "Dr. Piddles", 4);
}

此测试也通过了,并且在您将CatOwner DeepClone方法更改为返回this而不是新的、克隆的Cat对象时会失败。一个重要的观察结果是,CatOwner类中损坏的DeepClone方法只会破坏CatOwner类的测试,而不会破坏Cat类的测试(即使它应该是一个深度克隆)。这表明CatTests测试夹具中缺少一个测试。

[Test]
public void DeepClone_CatClone_DeepClonesCatOwner()
{
    CatOwner mockCatOwner = Substitute.ForPartsOf<CatOwner>("NotDanny", 9);
    Cat catUnderTest = new Cat(mockCatOwner, "Sir Bottomsworth", 5);

    catUnderTest.DeepClone();

    mockCatOwner.Received().DeepClone();
}

CatOwner类的DeepClone方法损坏时,此测试仍然通过,但这不应由CatTests类负责确保CatOwner类正常工作。然而,它负责确保其所有引用类型都被深度克隆。为此,只需要在克隆Cat时调用CatOwner类的DeepCloneCatOwner不是唯一需要检查的引用类型,Name也需要被克隆。无法模拟String类,但可以使用ReferenceEquals方法来确保对象被克隆。

[Test]
public void DeepClone_CatClone_DeepClonesName()
{
    CatOwner mockCatOwner = Substitute.For<CatOwner>("NotDanny", 9);
    Cat catUnderTest = new Cat(mockCatOwner, "Captain Whiskers", 7);
    Cat catClone = catUnderTest.DeepClone();

    bool referenceEquals = Object.ReferenceEquals(catUnderTest.Name, catClone.Name);

    Assert.False(referenceEquals);
}

可以在CatOwnerTests类中复制相同的测试。

[Test]
public void DeepClone_CatOwnerClone_ClonesCatOwnerName()
{
    CatOwner catOwnerUnderTest = new CatOwner("Mr. Biscuits", 38);
    CatOwner catOwnerClone = catOwnerUnderTest.DeepClone();

    bool referenceEquals = object.ReferenceEquals(catOwnerUnderTest.Name, catOwnerClone.Name);

    Assert.False(referenceEquals);
}

所有这些测试都通过了。要使它们失败,可以通过修改副本构造函数来仅复制类的引用,而不是执行应有的深度克隆。下面是Cat副本构造函数的一个修改版本,以展示不正确的实现是什么样的。

protected Cat(Cat cat)
{
    if (cat == null)
        throw new ArgumentNullException("cat");

    //this.CatOwner = cat.CatOwner.DeepClone();
    //this.Name = String.Copy(cat.Name);
    this.CatOwner = cat.CatOwner;
    this.Name = cat.Name;

    this.Age = cat.Age;
}

当使用此构造函数而不是正确的构造函数运行测试时,您可以看到DeepClone_CatClone_DeepClonesNameDeepClone_CatClone_DeepClonesCatOwner测试都会失败。当执行浅克隆而不是深克隆时,也可以显示测试失败。

public virtual CatOwner DeepClone()
{
    //return new CatOwner(this);
    return (CatOwner)this.MemberwiseClone(); // Please don't do this
}
public virtual Cat DeepClone()
{ 
    //return new Cat(this); 
    return (Cat)this.MemberwiseClone();
}

如果存在其他引用类型,也需要以类似的方式进行测试。虽然这可能很重复,但我不知道有任何通用的方法可以测试深度克隆是否递归地深度克隆了对象的所有属性(如果您能想到任何有效的方法,请告知我)。

还有一件事需要测试。当一个对象被克隆时,克隆也必须与原始对象完全相同。一种方法是回顾每个测试类并确保所有属性都匹配(最好是每个属性一个测试)。一个更简单的方法是比较两个克隆对象和原始对象在内存中的数据,并确保它们是相同的。这可以通过序列化来实现。回到IDeepCloneableTest类,有一个支持方法可以将IDeepCloneable对象转换为字节数组。

private byte[] SerializeObjectToByteArray(T value)
{
    BinaryFormatter formatter = new BinaryFormatter();
    using (MemoryStream stream = new MemoryStream())
    {
        formatter.Serialize(stream, value);
        return stream.ToArray();
    }
}

为了使此方法生效,所有实现IDeepCloneable并正在被测试的类都必须用Serializable属性标记。否则,IDeepCloneableTests类中的以下测试将失败。

IDeepCloneableTest类中,下一个测试是获取IDeepCloneable的新实例,并将其转换为字节数组。然后使用DeepClone方法克隆对象,并将克隆体转换为字节数组。如果两个字节数组相同,则测试通过。当对象被浅克隆时,此测试仍然通过,因此它仅在与其他单元测试结合使用时才有效。

[Test]
public void DeepClone_ClonedObject_HasIdentitcalByteStreamToOriginal()
{
    T objectUnderTest = this.GetCloneableObject();
    T clone = objectUnderTest.DeepClone(); 
    
    byte[] objectUnderTestAsBytes = this.SerializeObjectToByteArray(objectUnderTest);
    byte[] cloneAsBytes = this.SerializeObjectToByteArray(clone);

    Assert.AreEqual(objectUnderTestAsBytes, cloneAsBytes);
}

更重要的是,如果对象和克隆的属性不匹配,测试就会失败。这可以通过修改CatCatOwner类的副本构造函数来验证。

protected CatOwner(CatOwner catOwner)
{
    if (catOwner == null)
        throw new ArgumentNullException("catOwner");

    this.Name = "This sure isn't the way to perform a deep clone.";
    //this.Name = String.Copy(catOwner.Name);
    this.Age = catOwner.Age;
}

使用错误的副本构造函数运行测试会证实,如果对象没有被正确克隆,DeepClone_ClonedObject_HasIdentitcalByteStreamToOriginal测试将不会通过。即使只更改了一个副本构造函数,CatCatOwner的测试都会失败(因为CatOwnerCatTests类中没有被存根。这是因为两个对象的字节数组不再相同。

需要注意的是,如果GetCloneableObejct方法返回的对象具有任何用NSubstitute存根出的依赖项,DeepClone_ClonedObject_HasIdentitcalByteStreamToOriginal也会失败。通常,这在编写单元测试时是不好的做法,但有时必须做出例外。

完整的源代码附于本文。

© . All rights reserved.