使用 NUnit 测试深度克隆






4.11/5 (4投票s)
为深度克隆方法编写单元测试
引言
在我工作的领域,.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
中存储引用类型是不好的做法。
本文将考虑两个类:Cat
和CatOwner
。两者都实现了IDeepCloneable<T>
接口。
CatOwner
public class CatOwner : IDeepCloneable<CatOwner>
以CatOwner
对象为例,CatOwner
有一个Name
和Age
属性。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.Object
的Equals
和GetHashCode
方法。这在稍后测试代码时会有影响。
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);
}
我选择避免使用序列化技术进行克隆,因为这种方法速度更快。这样做的权衡是,任何继承自CatOwner
和Cat
的类都负责确保在DeepClone
方法中使用时,任何添加的引用类型都得到正确克隆。如果使用序列化执行深克隆,下面的测试仍然可以工作,但是用NSubstitute创建的模拟对象无法序列化。某些测试需要调整以适应这一点。
Cat
public class Cat : IDeepCloneable<Cat>
下一个要考虑的类是Cat
类。它与CatOwner
类似,只是它有一个CatOwner
属性。
与CatOwner
一样,Cat
类也以类似的方式重写了Equals
和GetHashCode
。
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实现。定义了一个名为IDeepCloneableTests
的abstract
测试类。
public abstract class IDeepCloneableTests<T> where T : class, IDeepCloneable<T>
该类实现了一个名为GetCloneableObject
的abstract
方法,该方法将由IDeepCloneable
的具体实现的任何测试夹具来实现。
protected abstract T GetCloneableObject();
为了确保DeepClone
已正确实现,必须定义多个测试。第一个测试是确保从DeepClone
方法返回的引用不指向被克隆的原始对象。如果原始对象和克隆对象的引用指向内存的同一区域,那么我们就知道DeepClone
没有正确实现。
通常,如果Equals
方法没有被重写,那么简单地调用Assert.AreNotEqual(original, clone)
就足够了。这将默认检查两个引用是否指向内存中的同一地址。由于Cat
和CatOwner
的实现能够打破这种行为,因此应该使用Object.ReferenceEqual(Object, Object)
方法。即使对象没有重写Equals
和GetHashCode
,这也是一个好习惯,因为正在测试的具体类的一个或多个实现的后续可能会发生变化。
[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
类的DeepClone
。CatOwner
不是唯一需要检查的引用类型,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_DeepClonesName
和DeepClone_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);
}
更重要的是,如果对象和克隆的属性不匹配,测试就会失败。这可以通过修改Cat
或CatOwner
类的副本构造函数来验证。
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
测试将不会通过。即使只更改了一个副本构造函数,Cat
和CatOwner
的测试都会失败(因为CatOwner
在CatTests
类中没有被存根。这是因为两个对象的字节数组不再相同。
需要注意的是,如果GetCloneableObejct
方法返回的对象具有任何用NSubstitute存根出的依赖项,DeepClone_ClonedObject_HasIdentitcalByteStreamToOriginal
也会失败。通常,这在编写单元测试时是不好的做法,但有时必须做出例外。
完整的源代码附于本文。