笨笨
独学而无友,则孤陋而寡闻

AN ILLUSTRATIVE EXAMPLE

We're going to consider a simple, somewhat contrived example of a situation where mocks can help. First we will do it the hard way: creating our mocks entirely by hand. Later we will explore excellent tools for automating much of the business of mock creation and setup.

Our example involves writing an adventure game in which the player tries to rid the world of foul creatures such as Orcs. When a player attacks an Orc they roll a 20-sided die to see if they hit or not. A roll of 13 or higher is a hit, in which case they roll the 20-sided die again to determine the effect of the hit. If the initial roll was less than 13, they miss.

Our task at the moment is to test the code in Player that governs this process. Here's Player (I know, we aren't test driving this code. ..assume we inherited it):

 1 public classPlayer {
 2   Die myD20 = null;
 3 
 4   public Player(Die d20) {
 5     myD20 = d20;
 6   }
 7 
 8   public boolean attack(Orc anOrc) {
 9     if (myD20.roll() 
10 >= 13) {
11       return hit(anOrc);
12     } else {
13       return miss();
14     }
15   }
16 
17   private boolean hit(OrcanOrc) {
18     anOrc.injure(myD20.roll());
19     return true;
20   }
21 
22   private boolean miss() {
23     return false;
24   }
25 }
26 

Here's the initial take at a test. We'll start simply with the case where the attack misses. We'll assume that a Die class already exists:

 1 public class Die {
 2   private int sides = 0;
 3   private Random generator = null;
 4 
 5 
 6 public Die(int numberOfSides) {
 7   sides = numberOfSides;
 8   generator=newRandom();
 9 }
10 
11 public int roll() {
12   return generator.nextInt(sides) + 1;
13   }
14 }
15 

Here's a first stab at the test for a missed attack:

1 public void testMiss() {
2   Die d20 = new Die(20);
3   Player badFighter = new Player(d20);
4   Orc anOrc = new Orc();
5   assertFalse("Attack should have missed.",badFighter.attack(anOrc));
6 }
7 

The problem is that there is a random number generator involved. Sometimes the test passes, other times it fails. We need to be able to control the return value in order to control the preconditions for the test. This is a case where we cannot (or rather, should not) get the actual test resource into the state we need for the test. So instead, we use a simple mock object for that. Specifically, we can mock the Die class to return the value we want. But first we need to extract an interface from Die, and use it in place of Die:[1]

[1] There are other ways of approaching this, such as creating a subclass that returns the constant, but I like working with interfaces.

 1 public interface Rollable {
 2   int roll();
 3 }
 4 
 5 
 6 public class Die implements Rollable {
 7 //. . .
 8 }
 9 
10 public classPlayer {
11   Rollable myD20 = null;
12   public Player(Rollable d20) {
13     myD20 = d20;
14   }
15   //. . .
16 }
17 

Now we can create a mock for a 20-sided die that always returns a value that will cause an attack to miss, say, 10:

1 public class MockD20FailingAttack implements Rollable {
2   public int roll() {
3     return 10;
4   }
5 }
6 

Now we use the mock in our test:

1 public void testMiss() {
2   Rollable d20 = new MockD20FailingAttack();
3   Player badFighter = new Player(d20);
4   Orc anOrc = new Orc();
5   assertFalse("Attack should have missed.",badFighter.attack(anOrc));
6 }
7 

There, the test always passes now. Next, we write a corresponding test with a successful attack:

1 public void testHit() {
2   Rollable d20 = new MockD20SuccessfulAttack();
3   Player goodFighter = new Player(d20);
4   Orc anOrc = new Orc();
5   assertTrue("Attack should have hit.",goodFighter.attack(anOrc));
6 }
7 

This requires a new mock:

1 public class MockD20SuccessfulAttack implements Rollable {
2   public int roll() {
3     return 18;
4   }
5 }
6 

Now, these two mocks are almost identical, so we can refactor and merge them into a single parameterized class:

 1 public class MockDie implements Rollable {
 2   private int returnValue;
 3 
 4   public MockDie(int constantReturnValue) {
 5     returnValue = constantReturnValue;
 6   }
 7 
 8   public int roll() {
 9     return returnValue;
10   }
11 }
12 

Our tests are now:

 1 public void testMiss() {
 2   Rollable d20 = new MockDie(10);
 3   Player badFighter = new Player(d20);
 4   Orc anOrc = new Orc();
 5   assertFalse("Attack should have missed.",badFighter.attack(anOrc));
 6 }
 7 
 8 public void testHit() {
 9   Rollable d20 = new MockDie(18);
10   Player goodFighter = new Player(d20);
11   Orc anOrc = new Orc();
12   assertTrue("Attack should have hit.",goodFighter.attack(anOrc));
13 }
14 

