These days, it’s common practice to unit-test our code. Whether you’re a TDD, or simply doing white box / black box unit-testing post coding, the final act of having a unit test in place to test conformancy to certain requirements is good practice.
But what requirements should we be testing?
The common approach is to test the functionality of a unit of code, i.e. for a particular set of inputs, test for the expected output(s). This is good.
But with OO based-design, and win-forms data-binding, I believe we need to go one step further. We need to test the public interface declaration – the classes contract to the outside world. We need to test that the public interface for both the getters and setters of a class do not change scope or name.
Why do I say name? Win-Forms data-binding is done via a String parameter which specifies the member name to be bound.
If a member name is changed (either manually or F2), more often than not, the data-binding that is bound to that member will not be updated, the program will compile successfully, and the problem will not be found until run-time (hopefully found before the product is shipped).
So how can we test that our public interface has not changed? By using Reflection, we can write a unit-test helper class that will allow us to test the public interface declaration. The goals I’ve set for this PropertyTester is that it should:
- Fail if a property’s getter or setter change’s scope
- Fail if a property’s name changes
- Fail if a property is not tested
Property Tester Helper Class
public class PropertyTester<T> : IDisposable where T : class { /// <summary> /// Creates an instance of the <c>PropertyTester</c> class. /// This class allows you to check that the setters and getters for properties on the /// target are as expected. When used in a using statement, the Dispose call will /// confirm that all properties were tested. /// </summary> /// <param name="target">The target object of interest</param> public PropertyTester() { // Store away the target this.target = typeof(T); // Create a list of all the properties expected to be tested. const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance; this.targetPropertyList = new List<PropertyInfo>(target.GetProperties(flags)); } /// <summary> /// Checks that the property getters and setters accessors for an object are as expected. /// </summary> /// <param name="memberName">The property of the object to be tested.</param> /// <param name="canRead">Whether we expect that we can read this property.</param> /// <param name="canWrite">Whether we expect that we can write to this property.</param> public void CheckGetterSetter(string propertyName, bool canRead, bool canWrite) { // Confirm that the getters and setters for the property are correct. // Get a handle to the Property of the interested target. PropertyInfo property = this.targetPropertyList.FirstOrDefault(p => p.Name == propertyName); if (property == null) { // If the test code fails here, then a Property name has changed on the target. // It is important that ALL GUI code that refers to this Property name is updated. // NOTE: The compiler may not find all of the GUI references for this Property name // as the ObjectListView class uses reflection for setting/getting values. this.failure = true; String failed_message = String.Format("Property \"{0}\" does not exist for object \"{1}\"", propertyName, target.ToString()); Assert.Fail(failed_message); } // Confirm the existance of the public getter. if (canRead != (property.CanRead && property.GetGetMethod() != null)) { this.failure = true; string failed_message = String.Format("Property \"{0}\": expected getter is {1}, actual getter is {2}.", propertyName, canRead, !canRead); Assert.Fail(failed_message); } // Confirm the existance of the public setter. if (canWrite != (property.CanWrite && property.GetSetMethod() != null)) { this.failure = true; string failed_message = String.Format("Property \"{0}\": expected setter is {1}, actual setter is {2}.", propertyName, canWrite, !canWrite); Assert.Fail(failed_message); } // Remove properties with this name from the targetPropertyList list. // Note: We're removing all instances of properties with the name defined // in the 'memberName' parameter as there could be more than one, as // is the case when properties can be redefined in a sub-class with // the 'new' keyword. this.targetPropertyList.RemoveAll(p => p.Name == propertyName); } /// <summary> /// Confirms that all property checker/getters were tested on the target. /// </summary> public void Dispose() { // If a failure hasn't occured during the test and the target property list // still contains objects, then this means some properties were not correctly tested. if ((this.failure == false) && (targetPropertyList.Count > 0)) { string failed_message = ""; foreach (PropertyInfo pinfo in this.targetPropertyList) { failed_message += String.Format("Property \"{0}\" was not tested for object \"{1}\"\r\n", pinfo.Name, this.target.ToString()); } Assert.Fail(failed_message); } } /// <summary> /// The target object to be tested. /// </summary> private Type target = null; /// <summary> /// The list of properties for the target expected to be tested. /// </summary> private List<PropertyInfo> targetPropertyList = null; /// <summary> /// Whether a failure has been detected or not. /// </summary> private bool failure = false; }
Example
To use this PropertyTester, let’s build a simple Person class
/// <summary> /// Example Person class to demonstrate how to use the PropertyTester /// <summary> public class Person { public string Name { get; private set; } public int Age { get; set; } }
Then in your unit-testing code, all we need to do is
/// <summary> /// Tests the properties getters and setters access to the Person Class. /// </summary> [TestMethod] public void TestPropertyGetterSetter() { // Create a PropertyTester to facilitate the testing of the getters/setters. using (PropertyTester<Person> propTester = new PropertyTester<Person>()) { // Check the expected access to each property. propTester.CheckGetterSetter("Name", true, true); propTester.CheckGetterSetter("Age", true, false); } }