To Test Or Not Test
Shall I unit test value object? Here are some arguments that I found when googling for an answer to the question.
- Value object are so simple that you don’t need to test them.
- Test value object is tedious, basically copy and pasting and you can make the same mistake that you did in the value object itself.
- If you test every other part of the application, you would have tested your value object.
And there are also opposite opinions.
- Value objects are not as simple as you thought. How about initial property value, equals, hashcode and cloneable? (Yes, that reminds me that I made quite some mistakes in value objects)
- Unit test is tedious in nature. Tedious is not a reason for not doing it. But it only means that we need to find a better way.
- This is about unit testing, not integration testing. Value object is a unit that should be tested alone.
In summary, value objects are relatively simple so that the cost of manually writing test cases for them are too high to justify the benefit but not testing them leaving potential bugs.
The Reusable Test Fixture
Compromising the quality is a no in my book so the solution is to develop reusable tests to replace the repetitive and tedious work. This is what programmers are good at after all!
Let’s take a look at a very simple value object.
public class Foo
{
public int Id { get; set; }
public string StringProperty { get; set; }
public long LongProperty { get; set; }
}
Writing a unit test for this class manually is plain boring. I should be able just do it in one line with a reusable test fixture.
[TestFixture] public void FooTest : SomeMagicReusableTestFixture<Foo> {}
This magic reusable test fixture is called NewableValueObjectTestFixture. It sets each property with a few (three minimal) values and read it back to make sure it gets the same value previously set. Of course, it does much more then just that…
Default Property Value
The above example is probably too simple. What if my property has default value? say the Id property is initialized to –1. No problem! The reusable test fixture also verifies the initial value of properties too. It expects all the properties are initialized to the default value of the type, or a value specified by DefaultValueAttribute.
The code below will fail the test because the the default value for int type is 0, not –1.
private int _id = -1;
public int Id
{
get { return _id; }
set { _id = value; }
}
If you intended to initialized to –1, you should tell this to the test fixture. The information is also useful to other .Net tools.
private int _id = -1;
[DefaultValue(-1)]
public int Id
{
get { return _id; }
set { _id = value; }
}
The code above passes the test happily.
Equals and GetHashCode
NewableValueObjectTestFixture and its brother ValueObjectTestFixture test the Equals and GetHashCode methods too. They basically ensure that:
- The value object does not equals to null.
- The value object does not equals an arbitrary object (new object()).
- The value object equals to itself.
- The value objects are not equal if any one of their properties are not equal.
- When two value objects have same property values and Equals method returns true. The GetHashCode methods must return same value.
The implementation in the Object class complies to the above rules so the your value object passes the tests by default. But if you decide to override any of them, the test helps to ensure you have implemented Equals and GetHashCode properly, otherwise the test will fail. Below is generated by Resharper and it passes the test.
public override bool Equals(object obj)
{
if (ReferenceEquals(null, obj)) return false;
if (ReferenceEquals(this, obj)) return true;
if (obj.GetType() != typeof(Foo)) return false;
return Equals((Foo)obj);
}
public bool Equals(Foo other)
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;
return other._id == _id &&
Equals(other.StringProperty, StringProperty) &&
other.LongProperty == LongProperty;
}
public override int GetHashCode()
{
unchecked
{
int result = _id;
result = (result * 397) ^ (StringProperty != null ? StringProperty.GetHashCode() : 0);
result = (result * 397) ^ LongProperty.GetHashCode();
return result;
}
}
A few days later if I add another property to the Foo class and forget to added it to the Equals method, the test fixture will effectively catch it and fails the test. This is exactly what I wanted, how many times did I forget this?
Cloneable
If my value object implements ICloneable, the test fixture will help to check that as well. It clones the object and test all readable properties to make sure the cloned one has the same property values. Below is my typical clone implementation and it works well with the test fixture.
public class Foo : ICloneable
{
public int Id { get; set; }
public string StringProperty { get; set; }
public long LongProperty { get; set; }
object ICloneable.Clone()
{
return Clone();
}
public Foo Clone()
{
return (Foo)MemberwiseClone();
}
}
Type of Property
Since the test fixture needs to generate test data, it had limited support for the type of the properties. For example, a property of interface type would be difficult for ValueObjectTestFixture to create test data of it.
Although, out of the box, ValueObjectTestFixture only support properties of c# build-in data types plus DateTime and TimeSpan, but it provides two extension mechanisms. You can either use a mock framework to generate mock objects for interface type and class type, or override the TestData method to provide your own test data for types that are not supported by the default implementation. Of course, nothing prevents you from making mixed use of both mechanisms.
Future enhancement may have ValueObjectTestFixture support enum, concrete class and/or maybe struct.
Using Mock Framework to Generate Test Data
ValueObjectTestFixture accepts an IMockTestDataProvider instance. I have included an implementation using RhinoMocks. But you can easily do the same using any other mock frameworks. RhinoMockTestDataProvider mocks any interfaces, or classes that are not sealed and has a default constructor. We may be able to remove the requirement of a default constructor in future but for now that is it. :)
Take a look at below value object:
public class ValueObject
{
public IList<string> StringIList { get; set; }
public List<TimeSpan> TimeSpanList { get; set; }
public ICollection<int> IntICollection { get; set; }
public IDictionary<string, object> StringObjectIDictrionary { get; set; }
public Dictionary<int, DateTime> IntDataTimeDictionary { get; set; }
public Component Component { get; set; }
// other properties, equals, hash code, clone and etc.
}
To test this, the test class is as simple as below:
[TestFixture]
public class MockProviderTest : NewableValueObjectTestFixture<ValueObject>
{
public MockProviderTest()
{
MockProvider = new RhinoMockTestDataProvider();
}
}
The complete example can be found in Google code: MockProviderTest.cs
Provide Your Own Test Data
If you have property type that is neither supported out-of-box, nor by a mock framework. You can always provide it yourself by overriding the method TestData. Below is an example from CustomTest.cs:
protected override IEnumerable TestData(PropertyInfo property)
{
// ValueObjectTestFixture doesn't support enum at this moment
// so we have to provide our own test data.
if (property.PropertyType == typeof(Operation))
{
return new[]
{
Operation.Subtraction,
new Operation(),
Operation.Addition,
Operation.Muiplication,
Operation.Division
};
}
return base.TestData(property);
}
Extensibility
While ValueObjectTestFixture makes simple things simpler, it ensures complex things possible too. Here are some additional features that will come handy when testing not so simple value objects.
Value Object without Default Constructor
What if the value object doesn’t have a default constructor? No problem, have the test case inherit from ValueObjectTestFixture. The only difference between ValueObjectTestFixture and NewableValueObjectTestFixture is that the former forces you to override an abstract method: NewValueObject, which you are required to return a new instance of the value object under test. Below example is from CustomTest.cs:
protected override CustomObject NewValueObject()
{
return new CustomObject("TestObject");
}
Test Special Properties Myself
There are times that value object has non-simple properties. For example, when a read only property is initialized by the constructor:
public class CustomObject
{
private readonly string _name;
public CustomObject(string name)
{
_name = name;
}
public string Name { get { return _name; } }
}
Or, when a property is calculated from others:
public int Result
{
get
{
switch (Operation)
{
case Operation.Addition:
return LeftOperand + RightOperand;
case Operation.Subtraction:
return LeftOperand - RightOperand;
case Operation.Muiplication:
return LeftOperand * RightOperand;
case Operation.Division:
return LeftOperand / RightOperand;
default:
throw new InvalidOperationException("Unsupported operation " + Operation);
}
}
}
ValueObjectTestFixture provides two ways to exclude those properties from being tested by it. But then you need to write the test cases yourself.
One way is to use one of the ExcludeProperties methods. Typically you call it in the constructor of your test fixture. Below is from CustomText.cs:
[TestFixture]
public class CustomTest : ValueObjectTestFixture<CustomTest.CustomObject>
{
public CustomTest()
{
// Exclude the Name property from being tested by ValueObjectTestFixture.
ExcludeProperties("Name");
}
// ... ...
}
This completely excludes the property from all the tests administered by ValueObjectTestFixture.
Another less aggressive approach is to override one of the XyzCandidates methods. They are listed below to provide a custom list of properties for the corresponding tests:
- DefaultTestCandidates – default value test.
- ReadWriteTestCandidates – read/write test.
- EqualsTestCandidates – equals and hash code test.
- ClonedProperties – cloneable test.
Example from CustomText.cs:
public override IEnumerable<PropertyInfo> EqualsTestCandidates()
{
// Exclude the Tag property from equality test.
return from p in base.EqualsTestCandidates() where p.Name != "Tag" select p;
}
You opinions is important. Let me know what you think :)
No comments:
Post a Comment