WPF Unit Test MVVM

WPF e Unit Test del ViewModel nel pattern MVVM

In una precedente guida ho illustrato alcuni dei vantaggi di Windows Presentation Foundation rispetto a Windows Form nello sviluppo di applicazioni desktop. Uno di questi è la possibilità di separare l’interfaccia utente dalle logiche di business e di accesso ai dati, permettendo di creare test unitari per ciascuno strato dell’applicazione. In questa guida non mi addentrerò nei dettagli dello sviluppo basato sui test, ma illustrerò un esempio pratico di creazione di unit test per lo strato del ViewModel.

La struttura del progetto

L’applicazione di esempio, situata nella tipica solution creata con Visual Studio, è composta da tre progetti:

  • BusinessLogicLayer, di tipo class library, in cui è contenuta la semplice classe statica Utils che implementa un metodo pubblico statico
  • WpfUnitTestExample, di tipo WPF, contenente la View e il ViewModel dell’applicazione
  • UnitTests, di tipo Unit Test Project, in cui vengono implementate una classe di test unitari per la libreria BusinessLogicLayer e una classe parallela di test per il ViewModel, a dimostrazione che, seguendo il pattern mvvm, il View model è slegato dalla main form del progetto e quindi testabile come la libreria di business esterna

Il repository dell’applicazione è reperibile qui ed è supportato da Visual Studio Community Edition (versione superiore o uguale alla 2017).

Il problema di esempio

Si suppone di avere uno scaffale di n unità che può essere circolare o meno (se circolare, l’ultimo e il primo elemento sono adiacenti). Nello scaffale alcune posizioni possono essere occupate, per cui è necessario stabilire, a fronte di n unità da inserire, qual è la prima posizione libera per queste unità.

Questo problema viene risolto nella libreria BusinessLogicLayer, dal metodo FindFreePosition della classe Utils, come segue:

/// <summary>
        /// Method returning the free positions
        /// </summary>
        /// <param name="positions">Array of positions. False value if position is available, true instead</param>
        /// <param name="isCircular">If the array is circular the first position is adjacent to the last</param>
        /// <param name="neededPositions">Number of positions needed</param>
        /// <returns>Index of first position found (zero based) or -1 if no position is found</returns>
        public static int FindFreePosition(bool[] positions, int neededPositions, bool isCircular)
        {
            int length = positions.Length;

            if (isCircular)
                length *= 2;

            int freePlacesCount = 0;

            for (int i = 0; i < length; i++)
            {
                int newIndex = i % positions.Length;
                if (!positions[newIndex])
                {
                    freePlacesCount++;
                    if (freePlacesCount == neededPositions)
                        return (i + 1) - freePlacesCount;
                }
                else
                    freePlacesCount = 0;
            }

            return -1;
        }

Il metodo prende in input un vettore di posizioni di tipo bool rappresentante lo stato dello scaffale, in cui True indica che un’unità occupa la posizione corrispondente all’indice dell’elemento dell’array; il secondo argomento del metodo indica il numero di unità che devono occupare lo scaffale e il terzo argomento stabilisce se lo scaffale è circolare o meno.

Se la prima posizione libera viene trovata, ne viene restituito in uscita l’indice; in caso contrario viene restituito -1

L’algoritmo di ricerca implementato non è dei più efficienti, tuttavia serve bene allo scopo di creare test unitari per verificarne la correttezza dell’output.

Lo unit test della classe Utils

Per comodità espositiva non è stata seguita la tipica procedura TDD, che prevede di scrivere prima i test e poi i metodi dell’applicazione, per cui vediamo subito il funzionamento della classe UnitTestUtils per testare il metodo FindFreePosition:

public class UnitTestUtils
    {
        private bool[] _places = new bool[]
        {
            false,
            false,
            false,
            true,
            true,
            false,
            false,
            false,
            false,
            true,
            false,
            false
        };

        public UnitTestUtils()
        {
        }

        [TestMethod]
        public void NeededPlaces6_ShouldReturnNotFound()
        {
            var result = Utils.FindFreePosition(_places, 6, false);
            Assert.AreEqual(result, -1);
        }

        [TestMethod]
        public void NeededPlaces4_ShouldReturn_5()
        {
            var result = Utils.FindFreePosition(_places, 4, false);
            Assert.AreEqual(result, 5);
        }

        [TestMethod]
        public void NeededPlaces5_ShouldReturnNotFound()
        {
            var result = Utils.FindFreePosition(_places, 5, false);
            Assert.AreEqual(result, -1);
        }

        [TestMethod]
        public void IsRotaryNeededPlaces5_ShouldReturn_10()
        {
            var result = Utils.FindFreePosition(_places, 5, true);
            Assert.AreEqual(result, 10);
        }
    }