Next, we want to write tests for the cases where an attack hurts the Orc, and where it kills it. For this we will need to extend MockDie so that we can specify a sequence of return values (successful attack followed by the amount of damage). In order to maintain the current behavior, MockDie repeatedly loops through the return value sequence as roll() is called.

 1 public class MockDie implements Rollable {
 2   private Vector returnValues = new Vector();
 3   private int nextReturnedIndex = 0;
 4 
 5   public MockDie() {
 6   }
 7 
 8   public MockDie(int constantReturnValue) {
 9     addRoll(constantReturnValue);
10   }
11 
12   public void addRoll(int returnValue) {
13     returnValues.add(new Integer(returnValue));
14   }
15 
16   public int roll() 
17 {
18     int val = ((Integer)returnValues.get(nextReturnedIndex++)).intValue();
19     if (nextReturnedIndex >=returnValues.size()) {
20       nextReturnedIndex = 0;
21     }
22     return val;
23   }
24 }
25 

Using the Rollable interface allows us to easily and cleanly create mocks without impacting the Player class at all. We can see this in the class diagram shown in Figure 7.1.

Figure 7.1. Class diagram of the Player and Die aspects of the example.





So far we've written simple mocks, really just stubs that return some predefined values in response to a method call. Now we'll start writing and using a real mock, one that expects and can verify specific calls. Again, so far, we're doing it all by hand.

First, consider this implementation of Orc and Game:

 1 public class Orc {
 2   private Game game = null;
 3   private int health = 0;
 4 
 5   public Orc (Game theGame, int hitPoints) 
 6 {
 7     game = theGame;
 8     health = hitPoints;
 9   }
10 
11   public void injure(int damage) {
12     health 
13 -=damage;
14     if (health 
15 <=0) {
16       die();
17     }
18   }
19 
20   private void die() {
21     game.hasDied(this);
22   }
23 
24   public boolean isDead() {
25     return health <=0;
26   }
27 }
28 
29 public interfaceGame {
30   void hasDied(Orc orc);
31 }
32 

Now we can write tests like this:

 1 public void testNoKill() 
 2 {
 3   MockGame mockGame = new MockGame();
 4   Orc strongOrc = new Orc(mockGame, 30);
 5 
 6   MockDie d20 = new MockDie();
 7   d20.addRoll(18);
 8   d20.addRoll(10);
 9 
10   Player fighter = new Player(d20);
11   fighter.attack(strongOrc);
12   assertFalse("The orc should not have died.",strongOrc.isDead());
13   mockGame.verify();
14 }
15 
16 public void testKill() {
17   MockGame mockGame = new MockGame();
18   Orc weakOrc = new Orc(mockGame, 10);
19   mockGame.expectHasDied(weakOrc);
20 
21   MockDie d20 = new MockDie();
22   d20.addRoll(18);
23   d20.addRoll(15);
24 
25   Player fighter = new Player(d20);
26   fighter.attack(weakOrc);
27   assertTrue("The orc should be dead.",weakOrc.isDead());
28   mockGame.verify();
29 }
30 

The one thing that is missing is MockGame. Here it is:

 1 public class MockGame implements Game 
 2 {
 3   private Orc deadOrc = null;
 4   private Orc orcExpectedToDie = null;
 5 
 6   public void hasDied(Orc orc) {
 7     if (orc != orcExpectedToDie) {
 8       Assert.fail("Unexpected orc died.");
 9     }
10 
11     if (deadOrc != null) {
12       Assert.fail("Only expected one dead orc.");
13     }
14 
15     deadOrc = orc;
16   }
17 
18   public void expectHasDied(Orc doomedOrc) {
19     orcExpectedToDie = doomedOrc;
20   }
21 
22 
23 
24 
25   public void verify () {
26     Assert.assertEquals("Doomed Orc didn't die.",orcExpectedToDie, deadOrc);
27   }
28 }
29 

When an Orc dies it reports the fact to the game. In the case of the tests, this is a MockGame that checks that the dead orc is the one that was expected and that this is the only dead orc so far. If either of these checks fail, then the mock causes the test to fail immediately by calling Asert.fail().When the mock is set up by the test it is told what orc it should expect to die. At the end of the test the MockGame can be asked to verify that the expected Orc died.

This is a trivial mock, but it should give you a taste of what is possible. Figure 7.2 shows this part of the class diagram, while Figure 7.3 shows the sequence diagram for testKill().

Figure 7.2. Class diagram of the Orc and Game aspects of the example.




Figure 7.3. Sequence diagram of the testKill() test.





You can now easily see that as our mocks get more complex, they get harder to write and maintain, if we write them from scratch each time. The folks that evolved the mock objects concept found this out quickly, and as happens when you find yourself writing similar code a lot, they developed a framework for creating mocks and tools to make the job easier. Again, we'll have more on that later.

posted on 2005-11-08 15:44  笨笨  阅读(310)  评论(0)    收藏  举报