6

Decoupling ViewModels in MVVM with the MVVM Toolkit Messenger

 2 years ago
source link: https://blogs.msmvps.com/bsonnino/2022/08/27/decoupling-viewmodels-in-mvvm-with-the-mvvm-toolkit-messenger/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Decoupling ViewModels in MVVM with the MVVM Toolkit Messenger – Bruno Sonnino

Bruno Sonnino

When you are using the MVVM pattern, at some time, you have to send data between ViewModels. For example, a detail ViewModel must tell the collection ViewModel that the current item should be deleted, or when you have a main view that opens details views and keeps track of them and must be acknowledged of any of them closing.
This is a very common pattern and usually is solved by coupling the two ViewModels.
That’s a solution, but not the best one, because coupling makes difficult maintenance and testing. For example, we’ll use the WPF project at https://github.com/bsonnino/MessengerToolkit/tree/Original.

This is a WPF project with a main window that shows a grid with all the customers:

Decoupling-ViewModels-in-MVVM-with-the-MVVM-Toolkit-MessengerMessenger1.png

When you click the button at the right, it opens a secondary window with the details:

Decoupling-ViewModels-in-MVVM-with-the-MVVM-Toolkit-MessengerMessenger2.png

This project is composed by two ViewModels, MainViewModel and CustomerViewModel. MainViewModel serves the main view and performs all actions for the buttons: Filter the data, Add a new customer and Save the data:

public partial class MainViewModel : ObservableObject
{
    private readonly ICustomerRepository _customerRepository;
    private readonly INavigationService _navigationService;
    
    public MainViewModel(ICustomerRepository customerRepository, INavigationService navigationService)
    {
        _customerRepository = customerRepository ?? 
                              throw new ArgumentNullException("customerRepository");
        Customers = new ObservableCollection<CustomerViewModel>(
            _customerRepository.Customers.Select(c => new CustomerViewModel(c)));
        _navigationService = navigationService;
    }

    [ObservableProperty]
    private ObservableCollection<CustomerViewModel> _customers;

    [ObservableProperty]
    private int _windowCount;

    [RelayCommand]
    private void Add()
    {
        var customer = new Customer();
        _customerRepository.Add(customer);
        var vm = new CustomerViewModel(customer);
        Customers.Add(vm);
        _navigationService.Navigate(vm);
    }

    [RelayCommand]
    private void Save()
    {
        _customerRepository.Commit();
    }

    [RelayCommand]
    private void Search(string textToSearch)
    {
        var coll = CollectionViewSource.GetDefaultView(Customers);
        if (!string.IsNullOrWhiteSpace(textToSearch))
            coll.Filter = c => ((CustomerViewModel)c).Country?.ToLower().Contains(textToSearch.ToLower()) == true;
        else
            coll.Filter = null;
    }

    [RelayCommand]
    private void ShowDetails(CustomerViewModel vm)
    {
        _navigationService.Navigate(vm);
        WindowCount++;
    }
}

It uses the features available in version 8 of the MVVM toolkit. In this code, we can see two things:

  • The ViewModel maintains the open windows count. When you click on the button to show the details, it will increase the window count. The problem here is to decrease the count once the window is closed. Right now, the count only increases 😃
  • To open the detail window, we could use something like:
private void ShowDetails(CustomerViewModel vm)
{
    var detailWindow = new Detail { DataContext = vm };
    detailWindow.Show();
    WindowCount++;
}

This approach has two flaws:

  • We are coupling the ViewModel to the Detail view
  • We are calling View details in the ViewModel

This makes this code to be completely untestable and defeats all purpose of the MVVM pattern. So, we must find another solution. What I devised is a NavigationService, that will open a Window, depending on the type of the ViewModel that is passed to the Navigate method. This service is very simple, and has only one method,Navigate:

public interface INavigationService
{
    void Navigate(object arg);
}

