Tuesday, October 3, 2017

Unit test MVVM DataGrid

I've been playing around with unit testing an MVVM page that includes a data grid. Before we worry about unit testing we need to think about how to design an MVVM page that supports a datagrid. In particular we need to consider how to communicate between the row's model and the grid's model.

We will create a simple DataGrid with three columns such that the value in the third column is the sum of the values in the first two columns.


To do this we will need an equation class that will handle the validation and calculation. It will implement INotifyPropertyChanged and have properties the grid can be bound to.

Start a new WPF project and call it MVVMGrid.


The page will contain a DataGrid and Add and Delete buttons. There is also a GrandTotal TextBlock that we will use later. The XAML looks like this.

<Window x:Class="MVVMGrid.MainWindow"
        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"
        xmlns:local="clr-namespace:MVVMGrid"
        mc:Ignorable="d"
        Title="MainWindow" Height="350" Width="525"
        DataContext="{Binding RelativeSource={RelativeSource Self}}">
    <Window.Resources>
        <RoutedCommand x:Key="AddCommand"/>
        <RoutedCommand x:Key="DeleteCommand"/>
    </Window.Resources>
    <Window.CommandBindings>
        <CommandBinding Command="{StaticResource AddCommand}" Executed="AddCommand_Executed"/>
        <CommandBinding Command="{StaticResource DeleteCommand}" CanExecute="DeleteCommand_CanExecute" Executed="DeleteCommand_Executed"/>
    </Window.CommandBindings>
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="*"/>
            <RowDefinition Height="auto"/>
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="auto"/>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="auto"/>
        </Grid.ColumnDefinitions>
        <DataGrid Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="3" AutoGenerateColumns="False" ItemsSource="{Binding Equations}" SelectedItem="{Binding SelectedEquation}" CanUserAddRows="false">
            <DataGrid.Columns>
                <DataGridTextColumn Header="Addend 1" Width="100" Binding="{Binding Addend1, UpdateSourceTrigger=PropertyChanged}"/>
                <DataGridTextColumn Header="Addend 2" Width="100" Binding="{Binding Addend2, UpdateSourceTrigger=PropertyChanged}"/>
                <DataGridTextColumn Header="Sum" Width="*" Binding="{Binding Sum}" IsReadOnly="True"/>
            </DataGrid.Columns>
        </DataGrid>
        <Button Grid.Row="1" Grid.Column="0" Content="Add" Command="{StaticResource AddCommand}"/>
        <TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding GrandTotal, StringFormat='Grand Total:{0}'}"/>
        <Button Grid.Row="1" Grid.Column="2" Content="Delete" Command="{StaticResource DeleteCommand}"/>
    </Grid>
</Window>

The code behind starts by declaring the cEquation class, a List to hold them, and the Add and Delete command event handlers.

using System;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Windows;

namespace MVVMGrid
{
    public class cEquation : INotifyPropertyChanged
    {
        private String _Addend1 = "";
        private String _Addend2 = "";
        private String _Sum = "";

        public String Addend1
        {
            get { return _Addend1; }
            set
            {
                if (_Addend1 != value)
                {
                    _Addend1 = value;
                    NotifyPropertyChanged("Addend1");
                    ComputeSum();
                }
            }
        }
        public String Addend2
        {
            get { return _Addend2; }
            set
            {
                if (_Addend2 != value)
                {
                    _Addend2 = value;
                    NotifyPropertyChanged("Addend2");
                    ComputeSum();
                }
            }
        }
        public String Sum
        {
            get { return _Sum; }
            set
            {
                if (_Sum != value)
                {
                    _Sum = value;
                    NotifyPropertyChanged("Sum");
                }
            }
        }

        private void ComputeSum()
        {
            try
            {
                Sum = (int.Parse(_Addend1) + int.Parse(_Addend2)).ToString();
            }
            catch (Exception ex)
            {
                Sum = ex.Message;
            }
        }


        public event PropertyChangedEventHandler PropertyChanged;
        private void NotifyPropertyChanged(String info)
        {
            if (PropertyChanged != null)
            {
                PropertyChanged(this, new PropertyChangedEventArgs(info));
            }
        }
    }

    public partial class MainWindow : Window, INotifyPropertyChanged
    {
        private ObservableCollection<cEquation> _Equations = new ObservableCollection<cEquation>();
        public ObservableCollection<cEquation> Equations
        {
            get { return _Equations; }
            set
            {
                _Equations = value;
                NotifyPropertyChanged("Equations");
            }
        }

        private cEquation _SelectedEquation = null;
        public cEquation SelectedEquation
        {
            get
            {
                return _SelectedEquation;
            }
            set
            {
                if (_SelectedEquation == null || !_SelectedEquation.Equals(value))
                {
                    _SelectedEquation = value;
                    NotifyPropertyChanged("SelectedEquation");
                }
            }
        }

        public MainWindow()
        {
            InitializeComponent();
        }

        public event PropertyChangedEventHandler PropertyChanged;
        public void NotifyPropertyChanged(String info)
        {
            if (PropertyChanged != null)
            {
                PropertyChanged(this, new PropertyChangedEventArgs(info));
            }
        }

        private void AddCommand_Executed(object sender, System.Windows.Input.ExecutedRoutedEventArgs e)
        {
            Equations.Add(new cEquation());
        }

        private void DeleteCommand_CanExecute(object sender, System.Windows.Input.CanExecuteRoutedEventArgs e)
        {
            e.CanExecute = (SelectedEquation != null);
        }

