Monday, 19 May 2008

MVC Architecting Silverlight Applications Part 3 - Testing the ViewModel

In Part 2 of this series, I built a small class used to model our Login page - LoginViewModel.

In this post, I will demonstrate how to unit test this class to show that it behaves as expected. This is pretty essential as we want to be sure the class works properly before handing it over to our designer to build a view for!

I will be using TestDriven.NET's new Silverlight unit testing support which I blogged about earlier. This is unbelievably simple - just download and install the project template and then create a new Silveright NUnit Project called Silversocial.Client.Modules.Test

Building some test doubles


Before we can start unit testing our LoginViewModel, we need to fake out the dependencies. Given that there currently exists no mocking framework for Silverlight ( are you listening Ayende? ;-> ) we will manually build some stubs for our dependencies and then reuse them in our tests.

It is quite easy to way manually to build reusable stubs for an interface of your choosing. I simply follow these rules:
  • Use "explicit interface implementations" for all members

  • Implement properties as normal properties with a backing field (or as automatic properties if you'd prefer)

  • Implement methods by delegating the entire method body to a public event handler with the same signature and name as the method. Set the event handler to empty delegate to avoid null checks

  • Implement events by delegating the add/remove to a public event handler with the same signature as the event. Set the event handler to empty delegate to avoid null checks


Here is the code for the stub for the IAppShell interface.

using Silverstone;

 

namespace Silversocial.Client.Modules.Tests.Stubs

{

    public class AppShellStub : IAppShell

    {

        private User user;

        private IView view;

 

        public User User

        {

            get { return this.user; }

        }

 

        public IView View

        {

            get { return this.view; }

        }

 

        User IAppShell.User

        {

            get { return this.user; }

            set { this.user = value; }

        }

 

        void IShell.SetView(IView view)

        {

            this.view = view;

        }

    }

}


And now the stub for the ILoginDataProvider, also following the guidelines set above:

using System;

 

namespace Silversocial.Client.Modules.Tests.Stubs

{

    public class LoginDataProviderStub : ILoginDataProvider

    {

        private EventHandler<ValidateUserCompletedEventArgs> validateUserCompleted = delegate { };

 

        public Action<User> ValidateUser = delegate { };

 

        public void RaiseValidateUserCompleted(ValidateUserCompletedEventArgs args)

        {

            this.validateUserCompleted(this, args);

        }

 

        event EventHandler<ValidateUserCompletedEventArgs> ILoginDataProvider.ValidateUserCompleted

        {

            add { this.validateUserCompleted += value; }

            remove { this.validateUserCompleted -= value; }

        }

 

        void ILoginDataProvider.ValidateUser(User user)

        {

            this.ValidateUser(user);

        }

    }

}


We will create a "ViewStubBase" which implements the IView interface itself. This will form the base class for all other stubs of the views.

using System;

using Silverstone;

 

namespace Silversocial.Client.Modules.Tests.Stubs

{

    public abstract class ViewStubBase : IView

    {

        public Action OnLoad = delegate { };

        public Action OnUnload = delegate { };

 

        void IView.OnLoad()

        {

            this.OnLoad();

        }

 

        void IView.OnUnload()

        {

            this.OnLoad();

        }

    }

}


And now the stub for the ILoginView interface. As you can see we just implement the LoginUnsuccessful method and call a public event handler which does nothing by default. The allows the test to listen to the method being called if it wants to, and can for example validate if it is called or not.

using System;

 

namespace Silversocial.Client.Modules.Tests.Stubs

{

    public class LoginViewStub : ViewStubBase, ILoginView

    {

        public Action LoginUnsuccessful = delegate { };

 

        void ILoginView.LoginUnsuccessful()

        {

            this.LoginUnsuccessful();

        }

    }

}


The other views are just empty classes for now. The first:

namespace Silversocial.Client.Modules.Tests.Stubs

{

    public class FriendListStub : ViewStubBase, IFriendListView

    {

    }

}


And the other...

namespace Silversocial.Client.Modules.Tests.Stubs

{

    public class RegisterStub : ViewStubBase, IRegisterView

    {

    }

}


The test fixture



Finally it's time to implement the test fixture, just using the standard NUnit syntax for defining the tests and setup method. The SetUp creates all the stubs, so they can be used by each test.

Note I am using the "AAA" pattern which was recently coined by Ayende for RhinoMocks 3.5 - I really like it! This is the way I have written most of my tests in the past, and it's nice to have a formal name for the stages undergone (especially when it's an alliterism)

Here are the tests:

using NUnit.Framework;

using Silversocial.Client.Modules.Tests.Stubs;

using Silverstone;

 

namespace Silversocial.Client.Modules.Tests

{

    [TestFixture]

    public class LoginViewModelTester

    {

        private LoginViewStub loginViewStub;

        private AppShellStub shellStub;

        private FriendListStub friendListStub;

        private RegisterStub registerStub;

        private LoginDataProviderStub dataProviderStub;

        private LoginViewModel viewModel;

 

        [SetUp]

        public void SetUp()