public class NavigationService : INavigationService
{
    private readonly Dictionary<Type, Type> viewMapping = new()
    {
        [typeof(MainViewModel)] = typeof(MainWindow),
        [typeof(CustomerViewModel)] = typeof(Detail),
    };

    public void Navigate(object arg)
    {
        Type vmType = arg.GetType();
        if (viewMapping.ContainsKey(vmType))
        {
            var windowType = viewMapping[vmType];
            var window = (System.Windows.Window)Activator.CreateInstance(windowType);
            window.DataContext = arg;
            window.Show();
        }
    }
}

We’ve defined an interface, INavigationService, and created a class that implements it, NavigationService. This class has a dictionary with all the ViewModel types that have a corresponding window type, with their corresponding window type as the value of the item.

When we call Navigate and pass the instance of the ViewModel, the method will check its type, verify if it exists in the dictionary and instantiate the window, setting the VM as its DataContext and opening the Window. That way, we decouple the ViewModel from the views. The navigation service is injected in the constructor of MainViewModel by the use of dependency injection.

In App.xaml.cs we are registering the services and the VM. That way, we get the right data injected to the main VM:

public IServiceProvider Services { get; }

private static IServiceProvider ConfigureServices()
{
    var services = new ServiceCollection();

    services.AddSingleton<INavigationService, NavigationService>();
    services.AddSingleton<ICustomerRepository, CustomerRepository>();
    services.AddSingleton<MainViewModel>();

    return services.BuildServiceProvider();
}

One note is ShowDetails, which in fact is a command that should be sent to the CustomerViewModel, because it’s tied to every CustomerViewModel in the grid. What we did is to tie it to the ViewModel of the main view with:

<DataGridTemplateColumn>
    <DataGridTemplateColumn.CellTemplate>
        <DataTemplate>
            <Button Command="{Binding DataContext.ShowDetailsCommand, 
                RelativeSource={RelativeSource AncestorType=Window}}" 
                    CommandParameter="{Binding}" ToolTip="Details">
                <TextBlock Text="" FontFamily="Segoe UI Symbol" />
            </Button>
        </DataTemplate>
    </DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>

We set the command to ShowDetailsCommand in the datacontext of the parent window, and pass the current CustomerViewModel as a parameter. That suffices to do the trick.

Now, onto our problems:

  • The Window Closing event is just that – an event, not a command and there is no direct way to handle it in the ViewModel
  • Even if we can handle it in the ViewModel, there is no direct way to warn the main viewmodel about the change
  • When we click the Remove button in the Detail view, we cannot remove the item from the list, because the list is on the Main ViewModel, which is not accessible from the Customer ViewModel

For the first problem, we could handle the Closing event in the code behind of the Detail view:

private void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{
    var vm = DataContext as CustomerViewModel;
    vm.ClosingCommand.Execute(null);
}

or, better, send the closing signal directly to the main viewmodel:

private void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{
    var vm = App.Current.MainVM;
    vm.ClosingCommand.Execute(null);
}

This would work fine, but purists would say that it’s a code smell. So, let’s do it in the MVVM way. For that, we must install the Microsoft.Xaml.Behaviors.WPF NuGet package, that has some behaviors we will need in the detail window:

<Window x:Class="CustomerApp.Detail"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:b="http://schemas.microsoft.com/xaml/behaviors"
        mc:Ignorable="d"
        Height="440"
        Width="520"
        Background="AliceBlue">
    <b:Interaction.Triggers>
        <b:EventTrigger EventName="Closing">
            <b:InvokeCommandAction Command="{Binding ClosingCommand}" />
        </b:EventTrigger>
    </b:Interaction.Triggers>

There, we have Interaction.Triggers, to add the triggers we want. We want to use the EventTrigger, that is triggered whe an event is fired. In our case, it’s the Closing event. When it’s fired, it will invoke the command ClosingCommand in the detail window. Problem solved. No more code behind.