La classe di test unitari contiene il campo privato _places che rappresenta uno scaffale di 12 unità di cui la quarta, la quinta e la decima già occupate; a fronte di questo stato dello scaffale utilizziamo quattro metodi di verifica:

  • NeededPlaces6_ShouldReturnNotFound: scaffale non circolare in cui bisogna inserire 6 unità e la cui prima posizione libera deve risultare non trovata
  • NeededPlaces4_ShouldReturn_5: scaffale non circolare in cui bisogna inserire 4 unità e la cui prima posizione libera deve risultare in sesta posizione
  • NeededPlaces5_ShouldReturnNotFound: scaffale non circolare in cui bisogna inserire 5 unità e la cui prima posizione libera deve risultare non trovata
  • IsRotaryNeededPlaces5_ShouldReturn_10: scaffale circolare in cui bisogna inserire 5 unità e la cui prima posizione libera deve risultare undicesima

Lanciando la procedura nella finestra Test Explorer, nessun test fallisce.

La View dell’applicazione WPF

La main form del progetto wpf funge da rappresentazione astratta dello scaffale (come da foto di anteprima del presente articolo), con la quale è possibile verificare visivamente il funzionamento del metodo FindFreePlaces. Ogni posizione dello stesso è rappresentata da un quadrato, al click del quale viene automaticamente inserita una nuova unità ad occuparne la posizione, rappresentata da un rettangolo.

<Window x:Class="WpfUnitTestExample.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:WpfUnitTestExample"
        mc:Ignorable="d"
        
        Title="MainWindow" Height="280" Width="850" Loaded="Window_Loaded">
    <Window.Resources>
        <BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter"/>
    </Window.Resources>
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="50"/>
        </Grid.RowDefinitions>
        <Grid>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="auto"/>
                <ColumnDefinition Width="50"/>
                <ColumnDefinition Width="auto"/>
                <ColumnDefinition Width="50"/>
                <ColumnDefinition Width="auto"/>
                <ColumnDefinition/>
            </Grid.ColumnDefinitions>
            <Label FontSize="14"  HorizontalAlignment="Center">Places count:</Label>
            <TextBox Grid.Column="1" Text="{Binding PositionsCount,Mode=TwoWay,UpdateSourceTrigger=PropertyChanged}"></TextBox>
            <Label FontSize="14" Grid.Column="2" HorizontalAlignment="Center">Needed places</Label>
            <TextBox Grid.Column="3" Text="{Binding PositionsNeeded,Mode=TwoWay,UpdateSourceTrigger=PropertyChanged}"></TextBox>
            <CheckBox Grid.Column="4" IsChecked="{Binding IsCircular}" Content="Is Rotary" VerticalAlignment="Center" />
            <Label  Grid.Column="5" FontWeight="Bold" Foreground="Green" Content="{Binding ResultMessage}"/>
        </Grid>
        <ListView Name="lstPlaces" Grid.Row="1" ItemsSource="{Binding ListItemSource}" SelectionMode="Multiple" SelectedItem="{Binding SelectedPlace,Mode=TwoWay,UpdateSourceTrigger=PropertyChanged}" MinHeight="160" SelectionChanged="lstPlaces_SelectionChanged">
            <ListView.ItemsPanel>
                <ItemsPanelTemplate>
                    <StackPanel Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Bottom"></StackPanel>
                </ItemsPanelTemplate>
            </ListView.ItemsPanel>
            <ListView.ItemTemplate>
                
                <DataTemplate>
                    <Grid>
                        <Grid.RowDefinitions>
                            <RowDefinition Height="90"/>
                            <RowDefinition Height="auto"/>
                        </Grid.RowDefinitions>
                        
                        <Rectangle Visibility="{Binding RelativeSource={RelativeSource 
                            Mode=FindAncestor, AncestorType={x:Type ListViewItem}}, 
                            Path=IsSelected, Converter={StaticResource BooleanToVisibilityConverter}}" 
                            Fill="LightBlue" Stroke="Blue" Height="90" Width="30" StrokeThickness="5">
                            
                        </Rectangle>                       

                        <Rectangle Grid.Row="1" MaxHeight="50" Fill="LightCyan" Stroke="Orange" Height="50" Width="50" StrokeThickness="5">                            
                        </Rectangle>
                     
                    </Grid>
                </DataTemplate>
            </ListView.ItemTemplate>

        </ListView>


        <Rectangle Grid.Row="2" Fill="LightGreen" Stroke="Green" StrokeThickness="5" ></Rectangle>
    </Grid>