        private void DeleteCommand_Executed(object sender, System.Windows.Input.ExecutedRoutedEventArgs e)
        {
            if (SelectedEquation != null)
            {
                Equations.Remove(SelectedEquation);
            }
        }
    }
}

At this point we have quite a bit of functionality. We can click [Add], enter two integer addends, see the sum, and click [Delete]. We can also see that the [Delete] button is disabled when the list of equations is empty.

This architecture works fine when the list items don't need to interact with each other or anything else. Let's look at the GrandTotal TextBlock now.

We want the GrandTotal to be updated whenever a new sum is calculated. This means the cEquation class needs to have access to the MainWindow. There are several ways this can be done. I have chosen to write a MainWindow.UpdateGrandTotal function and tell the cEquation class to execute it whenever it has recalculated it's own Sum.

In MainWindow.cs, add a GrandTotal property and an UpdateGrandTotal method.

public int GrandTotal { get; set; }

public int UpdateGrandTotal()
{
    GrandTotal = 0;
    foreach (cEquation e in Equations)
        try { GrandTotal += int.Parse(e.Sum); } catch { }
    NotifyPropertyChanged("GrandTotal");
    return GrandTotal;
}

In Framework 3.5 we saw the FUNC<> feature added. This allows us the pass a function as a parameter to another function. We will write a constructor for cEquation that takes a function. Add this member and constructor to the cEquation class. It says that the "Changed" member is a function that takes no parameters and returns an integer.

private Func<int> Changed = null;

public cEquation(Func<int> Changed)
{
    this.Changed = Changed;
}

Now change the Sum property of cEquation to look like this.

public String Sum
{
    get { return _Sum; }
    set
    {
        if (_Sum != value)
        {
            _Sum = value;
            NotifyPropertyChanged("Sum");
            Changed();
        }
    }
}

Lastly change the Executed methods to look like this.

private void AddCommand_Executed(object sender, System.Windows.Input.ExecutedRoutedEventArgs e)
{
    Equations.Add(new cEquation(UpdateGrandTotal));
}

private void DeleteCommand_Executed(object sender, System.Windows.Input.ExecutedRoutedEventArgs e)
{
    if (SelectedEquation != null)
    {
        Equations.Remove(SelectedEquation);
        UpdateGrandTotal();
    }
}

The net effect of this is to cause the Equation object to call the MainWindow's UpdateGrandTotal method whenever the equation's sum is recalculated, thus displaying the total of the sums in the GrandTotal text block. There are many ways to achieve the same functionality, but this is one of the most flexible because the cEquation class makes few assumptions about the MainWindow class.

The last thing to do in this project is to ensure that SelectedEquation is set whenever we add a new equation. We do this because the DeleteCommand_CanExecute method depends on it. We can do this by adding a lambda function as the Equations CollectionChanged event handler. We do this in the MainWindow constructor so it looks like this.

public MainWindow()
{
    InitializeComponent();
    Equations.CollectionChanged +=
        (sender, e) =>
        {
            if (e.Action == NotifyCollectionChangedAction.Add)
                SelectedEquation = (cEquation)e.NewItems[0];
        };
}

Now we can add the UnitTest project.


Add a reference to the MVVMGrid project and others until the reference list looks like this.


We will now write two tests. One will check the calculations, the other will check the button functionality.

using Microsoft.VisualStudio.TestTools.UnitTesting;
using MVVMGrid;
using System.Windows.Input;

namespace MVVMGridTest
{
    [TestClass]
    public class UnitTest1
    {
        [TestMethod]
        public void SumOnePlusOne()
        {
            MainWindow mw = (MainWindow)new MainWindow();

            RoutedCommand ac = (RoutedCommand)mw.Resources["AddCommand"];
            ac.Execute(null, mw);
            Assert.AreEqual(1, mw.Equations.Count, "Add Command did not create a new equation");

            cEquation e1 = mw.SelectedEquation;
            e1.Addend1 = "1";
            e1.Addend2 = "1";
            Assert.AreEqual("2", e1.Sum, "Can't add one plus one");

            ac.Execute(null, mw);
            cEquation e2 = mw.SelectedEquation;
            e2.Addend1 = "2";
            e2.Addend2 = "2";
            Assert.AreEqual("4", e2.Sum, "Can't add two plus two");

            Assert.AreEqual(6, mw.GrandTotal, "Grand total is wrong");
        }

        [TestMethod]
        public void AddAndDeleteEquation()
        {
            MainWindow mw = (MainWindow)new MainWindow();

            RoutedCommand ac = (RoutedCommand)mw.Resources["AddCommand"];
            ac.Execute(null, mw);
            Assert.AreEqual(1, mw.Equations.Count, "Add Command did not create a new equation");

            RoutedCommand dc = (RoutedCommand)mw.Resources["DeleteCommand"];
            Assert.IsTrue(dc.CanExecute(null, mw), "Adding equation did not enable the delete command");

            dc.Execute(null, mw);
            Assert.AreEqual(0, mw.Equations.Count, "Delete command did not delete the selected equation");
        }

    }
}

As you can see, a test project can contain as many tests as you want. There is no guarantee that the tests will be run in any particular order so you will often find yourself repeating the same steps in several tests. This is OK.

We could add a new equation directly to the Equations collection, but that wouldn't be a valid test. Instead, we execute the command that adds a new equation.

No comments:

Post a Comment