To solve the second and third problem, we would have to couple our Detail ViewModel to the Main ViewModel and have a variable that stores its instance. One way would be to pass the VM as parameter:

private readonly MainViewModel _mainvm;

public CustomerViewModel(Customer customer, MainViewModel mainvm)
{
    _customer = customer;
    _mainvm = mainvm;
    CustomerId = _customer.CustomerId;
    CompanyName = _customer.CompanyName;
    ContactName = _customer.ContactName;
    ContactTitle = _customer.ContactTitle;
    Address = _customer.Address;
    City = _customer.City;
    Region = _customer.Region;
    PostalCode = _customer.PostalCode;
    Country = _customer.Country;
    Phone = _customer.Phone;
    Fax = _customer.Fax;
}

One other way would be to get the App property:

private readonly MainViewModel _mainvm = App.Current.MainVM;

or to use the Service Locator to get the instance:

private readonly MainViewModel _mainvm = (MainViewModel)App.Current.Services.GetService(typeof(MainViewModel));

No matter with way we are using it, we are coupling the two viewmodels and that will make testing difficult. Passing the VM as a parameter for the constructor will be the more testable way, the other two would get us in trouble: we should have an App created for the second option and have an App created and a Service Locator in place to get our instance. But wouldn’t it be better if we could get rid of the instance of Main ViewModel and do not have them coupled?

In fact there is: Messages. Messaging is a mechanism that decouples the sender and receiver of a message, there is not even need to have a receiver for the message, or there may be many receivers from it – the sender doesn’t care.

The messenger is based on the Mediator pattern, where the Messenger class acts as the mediator for the messages. The potential receivers subscribe for receiving some kind of messages and, when some sender sends a message, the messenger will send it to the subscribers that are willing to receive that kind of message.

In the MVVM Toolkit, you can define messengers that implement the IMessenger interface, but the class WeakReferenceMessenger exposes a Default property, which offers a thread safe implementation for this interface, which makes it easier to implement. This class has a Register method, which will register the client for some message type and will pass the callback function that will be called when a message of the desired type is received. If you wat toknow more about that, you can take a look at https://docs.microsoft.com/en-us/windows/communitytoolkit/mvvm/messenger.

For our needs, we have to define two types of different messages: a message for when a customer should be deleted and the other one, when the window is closed:

public class WindowClosedMessage: ValueChangedMessage<CustomerViewModel>
{
    public WindowClosedMessage(CustomerViewModel vm) : base(vm)
    {

    }
}

public class ViewModelDeletedMessage : ValueChangedMessage<CustomerViewModel>
{
    public ViewModelDeletedMessage(CustomerViewModel vm) : base(vm)
    {

    }
}

The messages inherit from ValueChangedMessage. This kind of messages are sent to the recipients and the execution goes on. If, on the other way, the Sender needs an answer from the receiver, it will send a message of the RequestMessage type. In this case, the sender will wait the response that the receiver will send using the Reply method of the message. In our case, both messages don’t need a reply, so we use the ValueChangedMessage type. We must create two different types of message, because we want different processing for each one.

Now, we must send the messages in CustomerViewModel, in the Closing and Delete commands:

[RelayCommand]
private void Delete()
{
    WeakReferenceMessenger.Default.Send(new ViewModelDeletedMessage(this));
}

[RelayCommand]
private void Closing()
{
    WeakReferenceMessenger.Default.Send(new WindowClosedMessage(this));
}

The processing for these messages is done in the MainViewModel class:

public MainViewModel(ICustomerRepository customerRepository, INavigationService navigationService)
{
    _customerRepository = customerRepository ?? 
                          throw new ArgumentNullException("customerRepository");
    Customers = new ObservableCollection<CustomerViewModel>(
        _customerRepository.Customers.Select(c => new CustomerViewModel(c)));
    _navigationService = navigationService;
    WeakReferenceMessenger.Default.Register<WindowClosedMessage>(this, (r, m) =>
    {
        WindowCount--;
    });
    WeakReferenceMessenger.Default.Register<ViewModelDeletedMessage>(this, (r, m) =>
    {
        DeleteCustomer(m.Value);
    });
}