</Window>

Nelle caselle Places Count, Needed Places e IsRotary, è possibile modificare rispettivamente le dimensioni dello scaffale, il numero di unità da inserire e se è circolare o meno. Ogni proprietà è legata al ViewModel a cui viene notificato il cambiamento di stato, facendo scattare il ricalcolo della prima posizione libera:

 /// <summary>
        /// Check if free position is found
        /// </summary>
        public int CheckResult()
        {
            var result = Utils.FindFreePosition(Positions, PositionsNeeded, IsCircular);

            ResultMessage = $"isRotary = {IsCircular} , needed Places = {PositionsNeeded} , returns {result}";

            return result;
        }

Il metodo CheckResult del ViewModel, richiamato all’evento OnProperyChanged delle sue proprietà pubbliche, invoca FindFreePosition della libreria esterna e ne assegna il risultato alla proprietà ResultMessage che provocherà la visualizzazione dell’output ottenuto sulla finestra dell’applicazione.

Il test del ViewModel

Dato che CheckResult restituisce l’indice della posizione trovata nello scaffale, è possibile scrivere dei test unitari come per la libreria di business esterna.

[TestClass]
    public class ViewModelUnitTest
    {
        private ViewModel _viewModel;


        public ViewModelUnitTest()
        {
            _viewModel = new ViewModel();

            _viewModel.Positions = new bool[]
            {
                false,
                false,
                false,
                true,
                true,
                false,
                false,
                false,
                false,
                true,
                false,
                false
            };
        }

        [TestMethod]
        public void NeededPlaces6_ShouldReturnNotFound()
        {            
            _viewModel.IsCircular = false;
            _viewModel.PositionsNeeded = 6;
            Assert.AreEqual(_viewModel.CheckResult(), -1);
        }

        [TestMethod]
        public void NeededPlaces4_ShouldReturn_5()
        {
            _viewModel.IsCircular = false;
            _viewModel.PositionsNeeded = 4;
            Assert.AreEqual(_viewModel.CheckResult(), 5);
        }

        [TestMethod]
        public void NeededPlaces5_ShouldReturnNotFound()
        {
            _viewModel.IsCircular = false;
            _viewModel.PositionsNeeded = 5;
            Assert.AreEqual(_viewModel.CheckResult(), -1);
        }

        [TestMethod]
        public void IsRotaryNeededPlaces5_ShouldReturn_10()
        {
            _viewModel.IsCircular = true;
            _viewModel.PositionsNeeded = 5;
            Assert.AreEqual(_viewModel.CheckResult(), 10);
        }
    }

I test sono praticamente speculari a quelli precedenti, con la differenza che i parametri rappresentanti il tipo di scaffale, le sue dimensioni e le posizioni da occupare, vengono passati tramite le assegnazioni delle proprietà pubbliche del ViewModel.

Cliccando su Run all tests nella finestra Test Explorer di Visual Studio otteniamo il seguente risultato:

Risultati esecuzione unit test su viewmodel wpf

Conclusione

La possibilità di separare il codice di presentazione dalla logica di business nelle applicazioni WPF permette di eseguire test unitari sul ViewModel di una finestra. Nell’applicazione di esempio presentata in questo articolo, si è proceduto a creare dei test speculari per una libreria esterna e per il ViewModel che ne richiamava il metodo pubblico usato nel progetto wpf. I due test hanno dato gli stessi risultati. A questo punto è possibile spostare il ViewModel nella libreria di business esterna all’applicazione wpf, obbedendo alla logica del loose coupling e ottenendo i vantaggi della riutilizzabilità e testabilità del codice.