• Share this article:

JUnit Testing GUIs

Monday, October 20, 2008 - 15:46 by Wayne Beaton

I’ve been filling in some of the gaps in unit testing on the Eclipse Examples project’s Eclipse Business Expense Reporting and Tracking (EBERT) component. My focus over the past several days has been to make sure that the user interface tests are as complete as I can make them. I thought I might spend a little time discussing some of the tests.

Currently, all the views for the component are in a single bundle named org.eclipse.examples.expenses.views. There are three views. The BinderView provides, curiously enough, an interface for interacting with an ExpensesBinder. The idea is that a "binder" binds together a collection of ExpenseReport instances (“All of Wayne’s 2008 Expense Reports”, for example). The view is shown on the left side of the window below:

The view doesn’t really do all that much:

  • It displays the list of ExpenseReport instances owned by the binder.
  • Should the title of one of these reports change, that change is reflected in the list.
  • Based on the selection state of the list, the “Remove” button is enabled or disabled

The implementation of this view makes use of the classic/traditional observer pattern. It installs listeners on all the interesting objects and responds to change notifications through those listeners. The other views use different approaches; the the ExpenseReportView uses the Equinox EventAdmin implementation, and the ListItemView uses the JFace DataBinding (I thought that it might be helpful to show some different ways of doing things). I intend to talk a little bit about each of these in the future.

The test class, BinderViewTests is defined in a Fragment named org.eclipse.examples.expenses.views.tests. The test class is defined in the same Java package as the BinderView class. At runtime, the content of the fragment overlays the content of the host bundle which allows the test classes to access package-private (i.e. “default” visible) members. This is handy for testing purposes.

The test class contains some fixtures that are used in most of the test methods:

public class BinderViewTests extends WorkbenchTests {

	private BinderView view;
	private ExpensesBinder binder;
	private ExpenseReport report;

	@Before
	public void setUp() throws Exception {
		view = (BinderView) getActivePage().showView(BinderView.ID);
		binder = new ExpensesBinder();
		report = new ExpenseReport("Trip to Hell");
		binder.addExpenseReport(report);
		view.setBinder(binder);

		processEvents();
	}
	...
}

This test runs in the live workbench. The setUp method first obtains a reference to the view. It then creates an instance of the ExpensesBinder class with a single ExpenseReport instance and then gives the binder to the view. The final line is a bit of code that forces SWT to process any pending asynchronous tasks. The view defers a some of the updates and we need to make sure that these updates have occurred before we can confirm that their results are valid.

Most of the tests in this class focus around ensuring that the content provider for the ListViewer does what it’s supposed to do. Here’s the code for the content provider from the BinderView class:

IStructuredContentProvider contentProvider = new IStructuredContentProvider() {
	/**
	 * When asked to get the elements that are to be displayed by the viewer,
	 * this method returns an array of {@link ExpenseReport} instances owned
	 * by the input object (an instance of {@link ExpensesBinder}). If the
	 * input is anything other than an instance of {@link ExpensesBinder}, a
	 * generic empty array is returned.
	 */
	public Object[] getElements(Object input) {
		if (input instanceof ExpensesBinder) {
			return ((ExpensesBinder)input).getReports();
		}
		return new Object[0];
	}

	/**
	 * When the instance is disposed, any previously installed listeners
	 * are cleaned up.
	 */
	public void dispose() {
		unhookListeners(expensesBinder);
	}

	public void inputChanged(Viewer viewer, Object oldBinder, Object newBinder) {
		unhookListeners((ExpensesBinder)oldBinder);
		hookListeners((ExpensesBinder)newBinder);
	}
};

The first two tests ensure that the getElements method answers correct values:

/**
 * This test ensures that the {@link BinderView#contentProvider} gives us
 * the right collection of objects to display when given valid input.
 */
@Test
public void testThatContentProviderAnswersExpenseReportsForBinder() {
	assertArrayEquals(binder.getReports(), view.contentProvider.getElements(binder));
}

/**
 * This test ensures that the {@link BinderView#contentProvider} gives us
 * the right collection of objects to display when given invalid input. For
 * this content provider, an empty array is expected if anything other than
 * an instance of {@link ExpensesBinder} is given as input.
 */
@Test
public void testThatContentProviderAnswersEmptyArrayForInvalidInput() {
	assertArrayEquals(new Object[0], view.contentProvider.getElements(new Date()));
}

I’m going directly to the content provider with these tests. At runtime, the content provider is invoked indirectly through the viewer: when you set the “input” on the viewer, it asks its content provider to figure out what it should display for that kind of input. For this test, I don’t really care that the viewer-provider mechanism works (I assume, at least at this point, that somebody else is testing this for me). I do care that my content provider provides the types of values that it’s supposed to.