private void DeleteCustomer(CustomerViewModel vm)
{
    Customers.Remove(vm);
    var deletedCustomer = _customerRepository.Customers.FirstOrDefault(c => c.CustomerId == vm.CustomerId);
    if (deletedCustomer != null)
    {
        _customerRepository.Remove(deletedCustomer);
    }
}

Now, when you run the app, you will see that when you close the detail window, the window count decreases. We could even keep a list of open windows, with some code like this:

[ObservableProperty]
private ObservableCollection<CustomerViewModel> _openWindows = new ObservableCollection<CustomerViewModel>();

public MainViewModel(ICustomerRepository customerRepository, INavigationService navigationService)
{
    _customerRepository = customerRepository ?? 
                          throw new ArgumentNullException("customerRepository");
    Customers = new ObservableCollection<CustomerViewModel>(
        _customerRepository.Customers.Select(c => new CustomerViewModel(c)));
    _navigationService = navigationService;
    WeakReferenceMessenger.Default.Register<WindowClosedMessage>(this, (r, m) =>
    {
        WindowCount--;
        _openWindows.Remove(m.Value);
    });
    WeakReferenceMessenger.Default.Register<ViewModelDeletedMessage>(this, (r, m) =>
    {
        DeleteCustomer(m.Value);
    });
}

....

[RelayCommand]
private void ShowDetails(CustomerViewModel vm)
{
    _navigationService.Navigate(vm);
    WindowCount++;
    _openWindows.Add(vm);
}
Decoupling-ViewModels-in-MVVM-with-the-MVVM-Toolkit-MessengerMessgenger3.png

When you click the Delete button in the detail window, the corresponding item is deleted in the main list, but the window remains open, and it should not ne there anymore, as the corresponding customer does not exist anymore. We could use some code behind to close the window, but I’d prefer another approach: change the navigation service to allow closing the windows. For that, we should keep the lists of open windows and have another method, Close, to close the selected window:

public interface INavigationService
{
    void Navigate(object arg);
    void Close(object arg);
}

public class NavigationService : INavigationService
{
    private readonly Dictionary<Type, Type> viewMapping = new()
    {
        [typeof(MainViewModel)] = typeof(MainWindow),
        [typeof(CustomerViewModel)] = typeof(Detail),
    };

    private readonly Dictionary<object, List<System.Windows.Window>> _openWindows = new();

    public void Navigate(object arg)
    {
        Type vmType = arg.GetType();
        if (viewMapping.ContainsKey(vmType))
        {
            var windowType = viewMapping[vmType];
            var window = (System.Windows.Window)Activator.CreateInstance(windowType);
            window.DataContext = arg;
            if (!_openWindows.ContainsKey(arg))
            {
                _openWindows.Add(arg, new List<System.Windows.Window>());
            }
            _openWindows[arg].Add(window);
            window.Show();
        }
    }

    public void Close(object arg)
    {
        if (_openWindows.ContainsKey(arg))
        {
            foreach(var window in _openWindows[arg])
            {
                window.Close();
            }
        }
    }
}

In this case, we keep a list of windows open for each customer. If we try to open another window for the same customer, the corresponding window will be added to the corresponding list. When the close method is called, all windows for that customer will be closed at once. The DeleteCustomer in MainViewModel becomes

private void DeleteCustomer(CustomerViewModel vm)
{
    Customers.Remove(vm);
    var deletedCustomer = _customerRepository.Customers.FirstOrDefault(c => c.CustomerId == vm.CustomerId);
    if (deletedCustomer != null)
    {
        _customerRepository.Remove(deletedCustomer);
    }
    _navigationService.Close(vm);
}

And now everything works fine.

