In an interview earlier this year with MSDN geekSpeak, I discussed unit testing for Silverlight and some of the frameworks that are available. One audience member raised a very important question: "How do we test the XAML?" Specifically, what happens when we hand off XAML to a designer or another developer, and they accidently remove a data-binding or other critical element, then pass it back to us?
The Problem
Many developers don't realize this, but the view is not decoupled from the underlying code. Unless you are using a convention-based approach (and then an affinity still exists, albeit expressed via the convention), you are likely either wiring in events and managing the view from code-behind, or using data-binding to bind a view to an underlying view model or binding model. One mistake people tend to make is the assumption that the view is completely decoupled from the view model.
The truth is, the data-bindings are a double-edged sword. First, make no mistake, you are explicitly referring to a contract when you specify data-binding. You might not need to know the specific type of the underlying data context, but you are pointing to specific sources and property paths on "some object" that must be satisfied. The "double-edge" is that the data-binding framework will silently swallow any exceptions if you happen to bind to the wrong path. Fat-finger "country" as "county" and you may find a big blank label appearing where you were hoping to see "United States."
Even more likely is the fact that often you may have a designer creating XAML assets, an integrator moving those assets into the project and then a developer working on the actual code. At any phase of the hand-off, someone might get fancy and decide to replace a combo box with a list box and forget to update the data-bindings. The compiler won't complain at all, because there is no check for the absence of data-binding, so you'll likely not catch the error until you begin testing the application.
The Solution
While there is no easy way to "type" the view and force the compiler to emit errors when the data-binding is incorrect, you can create unit tests on those views to test the expected bindings. You can also test for attached behaviors and other artifacts in the XAML that are expected for the application to run successfully.
Let's take a look at a simple view model I like to use when demonstrating the Model-View-ViewModel (MVVM) pattern. I call it Cascadia
because it facilitates the classic case of cascading dropdowns. The view model sits on top of a service for fetching countries and states or provinces, that is injected via the Managed Extensibility Framework. MEF is outside the scope of this post, and we won't need it for testing anyway because we'll mock the dependencies. Here is my view model:
[Export] public class CascadiaViewModel : INotifyPropertyChanged, IPartImportsSatisfiedNotification { [Import] public IAddressService AddressService { get; set; } public CascadiaViewModel() { Countries = new ObservableCollection<Country>(); States = new ObservableCollection<State>(); if (!DesignerProperties.IsInDesignTool) return; // design-time controls var country = new Country {CountryCode = "US", CountryName = "United States"}; var state = new State {StateCode = "GA", StateName = "Georgia", StateCountry = country}; Countries.Add(country); States.Add(state); CurrentCountry = country; CurrentState = state; } public ObservableCollection<Country> Countries { get; set; } private Country _country; public Country CurrentCountry { get { return _country; } set { _country = value; _UpdateStates(); _RaisePropertyChanged("CurrentCountry"); } } private void _UpdateStates() { if (DesignerProperties.IsInDesignTool) { return; } States.Clear(); AddressService.GetStatesForCountry(CurrentCountry.CountryCode, states=> { foreach(var state in states) { States.Add(state); } CurrentState = States[0]; }); } public ObservableCollection<State> States { get; set; } private State _state; public State CurrentState { get { return _state; } set { _state = value; _RaisePropertyChanged("CurrentState"); } } private void _RaisePropertyChanged(string propertyName) { var handler = PropertyChanged; if (handler != null) { handler(this, new PropertyChangedEventArgs(propertyName)); } } public event PropertyChangedEventHandler PropertyChanged; public void OnImportsSatisfied() { AddressService.GetCountries(countries => { foreach (var country in countries) { Countries.Add(country); } CurrentCountry = Countries[0]; }); } }
Notice that I am providing some design-time support right in the view model so it will appear in the designer when a view is databound. There may be some arguments about the concept of a "designer" leaking into my view model, but in my opinion, the view model is specific to views. It is not "business logic" but an entity responsible for coordinating the hand-off between underlying business logic and the view itself. Therefore, why not have the intelligence to provide some sample data during design time?
The behavior is straightforward. We populate a list of countries, synchronize a default country, then populate a list of states for the selected country and synchronize a default state. The expected behavior is that I can change the selected state, and when I change the selected country, my state list is refreshed and a new default picked. All of this can be tested before we even worry about the service or the view, in my opinion one of the advantages of the MVVM pattern.
Next, let's take a look at the XAML for a view that binds to the view model. Here is the markup:
<UserControl x:Uid="UserControl_1" x:Class="Cascadia.MainPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" d:DesignHeight="300" d:DesignWidth="400"> <Grid x:Uid="LayoutRoot" x:Name="LayoutRoot" Background="White" DataContext="{Binding Source={StaticResource VMLocator},Path=Cascadia}"> <Grid.ColumnDefinitions> <ColumnDefinition x:Uid="ColumnDefinition_1" Width="Auto" /> <ColumnDefinition x:Uid="ColumnDefinition_2" Width="Auto" /> </Grid.ColumnDefinitions> <Grid.RowDefinitions> <RowDefinition x:Uid="RowDefinition_1" Height="Auto" /> <RowDefinition x:Uid="RowDefinition_2" Height="Auto" /> <RowDefinition x:Uid="RowDefinition_3" Height="Auto" /> <RowDefinition x:Uid="RowDefinition_4" Height="Auto" /> </Grid.RowDefinitions> <TextBlock x:Uid="TextBlock_1" Text="Select Your Country:" Grid.Row="0" Grid.Column="0" AutomationProperties.AutomationId="TextBlock_1" /> <ComboBox x:Uid="cbCountries" x:Name="cbCountries" Grid.Row="0" Grid.Column="1" ItemsSource="{Binding Countries}" SelectedItem="{Binding CurrentCountry,Mode=TwoWay}" DisplayMemberPath="CountryName" /> <TextBlock x:Uid="TextBlock_2" Text="Select Your State:" Grid.Row="1" Grid.Column="0" AutomationProperties.AutomationId="TextBlock_2" /> <ComboBox x:Uid="cbStates" x:Name="cbStates" ItemsSource="{Binding States}" SelectedItem="{Binding CurrentState,Mode=TwoWay}" Grid.Row="1" Grid.Column="1" DisplayMemberPath="StateName" /> <TextBlock x:Uid="TextBlock_3" Text="Selected Country:" Grid.Row="2" Grid.Column="0" AutomationProperties.AutomationId="TextBlock_3" /> <TextBlock x:Uid="tbSelectedCountry" x:Name="tbSelectedCountry" Text="{Binding Path=CurrentCountry.CountryName}" Grid.Row="2" Grid.Column="1" Height="16" VerticalAlignment="Bottom" /> <TextBlock x:Uid="TextBlock_4" Text="Selected State:" Grid.Row="3" Grid.Column="0" AutomationProperties.AutomationId="TextBlock_4" /> <TextBlock x:Uid="tbSelectedState" x:Name="tbSelectedState" Text="{Binding Path=CurrentState.StateName}" Grid.Row="3" Grid.Column="1" /> </Grid> </UserControl>
This basically shows two drop-downs, with two labels that synchronize with the selected items. The "affinity" or "coupling" is introduced when we specify a data-binding path and expected something specific, such as CurrentState.StateName
, to be present and correct. Our application is not functioning correctly if these bindings don't work.
If you're wondering about the Uid
and AutomationProperties
, both are important for globalization/localization as well as accessibility. In fact, I'm excited to be on one of the teams that is previewing UI Automation for Silverlight. This allows recording and/or creating coded UI tests (similar to WPF) and running/testing those against Silverlight applications. I've been very pleased with what the tool can do so far and am excited about the upcoming release to the public (this is not the same as the "manual" automation that I blogged about here.)
The Test
Now we can get down to writing a test to ensure that the bindings are working properly. In The Art of Unit Testing author Roy Osherove lists several properties of a good unit test. Two of those include it should run at the push of a button and it should run quickly. In this case, the workflow is that we receive XAML from a designer or integrator. Upon checkin, either we have an automated build process that runs our unit tests automatically, or we pull it down and as a manual process set the test page as the startup page and hit F5 to run the tests. Either way, we can check very quickly whether or not our bindings were preserved.
What does a test look like? In the setup for our test, we'll mock out the service and specify the countries and states we want. I'll also override the view locator specified in XAML and directly bind to the view model for our test. This is the test initialize method:
private MainPage _target; private CascadiaViewModel _viewModel; private Mock<IAddressService> _service; private Country[] _countries; private State[] _states; [TestInitialize] public void TestInit() { var us = new Country {CountryCode = "US", CountryName = "United States"}; var georgia = new State {StateCode = "GA", StateCountry = us, StateName = "Georgia"}; var mexico = new Country {CountryCode = "MX", CountryName = "Mexico"}; var oaxaca = new State {StateCode = "OA", StateCountry = mexico, StateName = "Oaxaca"}; _target = new MainPage(); _countries = new[] {us, mexico}; _states = new[] {georgia, oaxaca}; _viewModel = new CascadiaViewModel(); _service = new Mock<IAddressService>(); _viewModel.AddressService = _service.Object; _service.Setup(s => s.GetCountries(It.IsAny<Action<IEnumerable<Country>>>())) .Callback((Action<IEnumerable<Country>> action) => action(_countries)); _service.Setup(s => s.GetStatesForCountry(It.IsAny<string>(), It.IsAny<Action<IEnumerable<State>>>())) .Callback( (string countryCode, Action<IEnumerable<State>> action) => action(from s in _states where s.StateCountry.CountryCode.Equals(countryCode) select s)); GetUiElement<Grid>("LayoutRoot").DataContext = _viewModel; } private T GetUiElement<T>(string name) where T : UIElement { return (T) _target.FindName(name); }
Notice at this point we are just setting up the mocks and data-binding the view model. Next, we'll test the actual bindings:
[Asynchronous] [TestMethod] public void TestCountrySelection_UpdatesViewModelAndDataBindings() { _target.Loaded += (o, e) => { _viewModel.OnImportsSatisfied(); // set this up var comboBox = GetUiElement<ComboBox>("cbCountries"); var stateComboBox = GetUiElement<ComboBox>("cbStates"); var textBlock = GetUiElement<TextBlock>("tbSelectedCountry"); var stateTextBlock = GetUiElement<TextBlock>("tbSelectedState"); var defaultStates = from s in _states where s.StateCountry.CountryCode.Equals( _countries[0].CountryCode) select s; // defaults Assert.AreEqual(_countries[0], _viewModel.CurrentCountry, "Country combo box failed to initialize current country."); Assert.AreEqual(_countries[0].CountryName, textBlock.Text, "Country combo box failed to initialize the text block."); Assert.AreEqual(_states[0], _viewModel.CurrentState, "Failed to initialize current state."); Assert.AreEqual(_states[0].StateName, stateTextBlock.Text, "Failed to initialize the state text block."); CollectionAssert.AreEquivalent(comboBox.Items, _countries, "Failed to data-bind countries."); CollectionAssert.AreEquivalent(stateComboBox.Items, defaultStates.ToArray(), "Failed to data-bind states."); comboBox.SelectedItem = _countries[1]; var mexicoStates = from s in _states where s.StateCountry.CountryCode.Equals( _countries[1].CountryCode) select s; // defaults Assert.AreEqual(_countries[1], _viewModel.CurrentCountry, "Country combo box failed to update current country."); Assert.AreEqual(_countries[1].CountryName, textBlock.Text, "Country combo box failed to update the text block."); Assert.AreEqual(_states[1], _viewModel.CurrentState, "Failed to update current state."); Assert.AreEqual(_states[1].StateName, stateTextBlock.Text, "Failed to update the state text block."); CollectionAssert.AreEquivalent(stateComboBox.Items, mexicoStates.ToArray(), "Failed to update data-binding for states."); EnqueueTestComplete(); }; TestPanel.Children.Add(_target); }
We are doing a few things here. Unit tests follow the pattern: arrange, act, assert. I am arranging the view model and services in the beginning. Note that we wait until the target view is loaded before acting on it, so that data-binding has a chance to take effect. We are placing the view on a test surface, but tools like StatLight can effectively mock the surface to allow the tests to be run offline in an automated fashion. The test is asynchronous because we must wait for the loaded event to fire before acting and asserting, and the last step we do is add the view to the test surface.
When the view is loaded, I'm first testing some pre-conditions. These are the default bindings we'd expect from the view model being initialized. Then, I change the selection and assert that the changes happened as expected.
This test will function on several levels. First, because I'm grabbing controls by name, if the names change (for example, we change a combo box to a list box and change the name to "lbCountries") then the test will fail. If I don't have an affinity for named controls (i.e. everything happens through databinding) then I will change my strategy to use the automation id. I'll call the control something generic like "CountryList" and will find it regardless of the control type or name.
Second, we have some very specific behaviors we expect, such as changing a country and having the state list and default state update as well. This is fully tested and if any of the data-bindings are broken, the test will fail. I can quickly determine if the XAML was corrupted and fix the problem before it gets into production. I think most developers will agree that the easiest way to fix a bug is to find it as close to the source as possible.
What if I needed to test a custom control, or even do something simple like emulate a button click to test data-binding to an ICommand
object? No problem. This is where we would use automation peers. For example, to simulate a button click, I can include the following code:
var btn = GetUiElement<Button>("btnCommand"); var automation = new ButtonAutomationPeer(btn); var provider = automation.GetPattern(PatternInterface.Invoke) as IInvokeProvider; provider.Invoke();
The above code will simulate a button click, and then I can test my command to ensure it fired correctly. For custom controls, you can provide your own automation peer, which is a good idea anyway for accessibility.
Conclusion
Hopefully this article demonstrated another way to catch issues fast, close to the source and before the code even makes it to your QA machine. Indirectly, I've shown how MVVM can improve the development process through both testability and workflow (i.e. being able to work on independent pieces, including XAML and view logic, separately). You also witnessed how unit testing provides value to the development process and why most of the time the net impact is faster time to "production ready" in spite of the overhead of writing tests.