The testLabelProvider method ensures that the label provider for the ListViewer does what it’s supposed to do:

/**
 * This test ensures that the {@link BinderView#labelProvider} is
 * functioning properly. This test hammers directly on the label provider
 * (rather than working through the {@link ListViewer}) to ensure that the
 * label provider is properly generating labels and is correctly identifying
 * those properties that are used to create the label.
 *
 * @throws Exception
 */
@Test
public void testLabelProvider() throws Exception {
	ExpenseReport report = new ExpenseReport("Trip");
	assertEquals("Trip", view.labelProvider.getText(report));
	assertTrue(view.labelProvider.isLabelProperty(report, ExpenseReport.TITLE_PROPERTY));
	assertFalse(view.labelProvider.isLabelProperty(report, ExpenseReport.LINEITEMS_PROPERTY));
}

The label provider is pretty simple, so we don’t really need any additional tests (though you could argue that the last two asserts could reasonably be separate tests).

As an aside… I love how the label provider can be configured to know whether or not the label even needs to be updated depending on the properties that have changed.

Many of the remaining tests are concerned with proving that the right set of listeners are installed on the domain model objects. Again, most of these tests hammer directly on the content provider. For example:

/**
 * This test ensures that the {@link BinderView#expenseReportListener} is installed
 * on any {@link ExpenseReport} instance that is added to the binder after the
 * binder has been set into the view. We further check that the {@link ListViewer}
 * has been updated to include the new element.
 *
 * @throws Exception
 */
@Test
public void testThatListenerIsInstalledOnAddedExpenseReports() throws Exception {
	ExpenseReport addedReport = new ExpenseReport("Another Expense Report");
	binder.addExpenseReport(addedReport);

	assertEquals(view.expenseReportListener, addedReport.getPropertyChangeListeners()[0]);
	assertSame(addedReport, view.viewer.getElementAt(1));
}

/**
 * This test ensures that the {@link BinderView#expenseReportListener} is uninstalled
 * from any {@link ExpenseReport} instance that is removed from the binder after the
 * binder has been set into the view. We further check that the removed element
 * has been removed from the {@link ListViewer}.
 *
 * @throws Exception
 */
@Test
public void testThatListenerIsUninstalledFromRemovedExpenseReports() throws Exception {
	binder.removeExpenseReport(report);

	assertEquals(0, report.getPropertyChangeListeners().length);
	assertNull(view.viewer.getElementAt(0));
}

These tests confirm that, when an expense report is added or removed from the binder, the appropriate set of listeners are added to or removed from that expense report. These tests also confirm that the added or removed expense report is also added or removed from the ListViewer. One might argue that I should either change the names of the test methods or create additional tests for this last bit.

This is just a taste of the tests for the listeners; there’s a handful more tests to handle some other conditions. FWIW, these tests are the tip of the iceberg in motivating why you want to invest some time in understanding the JFace DataBinding API…

Finally, there are some tests that ensure that the “Remove” button’s enabled state is managed correctly:

/**
 * In this test, we are making sure that the state of the "Remove" button is
 * properly maintained. Specifically, we are ensuring that the "Remove"
 * button is enabled when a valid selection is made in
 * {@link BinderView#viewer} (an instance of {@link ListViewer}).
 *
 * <p>
 * Setting the selection in the ListViewer should invoke the
 * {@link ISelectionChangedListener}; this listener invokes the
 * {@link BinderView#updateButtons()} method which in turn sets the state of
 * the "Remove" button.
 *
 * @throws Exception
 */
@Test
public void testRemoveButtonEnabledWhenExpenseReportSelected() throws Exception {
	view.viewer.setSelection(new StructuredSelection(binder.getReports()[0]));
	assertTrue(view.removeButton.isEnabled());
}

/**
 * In this test, we are making sure that the state of the "Remove" button is
 * properly maintained.
 *
 * @see #testRemoveButtonEnabledWhenExpenseReportSelected()
 * @throws Exception
 */
@Test
public void testRemoveButtonDisabledWithEmptySelection() throws Exception {
	view.viewer.setSelection(StructuredSelection.EMPTY);
	assertFalse(view.removeButton.isEnabled());
}

That’s about it for this test class. I hope that you’ve found some of this valuable. It is indeed possible to use JUnit to test your user interface. Note that this is only part of an overall testing strategy. These whitebox testing techniques should be augmented with some blackbox testing along with amount of actual live user-testing and more.

If you’re interested in this example, I invite you to download it. Instructions can be found on the component’s page. Currently, I don’t have a project set for installing the tests, but you can find them in Eclipse CVS alongside the application code. There are some built-in launch configurations to run the tests, but if you decide not to use them, be sure to run the tests as a “JUnit Plug-in Test”.