But, what about testing? We did all this to ease testing, let’s test the VMs. In the Test project, let’s create a class CustomerViewModelTests.cs and add some tests:

[TestClass]
public class CustomerViewModelTests
{
    [TestMethod]
    public void Constructor_NullCustomer_ShouldThrow()
    {
        Action act = () => new CustomerViewModel(null);

        act.Should().Throw<ArgumentNullException>()
            .Where(e => e.Message.Contains("customer"));
    }
}

If we run this test, we’ll see it doesn’t pass. We haven’t made a check for null customers, so we have to change that in the code:

public CustomerViewModel(Customer customer)
{
    if (customer == null)
        throw new ArgumentNullException("customer");
    _customer = customer;
    CustomerId = _customer.CustomerId;
    CompanyName = _customer.CompanyName;
    ContactName = _customer.ContactName;
    ContactTitle = _customer.ContactTitle;
    Address = _customer.Address;
    City = _customer.City;
    Region = _customer.Region;
    PostalCode = _customer.PostalCode;
    Country = _customer.Country;
    Phone = _customer.Phone;
    Fax = _customer.Fax;
}

The test now passes and we can continue. For the next test, I’d like to have a customer filled with data, and FakeItEasy (which we are using for our test mocks) fills the data with nulls and that’s not what we want. In this article I show how to use Bogus to create fake data. We can use AutoBogus.FakeItEasy to integrate it with FakeItEasy. Install the package AutoBogus.FakeItEasy and let’s create the second test

[TestMethod]
public void Constructor_ShouldSetFields()
{
    var customer = AutoFaker.Generate<Customer>();
    var customerVM = new CustomerViewModel(customer);
    customerVM.CustomerId.Should().Be(customer.CustomerId);
    customerVM.CompanyName.Should().Be(customer.CompanyName);
    customerVM.ContactName.Should().Be(customer.ContactName);
    customerVM.ContactTitle.Should().Be(customer.ContactTitle);
    customerVM.Address.Should().Be(customer.Address);
    customerVM.City.Should().Be(customer.City);
    customerVM.Region.Should().Be(customer.Region);
    customerVM.PostalCode.Should().Be(customer.PostalCode);
    customerVM.Country.Should().Be(customer.Country);
    customerVM.Phone.Should().Be(customer.Phone);
    customerVM.Fax.Should().Be(customer.Fax);
}

For the third test, we will be testing the Delete command, and we want to check if if sends the correct message. For that, we must install the _Community.Toolkit.MVVM in the test project. Then, we can create the tests for the two commands:

[TestMethod]
public void DeleteCommand_ShouldSendMessageWithVM()
{
    var customer = AutoFaker.Generate<Customer>();
    var customerVM = new CustomerViewModel(customer);
    object callbackResponse = null;
    var waitEvent = new AutoResetEvent(false);
    WeakReferenceMessenger.Default.Register<ViewModelDeletedMessage>(this, (r, m) =>
    {
        callbackResponse = customerVM;
        waitEvent.Set();
    });
    customerVM.DeleteCommand.Execute(null);
    waitEvent.WaitOne(100);
    callbackResponse.Should().Be(customerVM);
}

[TestMethod]
public void CloseCommand_ShouldSendMessageWithVM()
{
    var customer = AutoFaker.Generate<Customer>();
    var customerVM = new CustomerViewModel(customer);
    object callbackResponse = null;
    var waitEvent = new AutoResetEvent(false);
    WeakReferenceMessenger.Default.Register<WindowClosedMessage>(this, (r, m) =>
    {
        callbackResponse = customerVM;
        waitEvent.Set();
    });
    customerVM.ClosingCommand.Execute(null);
    waitEvent.WaitOne(100);
    callbackResponse.Should().Be(customerVM);
}