        {

            this.shellStub = new AppShellStub();

            this.friendListStub = new FriendListStub();

            this.registerStub = new RegisterStub();

            this.dataProviderStub = new LoginDataProviderStub();

            this.viewModel = new LoginViewModel(shellStub, dataProviderStub, friendListStub, registerStub);

 

            this.loginViewStub = new LoginViewStub();

            ((IViewModel) this.viewModel).SetView(this.loginViewStub);

        }

 

        [Test]

        public void Login_WithoutUsernameAndPassword_CannotExecute()

        {

            Assert.IsFalse(this.viewModel.Login.CanExecute(null));

        }

 

        [Test]

        public void Login_WithUsernameAndPassword_CanExecute()

        {

            // Arrange

            this.viewModel.User.Username = "abc";

            this.viewModel.User.Password = "abc";

 

            // Assert

            Assert.IsTrue(this.viewModel.Login.CanExecute(null));

        }

 

        [Test]

        public void Login_BeforeCompleted_IsLoggingInIsTrue()

        {

            // Act

            this.viewModel.Login.Execute(null);

 

            // Assert

            Assert.IsTrue(this.viewModel.IsLoggingIn);

        }

 

        [Test]

        public void Login_AfterCompleted_IsLoggingInIsFalse()

        {

            // Act

            this.viewModel.Login.Execute(null);

            this.dataProviderStub.RaiseValidateUserCompleted(new ValidateUserCompletedEventArgs(false));

 

            // Assert

            Assert.IsFalse(this.viewModel.IsLoggingIn);

        }

 

        [Test]

        public void Login_AfterCompleted_RaisesCanExecuteChangedEvent()

        {

            // Arrange

            bool canExecuteChangedCalled = false;

            this.viewModel.Login.CanExecuteChanged +=

                delegate { canExecuteChangedCalled = true; };

 

            // Act

            this.dataProviderStub.RaiseValidateUserCompleted(new ValidateUserCompletedEventArgs(false));

 

            // Assert

            Assert.IsTrue(canExecuteChangedCalled);

        }

 

        [Test]

        public void Login_WhilstLoggingIn_CannotExecute()

        {

            // Arrange

            this.viewModel.User.Username = "abc";

            this.viewModel.User.Password = "abc";

            this.viewModel.IsLoggingIn = true;

 

            // Assert

            Assert.IsFalse(this.viewModel.Login.CanExecute(null));

        }

 

        [Test]

        public void Login_Always_CallsValidateUserOnDataProvider_WithCorrectUser()

        {

            // Arrange

            User userSentToDataProvider = null;

            this.dataProviderStub.ValidateUser +=

                u => userSentToDataProvider = u;

 

            // Act

            this.viewModel.Login.Execute(null);

 

            // Assert

            Assert.AreEqual(this.viewModel.User, userSentToDataProvider);

        }

 

        [Test]

        public void Login_WithInvalidDetails_CallsLoginUnsuccessfulOnView()

        {

            // Arrange

            this.dataProviderStub.ValidateUser +=

                u => this.dataProviderStub.RaiseValidateUserCompleted(new ValidateUserCompletedEventArgs(false));

            bool loginUnsuccessfulCalledOnView = false;

            this.loginViewStub.LoginUnsuccessful +=

                () => loginUnsuccessfulCalledOnView = true;

 

            // Act

            this.viewModel.Login.Execute(null);

 

            // Assert

            Assert.IsTrue(loginUnsuccessfulCalledOnView);

        }

 

        [Test]

        public void Login_WithValidDetails_SetsFriendListViewOnShell()

        {

            // Arrange

            this.dataProviderStub.ValidateUser +=

                u => this.dataProviderStub.RaiseValidateUserCompleted(new ValidateUserCompletedEventArgs(true));

 

            // Act

            this.viewModel.Login.Execute(null);

 

            // Assert

            Assert.AreEqual(this.friendListStub, this.shellStub.View);

        }

 

        [Test]

        public void Login_WithValidDetails_SetsUserOnShell()

        {

            // Arrange

            this.dataProviderStub.ValidateUser +=

                u => this.dataProviderStub.RaiseValidateUserCompleted(new ValidateUserCompletedEventArgs(true));

 

            // Act

            this.viewModel.Login.Execute(null);

 

            // Assert

            Assert.AreEqual(this.viewModel.User, this.shellStub.User);

        }

 

        [Test]

        public void Register_Always_SetsRegisterViewOnShell()

        {

            // Act

            this.viewModel.Register.Execute(null);

 

            // Assert

            Assert.AreEqual(this.registerStub, this.shellStub.View);           

        }

    }

}


And here you can see them running in the Resharper test runner - they all pass!



Now I can confidently pass my ViewModel to my designer to build a view against...

Or can I? Without a data provider, he's going to have problems creating and testing his view. This will be the subject of the next post.

2 comments:

Ayende Rahien said...

Yes, I am listening.
As I am not doing any sort of silverlight project at the moment, I would gladly welcome a patch.

Neil Mosafi said...

LOL I had a feeling you'd say that - as it says on your blog line!
I did try building it for SL but to no avail, will keep you updated (perhaps Beta 2 will yield some enjoyments).