« 12. Design Principle #4 - Multiple Failures | Main | 10. Design Principle #2 - Independent Test »

November 22, 2004

11. Design Principle #3 - Don't Repeat Yourself (DRY)

Definition

Minimize test code duplication to lower test maintenance costs.

Discussion

The evils of code duplication have been documented extensively in software development literature.10 The same principles that apply to application development also apply to test development. Namely, just as it becomes a maintenance problem to modify application code in multiple places, the same problem occurs when tests need modification in multiple places. Many of the patterns mentioned in this paper (e.g. Template, Object Genie, DTO, Transporter) minimize code duplication by consolidating common test code into one place instead of duplicating it. The advantage of this approach is that it becomes easier to update tests as the application changes, which in turn lowers the maintenance costs of tests.

Example

To test the coupon feature of the application, we need tests for submitting a valid coupon, an expired coupon, and an invalid coupon. In order to test these scenarios, we need to load our cart with items to which the coupon would apply. This would be the setup part of the test (see Independent Test pattern), since before we could test the checkout process, we'd have to have a cart with items in it already. If we don't adhere to DRY, we would simply copy and paste previous test code that searched for an item(s) and inserted it into the cart. Figures 11 and 12 illustrate non-DRY and DRY approaches, respectively.


Figure 11: Repeating test code for coupon test cases.

import junit.framework.*;

public class CouponTest extends TestCase {
public CouponTest(String pName) { super(pName); }
public static Test suite() { return new TestSuite(CouponTest.class); }
public void testValidCoupon() {
login();
searchForItem1();
addItem1ToCart();
searchForItem2();
addItem2ToCart();
gotoCheckout();
applyValidCoupon();
checkCouponAccepted();
}
public void testExpiredCoupon() {
login();
searchForItem1();
addItem1ToCart();
searchForItem2();
addItem2ToCart();
gotoCheckout();
applyExpiredCoupon();
checkCouponRejected();
}
public void testInvalidCoupon() {
login();
searchForItem1();
addItem1ToCart();
searchForItem2();
addItem2ToCart();
gotoCheckout();
applyInvalidCoupon();
checkCouponRejected();
}
//tool-specific implementations of methods (e.g. login(), addItem2ToCart(), etc.)
}

This non-DRY approach is recommended by some due to its inherent simplicity and explicitness.11 The argument is that if a test developer tries to get too fancy with their test code, they will have to start writing tests for the test code, which can quickly get out of hand. However, writing more maintainable code does not mean that the code has to get more complex.

Using DRY, we have several options: 1) use the Template pattern to extract the act of filling up your cart to a super class, 2) use an Object Genie to grant us a cart with items already in it, 3) extract the setup code into a set up method so that each test case uses the same setup code, and 4) combine the above methods.

Template Pattern
Using a Template pattern, we can extract all the setup code into a super class and have each subclass implement a few small methods. All the common code is encapsulated in the super class and the subclasses don't have to concern themselves of dealing with it. The disadvantage, though, is that a separate subclass has to be created for each test case.

Object Genie
An Object Genie can grant us a cart with items in it anytime we want it. The advantage of this technique is that we are not forced to use subclasses. We just request the cart with items in it at the appropriate time.

Setup Method
JUnit supports Setup Methods that allow a test writer to place all common setup code into one place. The setup code gets executed before each test case is run. This is a good start to avoiding duplication, but often the code that gets factored out into the Setup Method could be duplicated in other Setup Methods for other tests. In our example, the setup code of filling a cart with items is needed by other tests, as well. For instance, to test cart manipulation functions (adding, removing, modifying items in the cart) we would need to have a cart with items in it as setup code. This implies that the setup code should be located in a more central location, as outlined in the previous two examples.

Combining Patterns
Filling up a cart is a common test step that is useful to many customer tests. The three pattern options to address this step can be used in combination to yield a greater benefit. Figure 12 combines the use of an Object Genie and a Setup Method. Other combinations are possible as well, including using all three patterns. We use the Setup Method to login, call the Object Genie, and go to the checkout page. The Object Genie returns a shopping cart with items in it. The result is that each test case only has to worry about entering a specific coupon and checking whether the coupon was accepted or rejected.


Figure 12: Consolidating test code using Object Genie and Setup Method.

import junit.framework.*;

public class CouponTest extends TestCase {
public CouponTest(String pName) { super(pName); }
public static Test suite() { return new TestSuite(CouponTest.class); }
protected void setUp() {
login();
ShoppingCart shoppingCart = CartGenie.getNonEmptyCart();
gotoCheckout();
}
public void testCheckoutWithValidCoupon() {
applyValidCoupon();
checkCouponAccepted();
}
public void testCheckoutWithExpiredCoupon() {
applyExpiredCoupon();
checkCouponRejected();
}
public void testCheckoutWithInvalidCoupon() {
applyInvalidCoupon();
checkCouponRejected();
}
//implementations of methods (e.g. login(), addItem2ToCart(), etc.)
}

We have now significantly reduced the size of each test, eliminated test code duplication and reduced future test code maintenance costs.

Posted by Misha Rybalov at November 22, 2004 05:25 PM

Comments

Post a comment




Remember Me?