These two tests have a particularity: the result is set in the callback, so we may not have it immediately, so we can have a flaky test if we don’t wait some time before testing the value – sometimes it may pass and sometimes not. For this issue, I’ve used an AutoResetEvent to be set when the callback is called and wait 100ms to see if it’s called. That makes the trick.

For the MainViewModel tests, we can do this:

[TestClass]
public class MainViewModelTests
{
    [TestMethod]
    public void Constructor_NullRepository_ShouldThrow()
    {
        Action act = () => new MainViewModel(null, null);

        act.Should().Throw<ArgumentNullException>()
            .Where(e => e.Message.Contains("customerRepository"));
    }

    [TestMethod]
    public void Constructor_Customers_ShouldHaveValue()
    {
        var repository = A.Fake<ICustomerRepository>();
        var navigationService = A.Fake<INavigationService>();
        var customers = A.CollectionOfFake<Customer>(10);
        A.CallTo(() => repository.Customers).Returns(customers);
        var vm = new MainViewModel(repository, navigationService);

        vm.Customers.Count.Should().Be(customers.Count);
    }

    [TestMethod]
    public void AddCommand_ShouldAddInRepository()
    {
        var repository = A.Fake<ICustomerRepository>();
        var navigationService = A.Fake<INavigationService>();
        var customers = A.CollectionOfFake<Customer>(10);
        A.CallTo(() => repository.Customers).Returns(customers);
        var vm = new MainViewModel(repository, navigationService);
        vm.AddCommand.Execute(null);
        A.CallTo(() => repository.Add(A<Customer>._)).MustHaveHappened();
    }

    [TestMethod]
    public void AddCommand_ShouldAddInCollection()
    {
        var repository = A.Fake<ICustomerRepository>();
        var navigationService = A.Fake<INavigationService>();
        var customers = A.CollectionOfFake<Customer>(10);
        A.CallTo(() => repository.Customers).Returns(customers);
        var vm = new MainViewModel(repository, navigationService);
        vm.AddCommand.Execute(null);
        vm.Customers.Count.Should().Be(11);
    }

    [TestMethod]
    public void AddCommand_ShouldCallNavigate()
    {
        var repository = A.Fake<ICustomerRepository>();
        var navigationService = A.Fake<INavigationService>();
        var customers = A.CollectionOfFake<Customer>(10);
        A.CallTo(() => repository.Customers).Returns(customers);
        var vm = new MainViewModel(repository, navigationService);
        vm.AddCommand.Execute(null);
        A.CallTo(() => navigationService.Navigate(A<CustomerViewModel>.Ignored)).MustHaveHappened();
    }

    [TestMethod]
    public void SaveCommand_ShouldCommitInRepository()
    {
        var repository = A.Fake<ICustomerRepository>();
        var navigationService = A.Fake<INavigationService>();
        var vm = new MainViewModel(repository, navigationService);
        vm.SaveCommand.Execute(null);
        A.CallTo(() => repository.Commit()).MustHaveHappened();
    }

    [TestMethod]
    public void SearchCommand_WithText_ShouldSetFilter()
    {
        var repository = A.Fake<ICustomerRepository>();
        var navigationService = A.Fake<INavigationService>();
        var customers = A.CollectionOfFake<Customer>(10);
        A.CallTo(() => repository.Customers).Returns(customers);
        var vm = new MainViewModel(repository, navigationService);
        vm.SearchCommand.Execute("text");
        var coll = CollectionViewSource.GetDefaultView(vm.Customers);
        coll.Filter.Should().NotBeNull();
    }

    [TestMethod]
    public void SearchCommand_WithoutText_ShouldSetFilter()
    {
        var repository = A.Fake<ICustomerRepository>();
        var navigationService = A.Fake<INavigationService>();
        var customers = A.CollectionOfFake<Customer>(10);
        A.CallTo(() => repository.Customers).Returns(customers);
        var vm = new MainViewModel(repository, navigationService);
        vm.SearchCommand.Execute("");
        var coll = CollectionViewSource.GetDefaultView(vm.Customers);
        coll.Filter.Should().BeNull();
    }

