Session 6 · Programming Technology
Separating the model from the view
A working flashcard trainer with everything in one Swing class is split, step by step, into a model with no Swing in it and a view that only forwards events and redraws itself with one refresh() – then the model gets JUnit tests.
In the Swing session the calculator already kept its arithmetic in a separate class. This session is about that separation itself. We take a small program that works but has everything crammed into one class – a flashcard trainer – and turn it into a model–view (MV) program, one step at a time. From the outside the two versions are identical: same window, same buttons, same behaviour. Only the code is organised differently, and that difference is what makes the second version testable and easy to extend.
You will do exactly this with the game of the second assignment, so read the two versions side by side: Flashcards (the starting point) and FlashcardsMV (the result).
The rule of thumb: the model knows what is true (the cards, the score, the rules); the view knows how it looks. The view asks, the model decides – and the model never knows a window exists.
The starting point: everything in the GUI
The program reads question–answer pairs from a text file, one card per line, with a ~ between question and answer. It shows the question, Toggle Q/A reveals the answer, and Good answer / Wrong answer moves on to the next card – a good answer is worth one point. At the end it shows the result; Reset starts again with the same cards.
cards.txt
What is an abstract method?~A method without a body; the subclasses must implement it.
Which Swing class represents a top-level window?~JFrameIn the starting version, FlashcardsGUI does everything. Four of its fields are not components at all – they are the state of the program:
// FlashcardsGUI (before) – four of its fields are not components but STATE
private List<Card> cards = new ArrayList<>(); // the loaded cards
private int answered; // cards answered so far
private boolean showingQuestion = true; // question or answer on screen
private int score; // good answers
private final JTextArea display; // ...and these are the view
private final JLabel statusLabel;
private final JButton toggleButton;The rules live in the event handlers: answer decides whether a point is due, whether there is another card, and that the next card starts with its question. It works, and at this size it is even readable. The trouble starts when you want to test or extend it:
- It can't be tested. To check that the practice really ends after three cards, you have to click through the window. A JUnit test can't call the rules, because they are tangled with
display.setTextandstatusLabel.setText. - Screen updates are scattered. There are three update methods, and every handler has to know which ones to call. Toggle calls one, answer calls three. Add a feature – shuffling, say – and it is easy to forget one, so the window disagrees with the state.
- Unrelated things are mixed. Reading the file and the
~separator have nothing to do with Swing, yet they sit in the GUI class. A console or web version would mean rewriting everything.
Separating them, step by step
This is the recipe that turned Flashcards into FlashcardsMV. Use the same one when your assignment game ends up in one class – or, better, plan the two columns before you write the first line.
- Find the state. Which fields are not Swing components? Here:
cards,answered,showingQuestion,score. They become the model's fields – allprivate; the view sees them only through query methods. - Find the rules. Which code changes those fields, and under what conditions? Those become the model's operations (
load,reset,toggle,answer). Of the event handler, only "call the model, then refresh" remains. - What isn't about the screen goes to the model too. Reading the file, the
~separator, the message about a malformed line – you would need all of these even if the GUI were replaced tomorrow. - One
refresh(). Instead of scattered update methods, one method redraws the whole window from the model, and every handler calls it at the end. The view becomes "dumb", which is good: there is nothing in it to get wrong. - Write tests for the model. Now you can. The tests also document the rules: a test called
theNextCardAlwaysStartsWithItsQuestionsays more than a comment.
Step 2 in practice – before and after
// Flashcards (before): the rule and the screen update in one method
private void answer(boolean correct) {
if (answered < cards.size()) {
if (correct) {
score++;
}
answered++;
showingQuestion = true;
updateDisplay(); // the handler has to know which parts
updateStatus(); // of the window this change affects
updateButtons();
}
}// FlashcardsModel (after): only the rule
public void answer(boolean correct) {
requireCurrentCard(); // no current card: IllegalStateException
if (correct) {
score++;
}
answered++; // moving on: the current card is cards.get(answered)
showingQuestion = true; // a new card always starts with its question
}
// FlashcardsGUI (after): the handler only forwards
model.answer(correct);
refresh();| Flashcards | FlashcardsMV | |
|---|---|---|
| Where the state is | fields of the GUI | FlashcardsModel (private fields, queries) |
| Where the rules are | in the event handlers | in the model's operations |
| Updating the window | 3 methods, each handler picks | one refresh() from the model |
| Reading the file | in the GUI | in the model, a static method |
| Testing | only by clicking | 22 JUnit tests, no window |
| Classes | 3 | 4 (+ 1 test class) |
The model
The model is a plain Java class. It stores the state, and its operations change the state according to the rules. Its methods fall into two groups:
| Operation | What it does |
|---|---|
load(Path) | reads the cards from a file and starts the practice from the beginning |
readCards(Path) | static: parses a file into a list; a malformed line throws IOException with its line number |
reset() | first card, question side, zero points – same cards |
toggle() | switches the current card between question and answer |
answer(boolean) | a point for a good answer, then the next card, question first |
| Query | Returns |
|---|---|
hasCards() | are any cards loaded |
hasCurrentCard() | is there a card to answer (cards loaded and not all answered) |
isFinished() | all cards answered |
getCurrentText() | the question or the answer of the current card, whichever is showing |
getAnsweredCount(), getCardCount(), getScore() | the numbers the status line needs |
Name the operations in the language of the problem (answer, toggle, step, newGame), not of the screen (buttonClicked, onToggle). The model doesn't know there are buttons.
One counter, two jobs
There is no "current card index" field. Because the cards are taken in order, the number of answered cards is the index of the current card: the current card is always cards.get(answered), and answered++ is how the practice moves on. When answered == cards.size(), there is no current card and the practice is over.
Guard rails: fail loudly
When there is no current card, toggle(), answer() and getCurrentCard() throw IllegalStateException instead of silently doing nothing. A wrong call is a programming error, and you want to see it immediately – in a test, too. The view simply never makes such a call: it disables the buttons that make no sense right now.
public boolean hasCurrentCard() {
return answered < cards.size();
}
private void requireCurrentCard() {
if (!hasCurrentCard()) {
throw new IllegalStateException("There is no current card");
}
}readCards is static: it doesn't depend on the model's state, it turns a file into a list. That makes it testable on its own, and it gives load a nice property for free – the old cards are replaced only if reading succeeded:
public void load(Path file) throws IOException {
load(readCards(file)); // if readCards throws, the old cards stay
}
public void load(List<Card> newCards) {
cards = new ArrayList<>(newCards); // the model keeps its own copy
reset();
}The view: forward, then refresh
The view builds the window. Its event handlers only forward to the model, and after every event the same refresh() runs:
toggleButton.addActionListener(e -> {
model.toggle();
refresh();
});refresh() is the only place where the view reads the model, and it sets everything from it: the text, the border title (Question / Answer), the status line and which buttons are enabled.
FlashcardsGUI.refresh() – shortened
private void refresh() {
if (model.hasCurrentCard()) {
display.setText(model.getCurrentText());
displayBorder.setTitle(model.isShowingQuestion() ? "Question" : "Answer");
statusLabel.setText("Card " + (model.getAnsweredCount() + 1) + " / " + model.getCardCount());
} else if (model.isFinished()) {
// ... the result
} else {
// ... "open a file"
}
resetButton.setEnabled(model.hasCards());
toggleButton.setEnabled(model.hasCurrentCard());
goodButton.setEnabled(model.hasCurrentCard());
wrongButton.setEnabled(model.hasCurrentCard());
}So the window can never contradict the model. We don't keep components in sync by hand, button by button; after every event we redraw the window from the state. For a window this size it costs nothing – nor for the few dozen buttons of a board game. (Next session shows what to do when redrawing everything gets wasteful.)
There is no rule in the view: it doesn't know how many cards there are, it doesn't decide whether a point is due. If an if in the view is about the state (rather than about how to draw something), it probably belongs in the model.
| Event | Handler | Action |
|---|---|---|
| File ▸ Open… (Ctrl+O) | this::open (method reference) | file chooser, model.load(file), error dialog on failure, refresh() |
| File ▸ Exit (Ctrl+Q) | lambda | exits |
| Reset button | lambda | model.reset(), refresh() |
| Toggle Q/A button | lambda | model.toggle(), refresh() |
| Wrong / Good answer | AnswerListener(correct) – inner class | model.answer(correct), refresh() |
The open handler: the model reads and validates, the view shows the error
private void open(ActionEvent e) {
if (fileChooser.showOpenDialog(frame) != JFileChooser.APPROVE_OPTION) {
return;
}
try {
model.load(fileChooser.getSelectedFile().toPath());
} catch (IOException ex) {
showError("Could not load the cards:\n" + ex.getMessage());
}
refresh();
}Testing the model
Because the model has no Swing in it, its rules can be checked by JUnit without a window. In NetBeans Alt+F6 runs the tests. The test class has three groups: state with no cards, the flow of a practice (moving on, scoring, finishing, reset) and file reading (blank lines, malformed line with its number, empty and missing file).
@Test
public void answeringAllCardsFinishesThePractice() {
model.load(List.of(new Card("Q1", "A1"), new Card("Q2", "A2"), new Card("Q3", "A3")));
model.answer(true);
model.answer(false);
model.answer(true);
assertTrue(model.isFinished());
assertFalse(model.hasCurrentCard());
assertEquals(2, model.getScore());
}The guard rail is a rule too – test it
@Test(expected = IllegalStateException.class)
public void cannotAnswerWhenFinished() {
model.load(List.of(new Card("Q1", "A1")));
model.answer(true);
model.answer(true); // there is nothing left to answer
}File tests write into a temporary folder that JUnit deletes after each test, so they never touch the real cards.txt. One last test does read the sample file next to the project – if someone breaks it, the test says so.
@Rule
public TemporaryFolder folder = new TemporaryFolder(); // deleted after each test
@Test
public void readCardsReportsTheMalformedLine() throws IOException {
Path file = fileWith("Q1~A1\nno separator here\nQ3~A3\n");
try {
FlashcardsModel.readCards(file);
fail("Expected an IOException");
} catch (IOException ex) {
assertTrue(ex.getMessage().startsWith("Line 2 "));
}
}None of these tests could be written for the starting Flashcards: its constructor opens a window, and its rules are mixed with component updates.
The second assignment
The game of the second assignment has the same two layers:
| Flashcards | Board game | What goes in it |
|---|---|---|
Card | Field, Player, Position | small data classes, enums, records |
FlashcardsModel | Board (and Game) | the board, whose turn it is, the rules of a move, the end of the game, the winner – no Swing |
FlashcardsGUI | BoardGUI | button grid, menu, status bar; handlers call the model, then refresh() |
FlashcardsModelTest | BoardTest | the rules: illegal move, detecting a win, a draw |
Practice
Task 1
Shuffle the cards
Add a Shuffle button to FlashcardsMV: it shuffles the loaded cards and starts the practice over. Put the rule in the model, write a test for it, and keep the handler a two-liner.
Approach
The model gets a shuffle operation; the randomness comes from a Random parameter so a test can pass a fixed seed. The button handler is model.shuffle(random); refresh();.
// in the model – the Random comes from outside, so a test can fix the seed
public void shuffle(Random random) {
Collections.shuffle(cards, random);
reset();
}What to test
Not the exact order (that's Collections.shuffle's job) but your rule: the same cards are still there, and the practice restarted.
model.load(cards);
model.shuffle(new Random(42));
// same 3 cards, practice starts over: 0 answered, 0 points, a question showingTask 2
Practise the wrong ones again
After the last card, offer Retry wrong: a new round with only the cards answered wrong. Which class remembers them? What should happen if there were none?
Approach
The model keeps a second list. answer(false) adds the current card before moving on; a new operation retryWrong() calls load(wrong) with a copy. With an empty list, throw IllegalStateException – and let refresh() disable the button via a query like hasWrongCards().
private final List<Card> wrong = new ArrayList<>();
// in answer(): remember the card BEFORE answered++ moves on
if (!correct) {
wrong.add(cards.get(answered));
}Task 3
Tests before features
Without running the program, write tests for these rules: a wrong answer moves on without a point; toggling twice shows the question again; reset() keeps the cards. Run them – they should pass on FlashcardsMV as it is.
Shape of a test
Arrange (load three cards with List.of(new Card(…))), act (one or two calls), assert (one or two queries). One rule per test, and a name that reads as the rule.
Task 4
Split your own program
Take a program you wrote with all its logic in the GUI class (the first assignment, or the Swing exercises). List its fields in two columns, then move the state and the rules into a model class following the five steps.
Where to start
Start with the fields: anything that is not a JComponent is a candidate for the model. Then search for every assignment to those fields – each one is part of a rule. When you are done, delete the GUI's import lines from the model and see what no longer compiles.
Summary
- Model
- the state and the rules of the program, as plain Java – no Swing, no AWT; testable with JUnit
- View
- builds the window, forwards events to the model, shows the model in refresh()
- State
- the fields that describe where the program is (cards, score …) – not the components
- Query
- a model method that only reads: hasCurrentCard(), getScore()
- Guard
- a check that throws IllegalStateException when an operation makes no sense now
- Find the state (non-component fields) and the rules (code that changes them); both go to the model.
- Handlers forward: call the model, then refresh(). One refresh() reads the model and sets everything.
- The model throws on illegal calls; the view disables what makes no sense.
- No javax.swing or java.awt in the model – then JUnit can test every rule without a window.
- Your board game: Board/Game is the model, BoardGUI is the view, BoardTest tests the rules.