XPlorations
My goals are:
Problem DomainFor the problem, I'll use a (super-) simple spreadsheet. My plan is to explore formulas and dependencies before dealing with any user interface.What To Do
Challenge, Part 1Domain: A simple spreadsheet with columns A..Z, AA.., and rows 1..n.Challenge Testspublic void testThatCellsAreEmptyByDefault() {
Sheet sheet = new Sheet();
assertEquals("", sheet.get("A1"));
assertEquals("", sheet.get("ZX347"));
}
// Implement each test before going to the next one.
public void testThatTextCellsAreStored() {
Sheet sheet = new Sheet();
String theCell = "A21";
sheet.put(theCell, "A string");
assertEquals("A string", sheet.get(theCell));
sheet.put(theCell, "A different string");
assertEquals("A different string", sheet.get(theCell));
sheet.put(theCell, "");
assertEquals("", sheet.get(theCell));
}
// Implement each test before going to the next one; then refactor.
public void testThatManyCellsExist() {
Sheet sheet = new Sheet();
sheet.put("A1", "First");
sheet.put("X27", "Second");
sheet.put("ZX901", "Third");
assertEquals("A1", "First", sheet.get("A1"));
assertEquals("X27", "Second", sheet.get("X27"));
assertEquals("ZX901", "Third", sheet.get("ZX901"));
sheet.put("A1", "Fourth");
assertEquals("A1 after", "Fourth", sheet.get("A1"));
assertEquals("X27 same", "Second", sheet.get("X27"));
assertEquals("ZX901 same", "Third", sheet.get("ZX901"));
}
// Implement each test before going to the next one.
// You can split this test case if it helps.
public void testThatNumericCellsAreIdentifiedAndStored() {
Sheet sheet = new Sheet();
String theCell = "A21";
sheet.put(theCell, "X99"); // "Obvious" string
assertEquals("X99", sheet.get(theCell));
sheet.put(theCell, "14"); // "Obvious" number
assertEquals("14", sheet.get(theCell));
sheet.put(theCell, " 99 X"); // Whole string must be numeric
assertEquals(" 99 X", sheet.get(theCell));
sheet.put(theCell, " 1234 "); // Blanks ignored
assertEquals("1234", sheet.get(theCell));
sheet.put(theCell, " "); // Just a blank
assertEquals(" ", sheet.get(theCell));
}
// Refactor before going to each succeeding test.
public void testThatWeHaveAccessToCellLiteralValuesForEditing() {
Sheet sheet = new Sheet();
String theCell = "A21";
sheet.put(theCell, "Some string");
assertEquals("Some string", sheet.getLiteral(theCell));
sheet.put(theCell, " 1234 ");
assertEquals(" 1234 ", sheet.getLiteral(theCell));
sheet.put(theCell, "=7"); // Foreshadowing formulas:)
assertEquals("=7", sheet.getLiteral(theCell));
}
// We'll talk about "get" and formulas next time.
Challenge, Part 2What To Do
Challenge Tests// Implement code for previous test before moving to next one.
public void testFormulaSpec() {
Sheet sheet = new Sheet();
sheet.put("B1", " =7"); // note leading space
assertEquals("Not a formula", " =7", sheet.get("B1"));
assertEquals("Unchanged", " =7", sheet.getLiteral("B1"));
}
// Next - start on parsing expressions
public void testConstantFormula() {
Sheet sheet = new Sheet();
sheet.put("A1", "=7");
assertEquals("Formula", "=7", sheet.getLiteral("A1"));
assertEquals("Value", "7", sheet.get("A1"));
}
// More formula tests. You may feel the need to make up
// additional intermediate test cases to drive your code
// better. (For example, you might want to test "2*3"
// before "2*3*4".) That's fine, go ahead and create them.
// Just keep moving one test at a time.
// We're doing expressions; you may need to do a spike
// (investigation) if you're not familiar with parsing.
// For background, look up "recursive descent" or
// "operator precedence". (Other techniques can work as well.)
// Order of tests - I'm familiar enough with parsing to think
// it's probably easiest to do them in this order (highest
// precedence to lowest). For extra credit, you might redo
// this part of the exercise with the tests in a different order
// to see what difference it makes.
public void testParentheses() {
Sheet sheet = new Sheet();
sheet.put("A1", "=(7)");
assertEquals("Parends", "7", sheet.get("A1"));
}
public void testDeepParentheses() {
Sheet sheet = new Sheet();
sheet.put("A1", "=((((10))))");
assertEquals("Parends", "10", sheet.get("A1"));
}
public void testMultiply() {
Sheet sheet = new Sheet();
sheet.put("A1", "=2*3*4");
assertEquals("Times", "24", sheet.get("A1"));
}
public void testAdd() {
Sheet sheet = new Sheet();
sheet.put("A1", "=71+2+3");
assertEquals("Add", "76", sheet.get("A1"));
}
public void testPrecedence() {
Sheet sheet = new Sheet();
sheet.put("A1", "=7+2*3");
assertEquals("Precedence", "13", sheet.get("A1"));
}
public void testFullExpression() {
Sheet sheet = new Sheet();
sheet.put("A1", "=7*(2+3)*((((2+1))))");
assertEquals("Expr", "105", sheet.get("A1"));
}
// Add any test cases you feel are missing based on
// where your code is now.
// Then try your hand at a few test cases: Add "-" and "/"
// with normal precedence.
// Next, error handling.
public void testSimpleFormulaError() {
Sheet sheet = new Sheet();
sheet.put("A1", "=7*");
assertEquals("Error", "#Error", sheet.get("A1"));
}
public void testParenthesisError() {
Sheet sheet = new Sheet();
sheet.put("A1", "=(((((7))");
assertEquals("Error", "#Error", sheet.get("A1"));
}
// Add any more error cases you need. Numeric errors (e.g.,
// divide by 0) can return #Error too.
// Take a deep breath and refactor. This was a big jump.
// Next time we'll tackle formulas involving cells.
Challenge, Part 3We're going to add dependencies now. This is one of those things that makes a spreadsheet a spreadsheet.What To Do
Challenge Testspublic void testThatCellReferenceWorks () {
Sheet sheet = new Sheet();
sheet.put("A1", "8");
sheet.put("A2", "=A1");
assertEquals("cell lookup", "8", sheet.get("A2"));
}
public void testThatCellChangesPropagate () {
Sheet sheet = new Sheet();
sheet.put("A1", "8");
sheet.put("A2", "=A1");
assertEquals("cell lookup", "8", sheet.get("A2"));
sheet.put("A1", "9");
assertEquals("cell change propagation", "9", sheet.get("A2"));
}
public void testThatFormulasKnowCellsAndRecalculate () {
Sheet sheet = new Sheet();
sheet.put("A1", "8");
sheet.put("A2", "3");
sheet.put("B1", "=A1*(A1-A2)+A2/3");
assertEquals("calculation with cells", "41", sheet.get("B1"));
sheet.put("A2", "6");
assertEquals("re-calculation", "18", sheet.get("B1"));
}
public void testThatDeepPropagationWorks () {
Sheet sheet = new Sheet();
sheet.put("A1", "8");
sheet.put("A2", "=A1");
sheet.put("A3", "=A2");
sheet.put("A4", "=A3");
assertEquals("deep propagation", "8", sheet.get("A4"));
sheet.put("A2", "6");
assertEquals("deep re-calculation", "6", sheet.get("A4"));
}
// The following test is likely to pass already.
public void testThatFormulaWorksWithManyCells () {
Sheet sheet = new Sheet();
sheet.put("A1", "10");
sheet.put("A2", "=A1+B1");
sheet.put("A3", "=A2+B2");
sheet.put("A4", "=A3");
sheet.put("B1", "7");
sheet.put("B2", "=A2");
sheet.put("B3", "=A3-A2");
sheet.put("B4", "=A4+B3");
assertEquals("multiple expressions - A4", "34", sheet.get("A4"));
assertEquals("multiple expressions - B4", "51", sheet.get("B4"));
}
// Refactor and get everything nice and clean.
// Next: (I almost made this a separate part, and when I
// originally did it, I did it in a different design session).
// So take a break if you need one.
// There's one big open issue for formulas: what about
// circular references?
// I'll sketch some hints, but you should define your own tests
// that drive toward a solution compatible with your own
// implementation.
public void testThatCircularReferenceDoesntCrash() {
Sheet sheet = new Sheet();
sheet.put("A1", "=A1");
assertTrue(true);
}
// Just like errors return a special value, it might be nice
// if circular references did too. (See notes below).
public void testThatCircularReferencesAdmitIt () {
Sheet sheet = new Sheet();
sheet.put("A1", "=A1");
assertEquals("Detect circularity", "#Circular", sheet.get("A1"));
}
// You might come up with some other approach that suits your
// taste. We won't be exploring this corner of the solution
// any further; you just want a scheme that blocks silly mistakes.
// Make sure you test deep circularities involving partially
// evaluated expressions.
// A hint: if you blindly evaluate an expression you have no
// control over how deep the expression can be, since
// circular references appear to be infinitely deep.
// Where are we? I intend to spend the next two parts hooking
// up a GUI. Then there will be an optional part that pushes
// things in an unexpected direction just to get a sense
// of our software's robustness.
Part 4, Moving Toward a GUIWhat To Do
Challenge TestsWe'll start by using a feature of JUnit that we haven't used before: a setUp() method. To use this, declare your variables at the class level, and initialize them in the setUp() method. There's a corresponding tearDown() method that we won't need, but which can be useful for some tests. JUnit works like this: call setUp(), call one of your tests, call tearDown(), call setUp(), call your next test, call tearDown(), etc. So, each test can assume it's got things just as setUp() left them.Quick DesignThe easiest thing to use in the GUI will be a JTable, which provides the 2-d grid we expect to see. The best way to use a JTable is usually to give it a TableModel. The TableModel acts as a wrapper between the GUI-oriented JTable and your object.The easiest way to create a TableModel is to subclass AbstractTableModel, so that's what we'll do. The TableModel is 0-based, so we'll use column 0 to hold the row's index, and we'll store spreadsheet row n in TableModel's row n-1. Sheet sheet;
TableModel table;
public void setUp() {
sheet = new Sheet();
table = new SheetTableModel (sheet);
}
// As usual, do one test at a time and refactor after each.
// For now, we're willing to hard-code a maximum spreadsheet size.
// A future story can deal with this.
int LAST_COLUMN_INDEX = 49;
int LAST_ROW_INDEX = 99;
public void testTableModelRequiredOverrides() {
assertTrue (table.getColumnCount() > LAST_COLUMN_INDEX);
assertTrue (table.getRowCount() > LAST_ROW_INDEX);
assertEquals ("", table.getValueAt(10,10));
}
// Take a look at AbstractTableModel's documentation before doing this test.
public void testColumnNames() {
assertEquals ("", table.getColumnName(0));
assertEquals ("A", table.getColumnName(1));
assertEquals ("Z", table.getColumnName(26));
assertEquals ("AW", table.getColumnName(LAST_COLUMN_INDEX));
}
public void testThatColumn0ContainsIndex() {
assertEquals ("1", table.getValueAt(0,0));
assertEquals ("50", table.getValueAt(49, 0));
assertEquals ("100", table.getValueAt(LAST_ROW_INDEX,0));
}
// Remember, one test at a time, followed by refactoring.
public void testThatMainColumnsHaveContents() {
sheet.put ("A1", "upper left");
assertEquals ("upper left", table.getValueAt(0,1));
sheet.put ("A100", "lower left");
assertEquals ("lower left", table.getValueAt(LAST_ROW_INDEX, 1));
sheet.put ("AW1", "upper right");
assertEquals ("upper right", table.getValueAt(0, LAST_COLUMN_INDEX));
sheet.put ("AW100", "lower right");
assertEquals ("lower right", table.getValueAt(LAST_ROW_INDEX, LAST_COLUMN_INDEX));
}
public void testThatStoresWorkThroughTableModel() {
table.setValueAt("21", 0, 1);
table.setValueAt("=A1", 1, 1);
assertEquals("21", table.getValueAt(0,1));
assertEquals("21", table.getValueAt(1,1));
table.setValueAt("22", 0, 1);
assertEquals("22", table.getValueAt(0,1));
assertEquals("22", table.getValueAt(1,1));
}
// We've established that the table model can get and set values.
// But JTable uses an event notification mechanism to find out
// about the changes.
// To test this, we'll introduce a test helper class. It's a very
// simple listener, and will assure us that notifications are
// sent when changes are made.
// There's a couple of design decisions implicit here. One is that
// we won't attempt to be specific about which cells change; we'll
// just say that the table data has changed and let JTable refresh
// its view of whichever cells it wants. (Because of cell dependencies,
// changes in one cell could potentially no others, all others,
// or anything in between.) We might revisit this decision during
// performance tuning, and try to issue finer-grained notifications.
// The other decision is that we have no mechanism for our Sheet
// to tell the table model about changes. So changes will either need
// to come in through the table model, or we'll have to add some
// notification mechanism to Sheet. For now, just make changes through the table model.
public class TestTableModelListener implements TableModelListener {
public boolean wasNotified = false;
public void tableChanged(TableModelEvent e) {wasNotified = true;}
}
public void testThatTableModelNotifies() {
TestTableModelListener listener = new TestTableModelListener();
table.addTableModelListener (listener);
assertTrue (!listener.wasNotified);
table.setValueAt("22", 0, 1);
assertTrue (listener.wasNotified);
}
// Note the cast in our test here. Previous tests have been straight
// implementations of TableModel functions; now we're saying that
// our model has some extra functions. We'll face a small tradeoff later
// when we want access to the feature: if we get the model back from JTable,
// we'll have to cast it; if we don't want to cast it we'll have to
// track it somewhere.
public void testThatSheetTableModelCanGetLiteral() {
sheet.put("A1", "=7");
String contents = ((SheetTableModel)table).getLiteralValueAt(0, 1);
assertEquals("=7", contents);
}
// We've left isCellEditable() false, on the assumption that the way to edit
// the cell is to go to a textbox provided for that purpose (rather than
// in place).
Part 5, GUIOne of the first XPlorations articles I wrote was to address the question, "Can you do a GUI test-first?" My answer there was, "Yes, but..." and this exercise might lead you to the same opinion.The goal is to make a simple GUI that looks sort of like this: +--------+---------------+------+ | Label | Text entry | OK | +--------+---------------+------+ | ^| | Grid (JTable) || | v| +-------------------------------+The idea is that if you click in the grid, the label tells which cell you're in, and the text entry field contains the literal value of that cell. You can edit it, and when you click "OK", it puts the value back in the cell. Then the whole spreadsheet updates accordingly. With this, you should have a minimalist but working spreadsheet. Quick DesignI'll put everything in a subclass of JFrame, using the Swing objects JLabel, JTextField, JButton, and JTable. In my frame, I'll have global variables for each of these components. (I won't bother exposing intermediate panels or other stuff not important outside the class.)Most of the problem will be setup and hookup. I'll want to hook the table to the model, and set up listeners so that the label and textfield know when the selection has changed. I'll need a listener on the ok button to tell when to update the grid with the edited value. And all these objects have various configuration options to set up as well. Timothy Wall has pointed out that there are tools designed to test GUIs: http://abbot.sf.net, http://jemmy.netbeans.org, and http://jfcunit.sf.net. You might want to give one of these a try and compare the result. Challenge TestsHere are my declarations and setUp routine for my test now:Sheet sheet;
TableModel table;
SheetTableModel model; // New for part 5
SheetFrame frame; // New for part 5
public void setUp() {
sheet = new Sheet();
table = new SheetTableModel (sheet);
model = new SheetTableModel(sheet);
frame = new SheetFrame(model);
}
// Here are the tests:
public void testThatFrameHasRightParts () {
assertNotNull(frame.table);
assertNotNull(frame.label);
assertNotNull(frame.editor);
assertNotNull(frame.okButton);
assertSame(model, frame.table.getModel());
}
public void testThatRowAndColumnSelectionAllowed() {
assertTrue(frame.table.getRowSelectionAllowed());
assertTrue(frame.table.getColumnSelectionAllowed());
}
public class TestSelectionListener implements ListSelectionListener {
public boolean wasNotified = false;
public TestSelectionListener() {}
public void valueChanged(ListSelectionEvent e) {
wasNotified = true;
}
}
// I expect this test to pass; it verifies how I think listeners work.
// You might call it a spike and omit it.
public void testThatSelectionsNotifyListeners() {
TestSelectionListener listener = new TestSelectionListener();
frame.table.getSelectionModel().addListSelectionListener(listener);
assertTrue (!listener.wasNotified);
frame.table.changeSelection (3, 2, false, false);
assertTrue (listener.wasNotified);
listener.wasNotified = false;
frame.table.changeSelection (1, 1, false, false);
assertTrue (listener.wasNotified);
}
// If you need info on hooking up a selection listener, see
// http://java.sun.com/docs/books/tutorial/uiswing/components/table.html#selection
public void testThatLabelIsUpdatedWhenSelectionChanges() {
assertEquals("", frame.label.getText());
frame.table.changeSelection (0, 1, false, false);
assertEquals("A1", frame.label.getText());
frame.table.changeSelection (10, 10, false, false);
assertEquals("J11", frame.label.getText());
}
// You might add a main() routine to SheetFrame and
// see how the GUI is looking.
public void testThatEditorSeesLiteralValue() {
model.setValueAt("=7", 1, 1);
frame.table.changeSelection(1,1,false,false);
assertEquals("=7", frame.editor.getText());
}
// We would like to have a way to programmatically let the
// text field click "Enter", but I don't see a mechanism.
// So we'll use the okButton instead.
public void testThatEditedValueGetsSaved() {
model.setValueAt("=7", 1, 1);
frame.table.changeSelection(1,1,false,false);
frame.editor.setText("=8");
frame.okButton.doClick();
assertEquals("=8", frame.model.getLiteralValueAt(1,1));
assertEquals("8", frame.model.getValueAt(1,1));
}
public void testThatValuePropagationWorks () {
frame.model.setValueAt("7", 0,1);
frame.model.setValueAt("=A1+2", 2,2);
assertEquals("9", frame.model.getValueAt(2,2));
assertEquals("=A1+2", frame.model.getLiteralValueAt(2,2));
frame.model.setValueAt("10", 0,1);
assertEquals("12", frame.model.getValueAt(2,2));
}
// See discussion below on acceptance tests.
public void testAcceptanceTest1() {
SheetTableModel model;
SheetFrame frame;
model = new SheetTableModel(new Sheet());
frame = new SheetFrame(model);
frame.table.changeSelection(0,1,false,false); // A1
frame.editor.setText("8");
frame.okButton.doClick();
frame.table.changeSelection(1,1,false,false); // A2
frame.editor.setText("=A1*A1+A1");
frame.okButton.doClick();
assertEquals("72", frame.table.getValueAt(1,1));
frame.table.changeSelection(0,1,false,false); // A1
frame.editor.setText("5");
frame.okButton.doClick();
assertEquals("30", frame.table.getValueAt(1,1));
}
DiscussionWhat I've found is that GUI testing works "ok" for some things but there tends not to be an easy way to do other things. For example, I didn't see an easy way to programmatically click "Enter" in the edited box. (I know you can set up a Robot and fool with events, but I wasn't willing to try that hard.) Another example is the difficulty of dealing with pop-up menus, tooltips, dialogs, etc.The other problem I have is that I just don't think of everything important without actually seeing the GUI grow. So even if I use test-first, I'll mix it with running the application and seeing how it looks. (For example, we didn't address column widths, or seriously look at what happens when you select multiple cells, and my solution doesn't leave the grid element selected after an edit.) I try to keep the GUI work limited to direct setup and hookup of objects. It can be very tempting to say "oh, this is GUI so I don't need to test it," but don't over-do that. Make sure that the GUI is not making application-level or algorithmic decisions. I do sometimes end up creating programmatic acceptance tests, like the last test. I try to minimize my need for these because they're a fair bit of work to program, and the customer can't really look at them and verify them. In this case, the test checks the hookup, which is ok. But for other spreadsheet tests, I might automate something that reads an input file and a "golden" result, then compares to the actual contents of the sheet. In this way I could check formulas, circularity, etc. without having to hand-code so much. Part 6, Bonus RoundThis part is designed to let you assess the flexibility of your design. I'm just going to provide general directions; you can develop your own tests for any area you undertake."Obvious" extensionsThere are a lot of places where we can expand on our spreadsheet. How much impact would each of these have? Do they affect the core of what you've done?
PerformanceIf you haven't performance-tuned, your solution probably re-parses (or at least re-evaluates) a value every time it's referenced. Try a test like this: A1 1
A2 =A1+A1+A1+A1+A1+A1+A1+A1+A1
A3 =A2+A2+A2+A2+A2+A2+A2+A2+A2
A4 =A3+A3+A3+A3+A3+A3+A3+A3+A3
A5 =A4+A4+A4+A4+A4+A4+A4+A4+A4
A6 =A5+A5+A5+A5+A5+A5+A5+A5+A5
A7 =A6+A6+A6+A6+A6+A6+A6+A6+A6
etc. as needed
Then change A1 to 2.
Get a profiler and see where the time is being spent. Can you speed up evaluation?
How hard would it be to cache values during an update cycle (instead of re-calculating for
each reference)? Would it make sense to have cells know which cells to notify when
they changed? What other tests would help you understand the spreadsheet's
performance?
Non-rectangular spreadsheetThe last couple challenges focused on using our Sheet as a traditional 2-d grid. But the idea of dependency and automatic updating is not really dependent on the shape.How about a tree-based spreadsheet? Instead of A1, C32, etc., let the cells be named as words separated by dots: "Sales.Y2000.Q1" and so on. You might have formulas like this: Sales =Sales.Y2000+Sales.Y2001 Sales.Y2000 =Sales.Y2000.H1 + Sales.Y2000.H2 Sales.Y2000.H1 =43000 Sales.Y2000.H2 =54000 Sales.Y2001 =Sales.Y2001.H1 + Sales.Y2001.H2 Sales.Y2000.H1 =45000 Sales.Y2000.H2 =74000Updating should still work as before; if you increased Sales.Y2000.H2 by 6000, then Sales.Y2001 and Sales should increase by that same amount. You might like to display all this in some sort of tree control rather than a table.
DiscussionI don't expect miracles out of test-first design, but I expect that I'll get a reasonably robust solution, that feels like it ended up a little different than it would have from an up-front design.I hope the experience of "test-first without writing tests" has inspired you to try "test-first by writing tests". Again, thanks to all participants. I've really enjoyed corresponding with people, reading code, and seeing the discussions about this exercise. Closing ObservationsHere's a few observations about the test-first challenge, from the author's point of view.
Participant SolutionsSeveral people who took the challenge in January 2002 have posted their solutions. I encourage that you not look at these before you attempt your own solution, and I encourage that you do look at them after you finish.If you've posted your solution and want to share it, let me know and I'll add you to this list.
Resources and Related Articles[Written January, 2002; fixed missing setup in part 3, February, 2003. Updated Sept. 04 to mention GUI test tools. Added James Cakalic's solution, August, '05.] |
|
Copyright 1994-2006, William C. Wake - William.Wake@acm.org |