    [TestMethod]
    public void ShowDetailsCommand_ShouldCallNavigate()
    {
        var repository = A.Fake<ICustomerRepository>();
        var navigationService = A.Fake<INavigationService>();
        var customers = A.CollectionOfFake<Customer>(10);
        A.CallTo(() => repository.Customers).Returns(customers);
        var vm = new MainViewModel(repository, navigationService);
        CustomerViewModel customerVM = vm.Customers[1];
        vm.ShowDetailsCommand.Execute(customerVM);
        A.CallTo(() => navigationService.Navigate(customerVM)).MustHaveHappened();
    }

    [TestMethod]
    public void ShowDetailsCommand_ShouldIncrementWindowCount()
    {
        var repository = A.Fake<ICustomerRepository>();
        var navigationService = A.Fake<INavigationService>();
        var customers = A.CollectionOfFake<Customer>(10);
        A.CallTo(() => repository.Customers).Returns(customers);
        var vm = new MainViewModel(repository, navigationService);
        CustomerViewModel customerVM = vm.Customers[1];
        vm.ShowDetailsCommand.Execute(customerVM);
        vm.WindowCount.Should().Be(1);
    }

    [TestMethod]
    public void ShowDetailsCommand_ShouldAddToOpenWindows()
    {
        var repository = A.Fake<ICustomerRepository>();
        var navigationService = A.Fake<INavigationService>();
        var customers = A.CollectionOfFake<Customer>(10);
        A.CallTo(() => repository.Customers).Returns(customers);
        var vm = new MainViewModel(repository, navigationService);
        CustomerViewModel customerVM = vm.Customers[1];
        vm.ShowDetailsCommand.Execute(customerVM);
        vm.OpenWindows.Count.Should().Be(1);
        vm.OpenWindows[0].Should().Be(customerVM);
    }

    [TestMethod]
    public void CustomerCloseCommand_ShouldDecreaseWindowCount()
    {
        var repository = A.Fake<ICustomerRepository>();
        var navigationService = A.Fake<INavigationService>();
        var customers = A.CollectionOfFake<Customer>(10);
        A.CallTo(() => repository.Customers).Returns(customers);
        var vm = new MainViewModel(repository, navigationService);
        CustomerViewModel customerVM = vm.Customers[1];
        vm.ShowDetailsCommand.Execute(customerVM);
        customerVM.ClosingCommand.Execute(null);
        vm.WindowCount.Should().Be(0);
    }

    [TestMethod]
    public void CustomerCloseCommand_ShouldRemoveFromOpenWindows()
    {
        var repository = A.Fake<ICustomerRepository>();
        var navigationService = A.Fake<INavigationService>();
        var customers = A.CollectionOfFake<Customer>(10);
        A.CallTo(() => repository.Customers).Returns(customers);
        var vm = new MainViewModel(repository, navigationService);
        CustomerViewModel customerVM = vm.Customers[1];
        vm.ShowDetailsCommand.Execute(customerVM);
        customerVM.ClosingCommand.Execute(null);
        vm.OpenWindows.Count.Should().Be(0);
    }

    [TestMethod]
    public void CustomerDeleteCommand_ShouldCallNavigationClose()
    {
        var repository = A.Fake<ICustomerRepository>();
        var navigationService = A.Fake<INavigationService>();
        var customers = A.CollectionOfFake<Customer>(10);
        A.CallTo(() => repository.Customers).Returns(customers);
        var vm = new MainViewModel(repository, navigationService);
        CustomerViewModel customerVM = vm.Customers[1];
        vm.ShowDetailsCommand.Execute(customerVM);
        customerVM.DeleteCommand.Execute(null);
        A.CallTo(() => navigationService.Close(customerVM)).MustHaveHappened();
    }

