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.

14 min readState vs rules vs presentationModel without SwingOne refresh() methodIllegalStateException guardsJUnit tests for the model

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?~JFrame

In 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.setText and statusLabel.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.
before – FlashcardsGUI does everythingStatecards, answered, score, showingQuestionRulesinside the event handlersFile readingreadCards – knows nothing of SwingComponentsframe, text area, 4 buttons, labelThree update methodsupdateDisplay / Status / Buttonsafter – FlashcardsModel (no Swing)Stateprivate fields + query methodsRulesload, reset, toggle, answerFile readingstatic readCards(Path)after – FlashcardsGUI (the view)Components + listenerslisteners only forward to the modelOne refresh()replaces the three update methodssplit
Fig. 1 · The same five responsibilities before and after. Three of them move into a class that has never heard of Swing; the two that stay in the view get simpler.

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.

  1. Find the state. Which fields are not Swing components? Here: cards, answered, showingQuestion, score. They become the model's fields – all private; the view sees them only through query methods.
  2. 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.
  3. 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.
  4. 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.
  5. Write tests for the model. Now you can. The tests also document the rules: a test called theNextCardAlwaysStartsWithItsQuestion says 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();
FlashcardsGUI – the viewSwing: components, listeners, refresh()FlashcardsModelTestJUnit – no window at allFlashcardsModel – the modelstate, rules, file reading – no Swinganswer(true), then getScore() …same calls + assertsneverCheck: the model class has no javax.swing or java.awt import. If it has one, something belongs in the view.
Fig. 2 · The dependency points one way. The view and the tests use the model through the same public methods; nothing points back.
FlashcardsFlashcardsMV
Where the state isfields of the GUIFlashcardsModel (private fields, queries)
Where the rules arein the event handlersin the model's operations
Updating the window3 methods, each handler picksone refresh() from the model
Reading the filein the GUIin the model, a static method
Testingonly by clicking22 JUnit tests, no window
Classes34 (+ 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:

OperationWhat 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
QueryReturns
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.

callcards – blue is cards.get(answered)answeredscoreload(cards)Card 1Card 2Card 300answer(true)Card 1Card 2Card 311answer(false)Card 1Card 2Card 321answer(true)Card 1Card 2Card 3none → isFinished()32Dashed = already answered. No second "current index" field to keep in sync.
Fig. 3 · Three cards, three answers. One integer is both the count and the position – there is no second field that could drift out of sync with it.

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();
});
PlayerAnswerListenercorrect = trueFlashcardsModelFlashcardsGUIrefresh()clicks Good answeranswer(true)score++, answered++showingQuestion = truerefresh()getCurrentText() …"Q2", 1 …set every component
Fig. 4 · The path of one click. The rule runs entirely inside the model; the view's only job afterwards is to ask what is true now and show it.

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.)

before – each handler picks its updatesopen()reset()toggle()answer()updateDisplayupdateStatusupdateButtonsRed: toggle refreshes only the text.after – every handler ends with refresh()open()reset()toggle()answer()refresh()reads the whole modelOne reader of the model – the window can't disagree.
Fig. 5 · Scattered updates force every handler to know the whole window. A single refresh() removes that knowledge from the handlers.

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.

EventHandlerAction
File ▸ Open… (Ctrl+O)this::open (method reference)file chooser, model.load(file), error dialog on failure, refresh()
File ▸ Exit (Ctrl+Q)lambdaexits
Reset buttonlambdamodel.reset(), refresh()
Toggle Q/A buttonlambdamodel.toggle(), refresh()
Wrong / Good answerAnswerListener(correct) – inner classmodel.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:

FlashcardsBoard gameWhat goes in it
CardField, Player, Positionsmall data classes, enums, records
FlashcardsModelBoard (and Game)the board, whose turn it is, the rules of a move, the end of the game, the winner – no Swing
FlashcardsGUIBoardGUIbutton grid, menu, status bar; handlers call the model, then refresh()
FlashcardsModelTestBoardTestthe 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 showing

Task 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.