One of the biggest pitfalls developers writing unit tests face is in the face of a re-design. They write hundreds of unit tests for various classes, and suddenly a design change breaks many of them - a default constructor is removed, parameters are added or removed from interfaces, various default conditions change - havoc rears its ugly head.
The poor developer is then faced with "fixing" hundreds of tests just because a method was added to a constructor.
To remove this obstacle from the developer one must take the precaution in writing the tests. There are several rules you should abide by that will save your hiny on most events where such a change occurs:
here are some guidelines:
- Encapsulate object creation code in helper methods inside your test fixture. If just your fixture is creating these objects - these methods should reside as private methods on the fixture. If more classes use them, they should be refactored out into a separate helper class with static helper methods for this creation (also known as the "Object Mother" pattern).
for example:
[Test]
public void Sum_ReturnsZeroByDefault()
{
Calculator calc = new Calculator();
int result = calc.Sum();
...
}
should be refactored into:
[Test]
public void Sum_ReturnsZeroByDefault()
{
Calculator calc = setup_CreateCalculator();
int result = calc.Sum();
...
}
private Calcualtor setup_CreateCalcualtor()
{
return new Calculator();
}
notice the following:
- After this change, any changes to the Calculator's public constructor should only propagate to a small "fix" in one private method in the fixture, used by many tests.
- The helper method has a special prefix "setup_" to denote that it is a helper method used to setup object instances for test cases. It also makes your test more readable.
If multiple test fixture need to use the Calculator object, the code might turn into:
[Test]
public void Sum_ReturnsZeroByDefault()
{
Calculator calc = setup_CreateCalculator();
int result = calc.Sum();
...
}
private Calcualtor setup_CreateCalcualtor()
{
return CalctulatorTestHelper.CreateDefaultCalculator();
}
public class CalctulatorTestHelper
{
public static Calculator CreateDefaultCalculator()
{
return new Calculator();
}
}
notice the following:
- We still keep the original setup_ helper method so that we change as little code as we need to in the old fixture. We just delegate the work into the CalculatorHelperobject. This level of indirection might not be needed but might help us in the future if we find we need to actually make a change to the CalculatorHelper class.
- None of this code sits in production, but is only related to the testing project
- Encapsulate complex or lengthy object initialization code or complex interaction code into helper methods in your fixture or helper objects. If you find that you have two tests that initialize an object's state to the same state - refactor that code into a different method that returns the object with the known state. This helps future changes to the various object methods and default state behavior not break your tests and you bursting into tears.
for example:
[Test]
[ExpectedException(typeof(Exception),"User cannot be added twice")
public void AddUser_AddingSameUserTwiceThrowsException()
{
LoginManager lm = setup_CreateDefaultLoginManager();
lm.AddUser("a","b");
//exception should be thrown here
lm.AddUser("a","");
}
[Test]
public void ChangePassword_AddingSameUserTwiceThrowsException()
{
LoginManager lm = setup_CreateDefaultLoginManager();
lm.AddUser("a","b");
lm.ChangePassword("a","c");
bool isLoginOk = lm.IsLoginOK("a","c");
Assert.IsTrue(isLoginOk,"Login should have been accepted for user with the new password"
}
private LoginManager setup_CreateDefaultLoginManager()
{
return new LoginManager();
}
should be replaced with something along the lines of:
[Test]
[ExpectedException(typeof(Exception),"User cannot be added twice")
public void AddUser_AddingSameUserTwiceThrowsException()
{
LoginManager lm = setup_CreateLoginManagerIwhtOneDefaultUser()
//exception should be thrown here
lm.AddUser("a","");
}
[Test]
public void ChangePassword_AddingSameUserTwiceThrowsException()
{
LoginManager lm = setup_CreateLoginManagerIwhtOneDefaultUser
lm.ChangePassword("a","c");
bool isLoginOk = lm.IsLoginOK("a","c");
Assert.IsTrue(isLoginOk,"Login should have been accepted for user with the new password"
}
private LoginManager setup_CreateLoginManagerIwhtOneDefaultUser()
{
LoginManager lm = setup_CreateDefaultLoginManager();
lm.AddUser("a","b");
return lm;
}
some points on this:
- You could also use various helper methods that take parameters (for example, a login manager with N users already added to it to check various counts
- You should use this method whenever you find duplication in your tests. Don't do it until you see duplication in your test code. Your primary concern is to make the test work, then refactor the test code as well as the production code.
- If you continue on with this method your tests will become very robust and be able to adept to new requirements in your code with little effort.
- Note that this method helps when the design change does not affect the logic behind your test. For example, the method of adding a user to LoginManager might change, but Changing a password for the user (as depicted above) should still be tested the same. If the design affects your test logic - there's no escape but changing the test. You should be very wary of changing your tests just to make them pass. Sometimes they break because they are telling you that you broke something in your logic.