    [TestMethod]
    public void CustomerDeleteCommand_ShouldRemoveCustomer()
    {
        var repository = A.Fake<ICustomerRepository>();
        var navigationService = A.Fake<INavigationService>();
        var customers = A.CollectionOfFake<Customer>(10);
        A.CallTo(() => repository.Customers).Returns(customers);
        var vm = new MainViewModel(repository, navigationService);
        CustomerViewModel customerVM = vm.Customers[1];
        vm.ShowDetailsCommand.Execute(customerVM);
        customerVM.DeleteCommand.Execute(null);
        vm.Customers.Count.Should().Be(9);
        vm.Customers.Should().NotContain(customerVM);
    }

    [TestMethod]
    public void CustomerDeleteCommand_ShouldCallRemoveFromRepository()
    {
        var repository = A.Fake<ICustomerRepository>();
        var navigationService = A.Fake<INavigationService>();
        var customers = A.CollectionOfFake<Customer>(10);
        A.CallTo(() => repository.Customers).Returns(customers);
        var vm = new MainViewModel(repository, navigationService);
        CustomerViewModel customerVM = vm.Customers[1];
        vm.ShowDetailsCommand.Execute(customerVM);
        customerVM.DeleteCommand.Execute(null);
        A.CallTo(() => repository.Remove(A<Customer>.Ignored)).MustHaveHappened();
    }
}

If you run the tests, all will pass.

As you can see, we have decoupled the two ViewModels with the Messenger implemented in the MVVM Toolkit, our code is completely testable and it runs fine. You can check this code here.

We can still go one step further (yes, we can always do that 😃): inject the IMessenger in the constructor of the ViewModels.

In App.xaml.cs we add the registration for IMessenger:

private static IServiceProvider ConfigureServices()
{
    var services = new ServiceCollection();

    services.AddSingleton<INavigationService, NavigationService>();
    services.AddSingleton<ICustomerRepository, CustomerRepository>();
    services.AddSingleton<IMessenger>(WeakReferenceMessenger.Default);
    services.AddSingleton<MainViewModel>();

    return services.BuildServiceProvider();
}

And inject it in the constructor of MainViewModel and CustomerViewModel:

public MainViewModel(ICustomerRepository customerRepository, 
    INavigationService navigationService,
    IMessenger messenger)
{
    _customerRepository = customerRepository ?? 
                          throw new ArgumentNullException("customerRepository");
    _navigationService = navigationService ?? 
        throw new ArgumentNullException("navigationService"); 
    _messenger = messenger ??
        throw new ArgumentNullException("messenger");
    Customers = new ObservableCollection<CustomerViewModel>(
        _customerRepository.Customers.Select(c => new CustomerViewModel(c, messenger)));
    messenger.Register<WindowClosedMessage>(this, (r, m) =>
    {
        WindowCount--;
        _openWindows.Remove(m.Value);
    });
    messenger.Register<ViewModelDeletedMessage>(this, (r, m) =>
    {
        DeleteCustomer(m.Value);
    });
}
private readonly IMessenger _messenger;

public CustomerViewModel(Customer customer, IMessenger messenger)
{
    if (customer == null)
        throw new ArgumentNullException("customer");
    if (messenger == null)
        throw new ArgumentNullException("messenger");
    _messenger = messenger;
    _customer = customer;
    CustomerId = _customer.CustomerId;
    CompanyName = _customer.CompanyName;
    ContactName = _customer.ContactName;
    ContactTitle = _customer.ContactTitle;
    Address = _customer.Address;
    City = _customer.City;
    Region = _customer.Region;
    PostalCode = _customer.PostalCode;
    Country = _customer.Country;
    Phone = _customer.Phone;
    Fax = _customer.Fax;
}

We have decoupled the ViewModels from the default messenger. There are some changes to do in the code and in the tests, but you can check them in the final project, here. The code now is decoupled, testable and works fine 😃

All the source code for this article is at https://github.com/bsonnino/MessengerToolkit


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK