
CS 3100: Program Design and Implementation II
Lecture 30: GUI Patterns and Testing
©2026 Jonathan Bell & Ellen Spertus, CC-BY-SA
Learning Objectives
After this lecture, you will be able to:
- Explain the limitations of MVC that motivated MVVM
- Implement the Model-View-ViewModel pattern with JavaFX properties and data binding
- Compare MVC and MVVM in terms of coupling, synchronization, and testability
- Write unit tests for a ViewModel without starting the JavaFX runtime
- Write end-to-end GUI tests using TestFX with accessibility-based locators
Corrections
-
Wednesday morning, I said that slider drags during a long handler were lost. That was wrong. They are queued.
-
In both sections, I said the model does not have any
javafximports. That was not true on slide 29, which had observable properties (BooleanProperty,SimpleBooleanProperty, etc.), which we will discuss today.
Last Lecture We Built an MVC App — Now Let's Break It
Our SceneItAll area dashboard from L29. Spot the bug:
// Controller from L29
@FXML private void initialize() {
brightnessSlider.valueProperty().addListener((obs, old, val) -> {
int level = val.intValue();
brightnessValueLabel.setText(level + "%");
model.setAllLightsBrightness(level);
updateDeviceList(); // ✅ remembered to update
});
}
@FXML private void handleActivateScene() {
String scene = sceneSelector.getValue();
if (scene != null) {
model.activateScene(scene);
// ❌ BUG: forgot to call updateDeviceList()!
// User activates "Evening" but the device list still shows old values
}
}
Every time the Model changes, you must remember to update the View. Forget once → stale UI. This doesn't scale.
MVC's Two Problems: Manual Sync and Untestable Controllers
Problem 1: Manual synchronization
Every Model change requires a manual View update. Forget one → stale UI.
As the app grows, the number of sync points grows linearly. Each one is a potential bug.
Problem 2: Untestable Controller
public class AreaDashboardController {
@FXML private Slider brightnessSlider;
@FXML private ListView<String> deviceList;
// ...
}
To test handleActivateScene(), you need to start the JavaFX runtime, load FXML, create widgets, and simulate clicks.
That's an integration test, not a unit test.
Recall from L16: testable code has high observability (can inspect state) and high controllability (can set state directly). MVC Controllers have neither.
MVVM Adds One Layer to Solve Both Problems
Model
Same as MVC. Business logic, no UI.
ViewModel (new)
UI state as bindable properties. No reference to the View. Fully testable.
View
Declaratively binds to ViewModel properties. Contains no logic.
The key innovation: The ViewModel exposes everything the View needs as observable properties. The View binds to them. When a property changes, the View updates automatically. No manual sync. No forgotten updateDeviceList() calls.
Introducing the Thermostat App
A thermostat has an actual temperature and a target temperature.
We want the view to:
- show the actual temperature
- show the target temperature
- change the target temperature
We want the model to:
- own the actual and target temperatures
- validate them
- communicate with devices

Data Flow
Logically, we want something like this:
We achieve this with a ViewModel:
MVVM Responsibilities
Model owns and validates the
- actual temperature, which it gets from a device
- target temperature
View
- displays the actual temperature
- sets and displays the target temperature
ViewModel
- exposes targetTemp as a DoubleProperty (read/write)
- exposes actualTemp as a DoubleProperty (conventionally read-only)
- listens for changes to targetTemp and forwards them to the model
Controller
- creates and connects the Model and ViewModel
- binds the UI elements to the ViewModel's properties
The View
<!-- thermostat.fxml -->
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.Slider?>
<?import javafx.scene.layout.VBox?>
<VBox spacing="10" xmlns:fx="http://javafx.com/fxml"
stylesheets="@styles.css"
fx:controller="thermostat.ThermostatController">
<Label fx:id="actualLabel"/>
<Label fx:id="targetLabel"/>
<Slider fx:id="targetSlider" min="10" max="35" value="20"/>
</VBox>

The Model
public class ThermostatModel {
public static final int MIN_TEMP = 10;
public static final int MAX_TEMP = 35;
private double targetTemp = 20.0;
private double actualTemp = 18.0;
public double getTargetTemp() {
return targetTemp;
}
public void setTargetTemp(double temp) {
this.targetTemp = Math.clamp(temp, MIN_TEMP, MAX_TEMP);
System.out.println("Target temperature set to: " + this.targetTemp);
// Occasionally simulate updating the actual temperature.
if (Math.random() < 0.05) {
updateActualTemp();
}
}
public double getActualTemp() {
return actualTemp;
}
public void setActualTemp(double temp) {
this.actualTemp = temp;
}
public void updateActualTemp() {
// Simulate the actual temperature moving towards the target temperature.
if (actualTemp < targetTemp) {
actualTemp += 0.5;
} else if (actualTemp > targetTemp) {
actualTemp -= 0.5;
}
System.out.println("Actual temperature updated to: " + this.actualTemp);
}
}
Observable Properties
To connect the View and Model, JavaFX provides observable properties — values that notify listeners when they change.
The ViewModel is the bridge between the View and the Model, so each can react to changes caused by the other.
The ViewModel
public class ThermostatViewModel {
private final DoubleProperty targetTemp = new SimpleDoubleProperty();
private final DoubleProperty actualTemp = new SimpleDoubleProperty();
private ThermostatModel model;
public void setModel(ThermostatModel model) {
this.model = model;
targetTemp.set(model.getTargetTemp()); // initialize from model
actualTemp.set(model.getActualTemp()); // initialize from model
// When the user changes targetTemp, forward the new value to the model
targetTemp.addListener((obs, oldVal, newVal) -> {
model.setTargetTemp(newVal.doubleValue());
// Check for other changes in the model.
refresh();
});
}
private void refresh() {
actualTemp.set(model.getActualTemp());
}
public DoubleProperty targetTempProperty() { return targetTemp; }
public DoubleProperty actualTempProperty() { return actualTemp; }
}
The Controller
public class ThermostatController {
@FXML private Slider targetSlider;
@FXML private Label targetLabel;
@FXML private Label actualLabel;
private ThermostatViewModel viewModel;
public void setViewModel(ThermostatViewModel viewModel) {
this.viewModel = viewModel;
// bidirectional: slider and targetTemp stay in sync
targetSlider.valueProperty().bindBidirectional(viewModel.targetTempProperty());
// one-way: labels just display the values
targetLabel.textProperty().bind(
viewModel.targetTempProperty().asString("Target: %.1f °C"));
actualLabel.textProperty().bind(
viewModel.actualTempProperty().asString("Actual: %.1f °C"));
}
}
Listening vs. Binding
Listening — imperative: "when this changes, do something"
- You provide code to run when the value changes
- Used when a change requires an action, not just a value update
// when targetTemp changes, forward the new value to the model
targetTemp.addListener((obs, oldVal, newVal) -> {
model.setTargetTemp(newVal.doubleValue());
refresh();
});
Binding — declarative: "always reflect this value"
- JavaFX maintains the relationship automatically
- Used to keep UI elements in sync with ViewModel properties
// one-way: label always shows the current actualTemp
actualLabel.textProperty().bind(
viewModel.actualTempProperty().asString("Actual: %.1f °C"));
// bidirectional: slider and targetTemp stay in sync
targetSlider.valueProperty().bindBidirectional(viewModel.targetTempProperty());
The Application
public class ThermostatApp extends Application {
@Override
public void start(Stage stage) throws Exception {
FXMLLoader loader = new FXMLLoader(getClass().getResource("thermostat.fxml"));
Parent root = loader.load();
ThermostatModel model = new ThermostatModel();
ThermostatViewModel viewModel = new ThermostatViewModel();
viewModel.setModel(model);
ThermostatController controller = loader.getController();
controller.setViewModel(viewModel);
stage.setScene(new Scene(root, 300, 200));
stage.setTitle("Thermostat");
stage.show();
}
public static void main(String[] args) {
launch(args);
}
}
Transition to Prof. Bell's Slides
- Prof. Bell has a bigger example with SceneItAll.
- You can always go through his lectures and notes.
- The main difference is his contains ObservableLists.
- Let's now go back to his slides...
MVC vs. MVVM: Same Feature, Two Architectures
The same "Activate Scene" feature. Look at what each class depends on:
MVC — logic lives in the Controller
public class AreaDashboardController {
// Depends on View widgets ↓
@FXML private Slider brightnessSlider;
@FXML private Label brightnessValueLabel;
@FXML private ListView<String> deviceList;
@FXML private ComboBox<String> sceneSelector;
// AND depends on the Model ↓
private Area model;
@FXML private void handleActivateScene() {
model.activateScene(sceneSelector.getValue());
// Must know about EVERY widget to update
brightnessSlider.setValue(model.getAvgBrightness());
brightnessValueLabel.setText(model.getAvgBrightness() + "%");
deviceList.getItems().setAll(/* ... */);
}
}
Depends on: Model + Slider + Label + ListView + ComboBox
MVVM — logic lives in the ViewModel
public class AreaDashboardViewModel {
// Depends on the Model only ↓
private Area model;
// Exposes observable properties (no widgets!)
private final IntegerProperty brightness = ...;
private final ObservableList<String> statuses = ...;
public void activateScene(String name) {
model.activateScene(name);
brightness.set(model.getAvgBrightness());
statuses.setAll(/* ... */);
// View updates automatically via binding
}
}
Depends on: Model only
MVC Controller Test — Needs Full UI
@Test
void activateScene_updatesDeviceStatuses() {
// Arrange — need JavaFX runtime!
Platform.startup(() -> {});
FXMLLoader loader = new FXMLLoader(getClass().getResource("/area-dashboard.fxml"));
Parent root = loader.load();
Stage stage = new Stage();
stage.setScene(new Scene(root));
stage.show();
// ... set up model, find widgets ...
// Act — simulate button click
clickOn("#sceneButton");
// Assert — inspect widget state
ListView list = lookup("#deviceList").query();
// ... check list items ...
}
Runs in: ~2000 ms (if it doesn't flake)
ViewModel Test — Pure Java, No UI
@Test
void activateScene_updatesDeviceStatuses() {
// Arrange
AreaDashboardViewModel vm = new AreaDashboardViewModel();
Area area = new Area("Living Room");
area.addDevice(new Light("Ceiling", 100));
area.addDevice(new Fan("Floor Fan", true));
area.addScene("Evening", Map.of("Ceiling", 30));
vm.setModel(area);
// Act
vm.activateScene("Evening");
// Assert
assertEquals(30, vm.brightnessProperty().get());
assertTrue(vm.getDeviceStatuses().contains("Ceiling: 30%"));
}
Runs in: ~5 ms
Thermostat Tests (1/2)
class ThermostatViewModelTest {
private ThermostatViewModel vm;
private ThermostatModel model;
@BeforeEach
void setUp() {
vm = new ThermostatViewModel();
model = new ThermostatModel();
vm.setModel(model);
}
// 1. State initialization
@Test
void setModel_populatesTargetTemp() {
assertEquals(model.getTargetTemp(), vm.targetTempProperty().get());
}
@Test
void setModel_populatesActualTemp() {
assertEquals(model.getActualTemp(), vm.actualTempProperty().get());
}
// 2. User action → model update
@Test
void setTargetTemp_updatesModel() {
vm.targetTempProperty().set(25.0);
assertEquals(25.0, model.getTargetTemp());
}
// 3. Edge cases
@Test
void setTargetTemp_belowMinimum_clampsToMin() {
vm.targetTempProperty().set(ThermostatModel.MIN_TEMP - 1);
assertEquals(ThermostatModel.MIN_TEMP, model.getTargetTemp());
}
@Test
void setTargetTemp_aboveMaximum_clampsToMax() {
vm.targetTempProperty().set(ThermostatModel.MAX_TEMP + 1);
assertEquals(ThermostatModel.MAX_TEMP, model.getTargetTemp());
}
}
Thermostat Tests (2/2)
// 3. Model change → UI property update
@Test
void setTargetTemp_belowActual_triggersRefresh() {
model.setActualTemp(25.0);
vm.targetTempProperty().set(22.0);
assertEquals(model.getActualTemp(), vm.actualTempProperty().get());
}
// 4. Edge cases
@Test
void setTargetTemp_belowMinimum_clampsToMin() {
vm.targetTempProperty().set(ThermostatModel.MIN_TEMP - 1);
assertEquals(ThermostatModel.MIN_TEMP, model.getTargetTemp());
}
@Test
void setTargetTemp_aboveMaximum_clampsToMax() {
vm.targetTempProperty().set(ThermostatModel.MAX_TEMP + 1);
assertEquals(ThermostatModel.MAX_TEMP, model.getTargetTemp());
}
}
What ViewModel Tests Should and Should NOT Cover
Do test ViewModel logic:
- Does setting target temp to 100 clamp to
MAX_TEMP? - Does setting target temp below actual trigger a property refresh?
- Does a null model get handled gracefully?
- Does initializing with a model populate all properties correctly?
These are decisions the ViewModel makes, independent of how the View displays them.
Don't test View concerns in ViewModel tests:
- Pixel positions or layout dimensions
- CSS styling or colors
- Animation timing or transitions
- Which specific widget type displays a temperature
- Focus order or tab behavior
These are View responsibilities. Test them in E2E tests (sparingly) or manual testing.
If your test imports javafx.scene, it's probably testing the wrong layer.
ViewModel Tests Cover Logic — But Who Tests the Wiring?
Your ViewModel is perfect. Your tests all pass. But:
- What if the FXML binds to the wrong property?
- What if the Controller wires one-way instead of bidirectional?
- What if the button's
onActionreferences a method that doesn't exist? - What if two features work individually but break when integrated?
You need some tests that exercise the full stack: View → Controller → ViewModel → Model. That's what end-to-end (E2E) tests are for.
But E2E tests are expensive. Use them sparingly — only for critical user journeys.
The Testing Pyramid, Revisited

