Over the past few posts I've been exploring models for modularized Silverlight applications that follow the MVVM pattern (using Prism/CAL). In this post, I'd like to cover unit testing, and writing appropriate tests not just for the view model, but the view itself.
Before we continue, I'm going to assume you've read:
- Dynamic Module Loading with Silverlight Navigation using Prism
- MVVM Composition in Silverlight
- Simplifying Asynchronous Calls in Silverlight using Action
These articles form the core of what I'm about to discuss. I also want to make sure you're familiar with the Silverlight unit testing framework. You can download it and review some articles about how to use it over at the Unit Test Framework for Microsoft Silverlight page. I highly recommend pulling down the project and class templates as they will make your life easier!
The testing framework for Silverlight sets up a project that you run, and that project will then create a visual page that displays the results of tests. What's important is that the test framework will not only support class tests, but can also host controls and test the hosted controls as well. Do we even want to do this? I think so.
Set up your test project and make it the runnable one by adding a new project to your existing Silverlight solution, using the Silverlight project template, then right-clicking on the project and setting it as the start-up project.
Let's get started with a real example. I want to control the visibility of a control based on a boolean value in the view model, so I create a converter that takes in a boolean and returns visibility. I can bind the visibility like this:
1.
<
TextBlock
Text
=
"Conditional Text"
Visibility
=
"{Binding ConditionFlag,Converter={StaticResource BoolVisibilityConverter}}"
>
The code for the converter is simple:
1.
public
object
Convert(
object
value, Type targetType,
object
parameter, System.Globalization.CultureInfo culture)
2.
{
3.
return
(
bool
)value ? Visibility.Visible : Visibility.Collapsed;
4.
}
To test that we get what we want, simply add a new class in your test project (use the Silverlight Test Class template). With a little bit of typing you will end up with something like this:
01.
[TestClass]
02.
public
class
BoolVisibilityConverterTest
03.
{
04.
BoolVisibilityConverter _target;
05.
06.
[TestInitialize]
07.
public
void
Initialize()
08.
{
09.
_target =
new
BoolVisibilityConverter();
10.
}
11.
12.
[TestMethod]
13.
public
void
TestTrue()
14.
{
15.
object
result = _target.Convert(
true
,
typeof
(
bool
),
null
, CultureInfo.CurrentCulture);
16.
Assert.IsNotNull(result,
"Converter returned null."
);
17.
Assert.AreEqual(Visibility.Visible, result,
"Converter returned invalid result."
);
18.
}
19.
20.
[TestMethod]
21.
public
void
TestFalseNoParameter()
22.
{
23.
object
result = _target.Convert(
false
,
typeof
(
bool
),
null
, CultureInfo.CurrentCulture);
24.
Assert.IsNotNull(result,
"Converter returned null."
);
25.
Assert.AreEqual(Visibility.Collapsed, result,
"Converter returned invalid result."
);
26.
}
27.
}
Not rocket science there ... but it's nice to start out with a few green lights. When you run it, you'll see that your two tests passed and all is well (you can, of course, assert something invalid to see what a failure looks like).
Now let's test a view model. Our view model takes in a
IService
reference so that it can log a user in. It has bindings for username and password and a login command. The service looks like this:So the view model looks like this:1.
public
interface
IService
2.
{
3.
void
Login(
string
username,
string
password, Action<
bool
> result);
4.
}
001.
public
class
ViewModel : INotifyPropertyChanged
002.
{
003.
private
IService _service;
004.
005.
public
ViewModel(IService service)
006.
{
007.
_service = service;
008.
009.
LoginCommand =
new
DelegateCommand<
object
>( o=>CommandLogin );
010.
}
011.
012.
private
bool
_isDirty;
013.
014.
public
bool
IsDirty
015.
{
016.
get
{
return
_isDirty; }
017.
}
018.
019.
private
string
_username, _password;
020.
021.
public
string
Username
022.
{
023.
get
{
return
_username; }
024.
set
025.
{
026.
if
(value !=
null
&& !value.Equals(_username))
027.
{
028.
_username = value;
029.
OnPropertyChanged(
"UserName"
);
030.
}
031.
}
032.
}
033.
034.
public
string
Password
035.
{
036.
get
{
return
_password; }
037.
set
038.
{
039.
if
(value !=
null
&& !value.Equals(_password))
040.
{
041.
_password = value;
042.
OnPropertyChanged(
"Password"
);
043.
}
044.
}
045.
}
046.
047.
public
DelegateCommand<
object
> LoginCommand {
get
;
set
; }
048.
049.
public
void
CommandLogin()
050.
{
051.
if
(
string
.IsNullOrEmpty(_username))
052.
{
053.
throw
new
ValidationException(
"Username is required."
);
054.
}
055.
056.
if
(
string
.IsNullOrEmpty(_password))
057.
{
058.
throw
new
ValidationException(
"Password is required."
);
059.
}
060.
061.
_service.Login(_username, _password, (result) =>
062.
{
063.
if
(result)
064.
{
065.
// logic to navigate to a new page
066.
}
067.
else
068.
{
069.
throw
new
ValidationException(
"The username/password combination is invalid."
);
070.
}
071.
});
072.
}
073.
074.
protected
void
OnPropertyChanged(
string
property)
075.
{
076.
PropertyChangedEventHandler handler = PropertyChanged;
077.
if
(handler !=
null
)
078.
{
079.
handler(
this
,
new
PropertyChangedEventArgs(property));
080.
}
081.
if
(!_isDirty)
082.
{
083.
_isDirty =
true
;
084.
if
(handler !=
null
)
085.
{
086.
handler(
this
,
new
PropertyChangedEventArgs(
"IsDirty"
));
087.
}
088.
}
089.
}
090.
091.
public
void
ResetDirtyFlag()
092.
{
093.
if
(_isDirty)
094.
{
095.
_isDirty =
false
;
096.
PropertyChangedEventHandler handler = PropertyChanged;
097.
if
(handler !=
null
)
098.
{
099.
handler(
this
,
new
PropertyChangedEventArgs(
"IsDirty"
));
100.
}
101.
}
102.
}
103.
}
Notice how properties being set should automatically set the "dirty" flag as well. I may want to bind my login button to the flag so it only becomes available when the user has changed something, for example. There is also a public method to reset the flag.
In order to satisfy my service, I'll create a "mock" object. Why a mock, and not a stub? A stub is a piece of code you put in place to allow something to happen. If I wanted to stub my service, I'd do this:
1.
public
class
ServiceStub : IService
2.
{
3.
public
void
Login(
string
username,
string
password, Action<
bool
> result)
4.
{
5.
result(
true
);
6.
}
7.
}
This would always call back with a valid user and stubs out the functionality so I don't have to implement a real login. A mock object, on the other hand, changes. To make this a mock, I do this:
01.
public
class
ServiceMock : IService
02.
{
03.
public
bool
LoginCalled {
get
;
set
; }
04.
05.
public
void
Login(
string
username,
string
password, Action<
bool
> result)
06.
{
07.
LoginCalled =
true
;
08.
result(
true
);
09.
}
10.
11.
}
The class is a mock because it changes based on how it is used, and then we can query that change to see if our code is doing what we want. So let's set up some tests with the view model:
01.
[TestClass]
02.
public
class
ViewModelTest
03.
{
04.
05.
private
ViewModel _target;
06.
private
ServiceMock _service;
07.
08.
[TestInitialize]
09.
public
void
Initialize()
10.
{
11.
_service =
new
ServiceMock();
12.
_target =
new
ViewModel(_service);
13.
14.
}
15.
16.
[TestMethod]
17.
public
void
TestConstructor()
18.
{
19.
Assert.IsFalse(_target.IsDirty,
"Dirty flag should not be set."
);
20.
Assert.IsFalse(_service.LoginCalled,
"Login should not have been called."
);
21.
Assert.IsNotNull(_service.LoginCommand,
"Login command was not set up."
);
22.
}
23.
}
You can test that the username hasn't been populated, for example. Now we can do a little more. In the example, we throw a
ValidationException
(a custom class) when the username is invalid. The Silverlight 3 validation framework can capture this based on data binding and show appropriate error messages to the client. We want to make sure if we try to login, we throw the exception, so we can do this:1.
[TestMethod]
2.
[ExpectedException(
typeof
(ValidationException))]
3.
public
void
TestLoginValidation()
4.
{
5.
_target.CommandLogin();
6.
}
Here we call the login command on the empty object and it should throw (and catch) the exception we're looking for.
Finally, to use our mock object, we can set a valid user name and password and call the login command, then verify that the mock object was called:
01.
[TestMethod]
02.
public
void
TestLogin()
03.
{
04.
_target.Username =
"Valid Username"
;
05.
06.
//bonus test: check that the dirty flag got set
07.
Assert.IsTrue(_target.IsDirty,
"Dirty flag was not set on update."
);
08.
09.
_target.Password =
"Password"
;
10.
_target.CommandLogin();
11.
12.
Assert.IsTrue(_service.LoginCalled);
13.
}
After testing your view model, you can then begin to work on testing the view itself. In the "required reading" I discussed having a generic view base that would interact with a navigation manager to swap views into and out of view. The views are all contained in an
ItemsControl
, and register to a view change event. If the view goes out of focus, it moves to a hidden state. If the view comes into focus, it moves to a visible state. While this allows more control over the way the states appear and how to transition between states, there is also the chance someone may add a view and forget wire in the visual state groups. TheVisualStateManager
won't complain, but it can look ugly. We need to test for things like this!Fortunately, the testing framework allows for us to host actual views. It provides a testing surface that we add controls to, and those controls are rendered so you can then inspect the visual tree. In this case, we want to emulate a view navigating to a new view and test that it is moved to the correct state.
Create a new test class. This time, however, we will inherit from the base class
SilverlightTest
which provides our class with a test panel to host controls on. The set up is a bit more involved, because we need to fold the mock services into the view model, then create the view and glue it all together.Before we do this, we'll create a helper class called
QueryableVisualStateManager
. This class is one I borrowed from Justin Angel's fantastic blog post about Custom Visual State Managers. In his post, he details how to create a custom visual state manager that holds a dictionary of the control states so they can be queried later on (in case you've been pulling your hair out in frustration, the framework does not provide direct access to query the current visual state of controls).I created the class verbatim, but don't care to use it in production code. Instead, we'll inject it in our test class. Here's the setup:
01.
[TestClass]
02.
public
class
LoginTest : SilverlightTest
03.
{
04.
private
Login _login;
05.
private
ViewModel _viewModel;
06.
private
ServiceMock _service;
07.
private
NavigationManager _navigationManager;
08.
private
QueryableVisualStateManager _customStateManager;
09.
10.
[TestInitialize]
11.
public
void
TestInitialize()
12.
{
13.
_login =
new
Login();
14.
15.
FrameworkElement root = VisualTreeHelper.GetChild(_login, 0)
as
FrameworkElement;
16.
root.SetValue(VisualStateManager.CustomVisualStateManagerProperty,
new
QueryableVisualStateManager());
17.
18.
_service =
new
ServiceMock();
19.
_navigationManager =
new
NavigationManager();
20.
_viewModel =
new
ViewModel(_service);
21.
_viewModel.Navigation = _navigationManager;
22.
_login.DataContext = _viewModel;
23.
TestPanel.Children.Add(_login);
24.
}
25.
}
What happened?
Login
is my user control ... it is the view I inject into the shell to show the login page. Here I create an instance of it. Then, I use my friend theVisualTreeHelper
to parse the the first child, which is going to be the grid or stack panel or whatever "host" control you have inside your user control. Then, I simply set the attached property for the custom view manager to point to the queryable helper class. This will ensure any visual state transitions are recorded in the internal dictionary. Then I wire up my mocks, databind, and finally add the login control to theTestPanel
. It now gets hosted on a real test surface and can initialize and display.Let's assume that the navigation manager I injected is responsible for swapping the view state of the control. The control goes into a
HideState
when not visible and aShowState
when visible. What I want to test is a simulated login command. We already tested this in the view model, so we can be confident it is going to hit the service and do what it is supposed to do. There is a piece of code that then calls the navigation manager and changes the control's state to hidden. We want to test that this hook actually gets fired when the user clicks login, so the login view disappears. Here's how:01.
[TestMethod]
02.
public
void
TestLogin()
03.
{
04.
const
string
SHOWSTATE =
"VisualStates.ShowState"
;
05.
const
string
HIDESTATE =
"VisualStates.HideState"
;
06.
07.
// set up this view
08.
_navigationManager.NavigateToPage(NavigationManager.NavigationPage.Login);
09.
10.
string
state = QueryableVisualStateManager.QueryState(_login);
11.
Assert.IsTrue(state.Contains(SHOWSTATE) && !state.Contains(HIDESTATE),
"Invalid visual state."
);
12.
13.
// trigger login
14.
_viewModel.Username =
"user"
;
15.
_viewModel.Password =
"password"
;
16.
_viewModel.CommandLogin();
17.
18.
state = QueryableVisualStateManager.QueryState(_login);
19.
Assert.IsTrue(state.Contains(HIDESTATE) && !state.Contains(SHOWSTATE),
"Invalid visual state."
);
20.
}
We first test the pre-condition by navigating to the login page and confirming it has the
ShowState
and not theHideState
. Then, we simulate a login action (this is why command binding and view models are so powerful) and query the state again, testing to make sure we went into a hidden state.When you run this test, you might actually see the control flicker for a moment on the screen as it gets initialized on the test surface before it is manipulated and then discarded for other tests. With the right architecture, you are now able to test from the view down to the backend services that drive your application. Now that's powerful!
Wednesday, January 27, 2010
Unit Tests for ViewModels AND Views in Silverlight
Subscribe to:
Post Comments (Atom)
No comments:
Post a Comment