Building Cost-Effective Specifications with StoriesOne good way to develop software requirements incrementally is in the manner advocated by extreme programming (XP), an agile software-development method that has become quite popular over the past few years. The name comes from the concept that many commonly accepted and uncontroversial development practices that are usually executed alone (such as testing, incremental development, pair programming, etc.) can create a synergistic effect when all practiced together in a radical, or extreme, form. Let's focus on the way in which XP teams specify the functionality of their software. (More on XP in Chapter 3: Debugging and the Development Process.)
In an XP project, the required functionality of a system is specified incrementally through the use of stories. Each story briefly describes one aspect of the system's behavior. Let's consider a simple story from a real-world XP project: a free, open-source Java IDE called DrJava. DrJava was developed at the JavaPLT research laboratory of Rice University and was designed to provide an extremely simple but powerful user interface that enables programmers at all levels to manipulate, test, and debug their code. It not only integrates testing and debugging support, but also provides an integrated "read-eval-print loop" that allows users to evaluate arbitrary Java expressions interactively. DrJava will be the basis of many examples in this book for the following reasons:
Let's consider the following story from DrJava's early stages of development:
Believe it or not, that's actually a hard story to get right in Java, partly because of some peculiar properties of block comments. Depending on the velocity of the development team, it may be advisable to break that story up into two or more smaller ones.
Still, notice that the functionality specified by this story is of a tiny scope when compared to a traditional, full, up-front specification for an IDE. Also, this story is written in simple, clear language. That makes it easy to split up into smaller stories when necessary, and it prevents coupling (unwanted entanglements) between the parts of a specification. Let's look at another story, this time for an "interactions window" in which the user can enter Java statements and expressions dynamically and then see the results. We wanted to add to this window the ability to scroll through earlier commands with the up and down arrow keys:
In this case, the story is slightly longer, but the specified functionality is still very limited. Some time after we implemented this story, a user complained that he couldn't easily get back to a blank prompt after he started scrolling through the old commands by going to the bottom of the list. (Actually, the user could have pressed the Escape key to clear the line, but his suggestion made for a more natural interface.) No problem. We extended the existing specification by writing a new story:
Very little new functionality is specified in this story. If support for scrolling through previous commands was already implemented, it's not hard to imagine that a pair of programmers could implement this new story in less than an hour. Because the stories are small, new ones can be added to modify program functionality without having to completely overhaul the specification. For this reason, stories work particularly well when the requirements for the software product change frequently. Additionally, by specifying and implementing stories incrementally, the programmers are able to release new functionality quite rapidly, allowing the customers to get more value from the software more quickly.
Although stories allow us to specify software incrementally, they have the disadvantage of not being as formal as those traditional up-front software specs. Therefore, they are prone to the same ambiguities and inconsistencies as any other informal specification. But if the traditional formal specifications are too costly, is there any way that these errors can be eliminated?
Include tests to eliminate specification errorsOne way to eliminate ambiguities and inconsistencies in a story is to include tests with it. If there's a section or clause in a story that has multiple interpretations, just write a test that helps to define that aspect of the interpretation. Provided the programming language you write the test in isn't ambiguous, that test will nail down the behavior of the program. In addition, if a set of unit tests specifies inconsistent functionality, it will be impossible for a program to pass them all. Extreme programming uses two forms of tests: acceptance tests and unit tests. Acceptance tests check user-observable functionality. Unit tests are small tests that check specific "units" of program functionality. A key feature of both kinds of tests is that they automatically check the desired functionality; it's not necessary for the programmer to examine the output of each test to ensure it's correct. If a test fails when run, the programmer is notified; otherwise, he knows that it passed. In extreme programming, testing is a way of life. The programmers start writing tests before they write any of the implementation at all, and they continue writing more unit tests for each new aspect of program functionality. A rigorous suite of tests laid over a software project provides several advantages:
The tests are an important form of documentationSince the tests (ideally) cover every aspect of the implementation, and since they invoke the functionality in simple ways to make sure it is working, it is easy for a programmer who is joining a project (or taking over maintenance of code) to read through the tests and determine what the various functional components do. When first hearing of the concept that a test can be considered documentation, some people are skeptical: "How can you write documentation for a program in the same language that the program is written in?" This question misses the point of code documentation. Code should never be documented to explain what the code is doing; the code itself already does that. Instead, documentation should explain why a block of code is doing what it does. Anyone reading the code should be already familiar with the language used; if not, then documentation in any language is unlikely to help. Granted, it is not always clear how a block of code interacts with the rest of a program, and documentation is good for that purpose. But since the reader of the code is (or should be) familiar with the language, it is perfectly valid to explain the intention behind the code in the same language as the code.
Tests expedite the process of refactoringWhen a suite of tests can be run over the code at any time to determine if any of the functionality has been broken, programmers can refactor the code with much more confidence that they aren't stomping over the invariants of each other's code. The vast majority of bugs introduced can be detected as soon as they're introduced.
Tests complement stories as part of the specificationBut what is mentioned less often is that tests complement stories as part of the specification. And just as stories allow for the incremental and informal specification of a system, unit tests allow for the incremental and formal specification of the same system. Although no set of unit tests can nail down all aspects of a system, a test suite can define the most ambiguous aspects. Furthermore, tests have huge advantages over most forms of formal specification:
For an example of how unit tests can help to better define a specification, let's return to our story concerning the history of commands in the DrJava interactions window. As we mentioned, the user can scroll back through this history with the up and down arrows, and extract text for forming new commands. One of the classes used to implement this story in DrJava is a History class, which stores the list of commands that have occurred so far. What happens when the user issues the same command twice in a row? This question isn't answered by the story shown previously. But we'd like the History to store only one of the two commands; it's tedious to have to scroll through a series of identical commands. We could write the following unit test to enforce this property:
public void testMultipleInsert() { _history.add("new Object()"); _history.add("new Object()"); assertEquals("Duplicate elements inserted", 1, _history.size()); } Notice that the method takes no arguments and returns void. That's because it is run automatically. We don't need to feed it input or check its output; if it doesn't pass, it'll throw an exception. (This test is written in the form used by JUnit, a free, open-source testing harness for Java. JUnit is part of the xUnit suite of test harnesses, providing open-source testing tools for most popular programming languages.) The test starts with a fresh History object (set in the _history field) and adds two identical commands. It then checks that the length of the History is exactly one. The assertEquals method takes three arguments: a message to signal if the test fails and two values. If the values are equal, the test succeeds; otherwise it fails. What are some other tests we could put in our History class? Why don't we formalize the property stated in the final story example: that we can move back to a blank line at the end of the History. Following is a test for that:
public void testCanMoveToEmptyAtEnd() { _history.add("some text"); _history.movePrevious(); assertEquals("Prev did not move to correct item", "some text", _history.getCurrent()); _history.moveNext(); assertEquals("Can't move to blank line at end", "", _history.getCurrent()); } Notice that these tests are gradually winnowing down the definitions for the set of methods that the History class will have to implement. Writing tests is a great way to determine the interface that a class should implement. Because you have to program to that interface yourself, you'll see just how difficult, or easy, you're making it to work with your interface. Your own preference for using simple interfaces will help you keep your own interfaces simple. It'll also help you maintain your tests in an easy-to-read form. Of course, there are many other tests we can include over class History. But we shouldn't write them all before implementing some of the functionality. The better procedure is to:
This way, we can integrate the code at each step (and make sure we didn't break anything). The History class was implemented in DrJava in the following way:
/** * Keeps track of what was typed in the interactions pane. * @version $Id: History.java,v 1.9 2002/03/06 18:59:02 eallen Exp $ */ public class History { private Vector<String> _vector = new Vector<String>(); private int _cursor = -1; /** * Adds an item to the history and moves the cursor to point * to the place after it. * * To access the newly inserted item, you must movePrevious first. */ public void add(String item) { if (item.trim().length() > 0) { if (_vector.isEmpty() || ! _vector.lastElement().equals(item)) { _vector.addElement(item); } moveEnd(); } } /** * Move the cursor to just past the end. To access the last element, * you must movePrevious. */ public void moveEnd() { _cursor = _vector.size(); } /** Moves cursor back 1, or throws exception if there is none. */ public void movePrevious() { if (!hasPrevious()) { throw new ArrayIndexOutOfBoundsException(); } _cursor-; } /** Moves cursor forward 1, or throws exception if there is none. */ public void moveNext() { if (!hasNext()) { throw new ArrayIndexOutOfBoundsException(); } _cursor++; } /** Returns whether moveNext() would succeed right now. */ public boolean hasNext() { return _cursor < (_vector.size()); } /** Returns whether movePrevious() would succeed right now. */ public boolean hasPrevious() { return _cursor > 0; } /** * Returns item in history at current position, or throws exception if none. */ public String getCurrent() { if (hasNext()) { return _vector.elementAt(_cursor); } else { return ""; } } /** * Returns the number of items in this History. */ public int size() { return _vector.size(); } } Now, here's a great example of just how easy it is to incrementally add to the formal specification of a program with unit tests. Let's say that, after writing the code above, we decided we wanted to limit the length of the History to 500 items in order to prevent runaway memory consumption in long-standing processes. So, we add the following unit test to our suite:
/** * Ensures that Histories are bound to 500 entries. */ public void testHistoryIsBounded() { int maxLength = 500; for (int i = 0; i < maxLength + 100; i++) { _history.add("testing "+ i); } while(_history.hasPrevious()) { _history.movePrevious(); } assertEquals("history length is not bound to "+ maxLength, "testing 100", _history.getCurrent()); } This new test adds 600 elements to the History and checks that a few assertions hold. Notice that it doesn't just check that only 500 entries are included in the History; it checks that items are removed in a FIFO (first-in-first-out) order. It accomplishes that check by ensuring that the oldest element in the History is the 100th element added, which is exactly what it should be if the oldest elements are removed with every command after the 500th entry. Modifying class History to pass this test was easy: first, we added the following constant to class History:
private static final int MAX_SIZE = 500; Then we modified the add() method as follows:
/** * Adds an item to the history and moves the cursor to point * to the place after it. * Note: Items are not inserted if they would duplicate the last item, * or if they are empty. (This is in accordance with bug #522123 and * feature #522213.) * * Thus, to access the newly inserted item, you must movePrevious first. */ public void add(String item) { if (item.trim().length() > 0) { if (_vector.isEmpty() || ! _vector.lastElement().equals(item)) { _vector.addElement(item); // If adding the new element has filled _vector to beyond max // capacity, spill the oldest element out of the History. if (_vector.size() > MAX_SIZE) { _vector.removeElementAt(0); } } moveEnd(); } } With this fix, the code behaves as specified.
Unit tests can't do everythingAs the preceding example demonstrates, unit tests are an essential complement to stories for the incremental specification of a software system. In fact, some might be tempted to use a suite of unit tests as the sole specification of a system. But using unit tests to form the only specification has one big disadvantage: the set of tests over a system are inevitably incomplete. No matter how many tests we specify over a system, there will always be more inputs and states of the system than we could ever hope to represent. We could interpret the tests as specifying the "most reasonable" extension, but such an extension will often be ambiguous. That's where the strength of stories comes in. Just as unit tests can clarify the intended specific aspects of a story, a story can clarify the intended general aspects of a unit test. Both are needed for an effective and agile software specification.
The use of stories and unit tests can aid software development in many ways, but here we've described their use solely for efficiently specifying software systems. And we've also emphasized the need to use specifications by pointing out that they're necessary for precisely identifying bugs in a program. Thus, a serious concern for debugging can influence the way we program, even at the level of specifying software. |