TestFX: Simulating a Real User
TestFX launches your real JavaFX application and simulates clicks, typing, and navigation:
public class ThermostatE2ETest extends ApplicationTest {
private ThermostatModel model;
private ThermostatViewModel vm;
@Override
public void start(Stage stage) throws Exception {
FXMLLoader loader = new FXMLLoader(
getClass().getResource("/thermostat/thermostat.fxml"));
stage.setScene(new Scene(loader.load(), 300, 200));
model = new ThermostatModel();
model.setActualTemp(18.0);
vm = new ThermostatViewModel();
vm.setModel(model);
loader.<ThermostatController>getController().setViewModel(vm);
stage.show();
}
// Tests go here — next slides...
}
The Locator Problem: How Do You Find a Slider?
| Strategy | Code | Problem |
|---|---|---|
| By fx:id | lookup("#targetSlider") | Breaks if developer renames the ID |
| By CSS class | lookup(".slider") | Matches ALL sliders — which one? |
| By position | lookup(".slider").nth(0) | Breaks if layout changes |
| By text | clickOn("Target") | Breaks if label text changes; non-unique |
All of these describe how the element is implemented, not what it does. Refactor the UI → tests break.
Is there an identifier that describes what an element does rather than how it's built?
The Solution: Locate by Accessibility Label
The accessibleText you added in L28 and L29 describes what the element does — not how it's implemented:
// Helper: find elements by accessible text
private <T extends Node> T findByAccessibleText(String text) {
return lookup(node ->
text.equals(node.getAccessibleText())
).query();
}
// Test uses purpose-based locators
@Test
void userCanSetTargetTemperature() {
Slider slider = findByAccessibleText("Target temperature slider");
interact(() -> slider.setValue(25.0));
FxAssert.verifyThat(
findByAccessibleText("Target temperature display"),
LabeledMatchers.hasText("Target: 25.0 °C"));
}
| Refactoring | ID-based test | Accessibility-based test |
|---|---|---|
Rename fx:id from targetSlider to tempSlider | Breaks ❌ | Still passes ✅ |
Change label format from Target: 25.0 °C to Set: 25.0° | Breaks ❌ | Breaks unless assertion avoids exact UI text ⚠️ |
| Move slider from bottom to top of layout | Breaks (position) ❌ | Still passes ✅ |
Your accessibility work from L28 makes your tests more stable. Accessibility and testability reinforce each other.
Thermostat E2E Test Setup
public class ThermostatE2ETest extends ApplicationTest {
private ThermostatModel model;
private ThermostatViewModel vm;
@Override
public void start(Stage stage) throws Exception {
FXMLLoader loader = new FXMLLoader(
getClass().getResource("/thermostat/thermostat.fxml"));
stage.setScene(new Scene(loader.load(), 300, 200));
model = new ThermostatModel();
model.setActualTemp(18.0);
vm = new ThermostatViewModel();
vm.setModel(model);
loader.<ThermostatController>getController().setViewModel(vm);
stage.show();
}
}
Thermostat E2E Tests
@Test
void labels_reflectInitialModelState() {
FxAssert.verifyThat("#targetLabel",
LabeledMatchers.hasText("Target: 20.0 °C"));
FxAssert.verifyThat("#actualLabel",
LabeledMatchers.hasText("Actual: 18.0 °C"));
}
@Test
void settingSlider_updatesTargetLabel() {
Slider slider = lookup("#targetSlider").query();
interact(() -> slider.setValue(25.0));
FxAssert.verifyThat("#targetLabel",
LabeledMatchers.hasText("Target: 25.0 °C"));
}
@Test
void settingSlider_updatesModel() {
Slider slider = lookup("#targetSlider").query();
interact(() -> slider.setValue(25.0));
assertEquals(25.0, model.getTargetTemp(), 0.01);
}
Your GA1 Testing Strategy
| Layer | What you test | How many | Speed |
|---|---|---|---|
| Model unit tests | Business logic (scaling, conversion, search) | Many (10-20) | ~5 ms each |
| ViewModel unit tests | UI state, commands, property updates | Many (10-20) | ~5 ms each |
| E2E tests (TestFX) | Critical user journey, end-to-end wiring | Few (1-2) | ~2 sec each |
In GA1 you'll apply these patterns to CookYourBooks. The ViewModel interfaces we provide are your testing contract:
// GA1 provides this interface
public interface LibraryViewModel {
StringProperty selectedCookbookProperty();
ObservableList<String> getRecipeNames();
void selectCookbook(String name);
void deleteRecipe(String name);
}
// You implement it — and test it
@Test
void selectCookbook_populatesRecipeNames() {
viewModel.selectCookbook("Italian Favorites");
assertFalse(viewModel.getRecipeNames().isEmpty());
assertTrue(viewModel.getRecipeNames().contains("Pasta Carbonara"));
}
Key Takeaways
- MVC's limitation: manual sync is error-prone. Every Model change requires a manual View update. Forget one → stale UI.
- MVVM solves this with data binding. The ViewModel exposes observable properties. The View binds to them. Sync is automatic.
- The ViewModel has no View reference. It's a plain Java class with properties and commands. Fully unit-testable without starting the UI.
- Push tests down the pyramid. ViewModel tests are fast and reliable. E2E tests are slow and flaky. Use E2E only for critical user journeys.
- Accessibility labels make the best test locators. They describe what an element does, not how it's built. Refactor-proof.
- Accessibility and testability reinforce each other. The same practices that help screen reader users also make your tests more stable.
Looking Ahead
Lab 12 (next Monday/Tuesday): Hands-on GUI practice
- Build SceneItAll GUI components using MVC and MVVM patterns from L29 and L30
- Practice ViewModel testing and FXML binding
- Bring your laptop with Scene Builder installed
Next up: Concurrency (L31-32)
- What happens when your event handler needs to talk to a network or database?
- If it blocks, the UI freezes (remember the event loop from L29)
- Background threads,
Platform.runLater(),TaskandServiceclasses - How to keep the UI responsive during long-running operations
Your group project:
- GA1 (due Apr 9): Implement your feature's ViewModel, View, and tests — in GA1 you'll apply these patterns to CookYourBooks
- Write ViewModel unit tests — these are individually graded
- Write 1-2 E2E tests for your feature's critical user journey
- Use accessibility labels on all interactive widgets — it helps your users AND your tests
Today you learned to separate what the UI shows from how it works, and to test both. Next, you learn to keep the UI alive while doing heavy work.
Poll: How do you feel about your Quiz 2 performance?


Text espertus to 22333 if the
URL isn't working for you.
Poll: How did you prepare?
A. I attended the quiz review in lecture
B. I watched the recording of the other quiz review.
C. I went through Prof. Bell's review slides
D. I went to the ASNUO study session
E. I went to Ellen's office hours to discuss topics
F. I made an appointment with Ellen
G. I asked AI for help with topics I didn't understand
H. none of the above

Text espertus to 22333 if the
URL isn't working for you.
Bonus Slide
