Wykorzystanie TDD wraz ze wzorcem MVVM  Udostępnij na: Facebook

Autor: Arkadiusz Benedykt

Opublikowano: 2011-12-09

Znasz już podstawy TDD, oraz wiesz, jak zastosować tę metodykę w pracy. W związku z tym zobaczysz teraz, jaka moc drzemie w połączeniu TDD oraz wzorca MVVM. Ponieważ WPF oraz Silverlight (a Silverlight to również Windows Phone 7) świetnie wspiera ten wzorzec, to właśnie z niego będziesz korzystać.

Kod źródłowy zamieszczonych w tym artykule przykładów znajduje się tutaj

MVVM – Model View ViewModel

Rys. 1. Model View ViewModel.

Twoim zadaniem będzie skonstruowanie Modelu, ViewModel oraz View (Rys.1.). Model zazwyczaj musi skądś pobierać dane. Najczęściej jest to baza danych. Używanie bazy danych, przy uruchamianiu testów jednostkowych, nie jest wskazane. Otóż testy jednostkowe powinny być niezależne od środowiska, na którym je uruchamiasz, i zawsze powinny dawać jednoznaczne wyniki. Sztywne powiązanie z bazą danych powoduje, że aby uruchomić takie testy, musisz najpierw uruchomić silnik bazy danych, odtworzyć bazę danych w odpowiedniej dla testów jednostkowych wersji i najlepiej ze spreparowanymi wcześniej danymi testowymi. Po uruchomieniu testów, które mogą zmienić stan bazy danych, przywróć bazę do poprzedniego stanu. Jak widać nie jest to proste. Dodatkowo, jak już wcześniej pisałem, testy powinny uruchamiać się możliwie szybko. Dlatego w podejściu TDD, zamiast bazy danych, stosuje się obiekty typu fake. Są to obiekty, które udają działanie innych obiektów. W tym wypadku utwórz obiekt fake, który udaje źródło danych. W kolejnym kroku utwórz ViewModel, czyli model, łączący z jednej strony dane pochodzące z bazy (lub ogólniej źródła danych), a z drugiej, przygotowujący te dane na potrzeby interfejsu użytkownika. Implementacja tego wzorca w WPF pozwala na luźne bindowanie danych ze UI.

Co to daje?

Z jednej strony łatwość podmieniana UI bez dotykania logiki biznesowej, z drugiej jest to wzorzec idealnie nadający się dla TDD. Wzorzec ten pozwala tak zbudować aplikację, że możesz ją uruchamiać i sterować nią za pomocą testów jednostkowych. Na tym skup się w tym artykule (szczegóły samego wzorca MVVM to już materiał na inny artykuł).

Punktem wyjściowym dla niniejszego artykułu będzie to, co zbudowałeś w poprzedniej części.

Rys. 2. Rozwijany projekt.

Powyższy Rys.2. pokazuje solution, który będziesz rozwijać. Zacznij od źródła danych, dla którego napiszesz bardzo prosty test:

using Microsoft.VisualStudio.TestTools.UnitTesting;
 
namespace TDD_Demo
{
    
    [TestClass]
    public class ZrodloDanychTesty
    {
         
        [TestMethod]
        public void PobierzOsobyZwracaKolekcjeKtoraMozeBycPusta()
        {
            IZrodloDanych zrodloDanych = MetodyTestowe.FakeZrodloDanych();
            var osoby = zrodloDanych.PobierzOsoby();
 
            Assert.IsNotNull(osoby);
        }
    }
}

Zmienna zrodloDanych (zgodnie z nazwą) będzie Twoim źródłem danych. W testach jednostkowych nie chcesz jednak pracować z bazami danych, co innego w żywej aplikacji. Musisz jakoś podmieniać obiekty, z jakimi będziesz pracować. Aby tego dokonać, a jednocześnie nie napracować się, użyj interfejsów. Tutaj IZrodloDanych to interfejs do obiektu, który jest źródłem danych. W testach będziesz posługiwać się obiektem typu fake, udającym bazę danych. W rzeczywistej aplikacji obiekt ten będzie podmieniony na prawdziwą bazę. Czyż nie jest to proste?

Test napisany. Uzupełnisz kod, który pozwoli na skompilowanie całości. Potrzebujesz do tego pomocniczej klasy statycznej MetodyTestowe w projekcie TDD_Demo.Testy:

using System;
using TDD_Demo.Core;



namespace TDD_Demo{


    public static class MetodyTestowe{

        public static IZrodloDanych FakeZrodloDanych()
        {
            throw new NotImplementedException();
        }

    }
}

oraz interfejsu IZrodloDanych w projekcie TDD_Demo.Core:

using System.Collections.Generic;

namespace TDD_Demo.Core
{ 
   public interface IZrodloDanych{

        IEnumerable<Osoba> PobierzOsoby();

    }

}

Tutaj warto zwrócić uwagę na to, aby interfejsy były możliwie elastyczne i pozwalały na wymianę poszczególnych części kodu. Powyższy interfejs można poprawić poprzez zamianę typu Osoba na IOsoba. To pociąga za sobą potrzebę zmian w kodzie:

Dotychczasowe public class Osoba : IWiek zamień na:

public class Osoba : IOsoba

i utwórz interfejs: 

public interface IOsoba : IWiek {

        string Imie { get; }
        string Nazwisko { get; }
 }

Teraz możesz wprowadzić potrzebne zmiany w IZrodloDanych:
public interface IZrodloDanych
{
        IEnumerable<IOsoba> PobierzOsoby();
}

Po takich dużych zmianach, możesz sprawdzić, czy wcześniej napisany kod nie został uszkodzony oraz czy nie powstały ewentualne błędy. Uruchom testy jednostkowe. Wówczas otrzymasz:

Rys. 3. Wynik uruchomienia testów jednostkowych.

Wcześniejsze testy jednostkowe działają poprawnie, jedynie ostatnio napisany test nie przeszedł (Rys.3.). Jaki z tego wniosek? Stara funkcjonalność działa poprawnie. W związku z tym możesz działać dalej.

Klasę MetodyTestowe uzupełnij o implementację metody FakeZrodloDanych

using TDD_Demo.Core;

namespace TDD_Demo
{
    public static class MetodyTestowe
    {
        public static IZrodloDanych FakeZrodloDanych()
        {
            return new FakeZrodloDanych();
        }

    }
}

oraz utwórz klasę FakeZrodloDanych:

using System;
using System.Collections.Generic;
using TDD_Demo.Core;

namespace TDD_Demo
{
    public class FakeZrodloDanych : IZrodloDanych
    {
        public IEnumerable<IOsoba> PobierzOsoby()
        {
            return new List<IOsoba>
                       {new Osoba("Jan", "Kowalski", new DateTime(1980, 12, 01)),                       new Osoba("Maria", "Kowalczyk", new DateTime(1970, 01, 13)),
                      new Osoba("Robert", "Kowalewski", new DateTime(1968, 8, 06))
                       };
        }
    }
}

Uruchom testy i zobacz, że teraz wszystkie testy kończą się „na zielono”. Może się wydawać, że wykonałeś bardzo dużo pracy, jak na napisanie kawałka tak prostego kodu. Przykład ten obrazuje, w sposób wyolbrzymiony, codzienność tworzenia kodu. Wprowadzając nową funkcjonalność, wielokrotnie musisz zmienić tę istniejącą. Wcześniej napisane testy jednostkowe pozwalają Ci, w każdym momencie, sprawdzić, czy wcześniejsza funkcjonalność nie została uszkodzona nieświadomie – mimo wprowadzenia zmian w kodzie. Takie bezpieczeństwo pozwala na zwiększenie komfortu pracy i daje możliwość skupienia się nad tworzonym kodem, zamiast martwienia się ewentualnymi błędami, które mogły powstać zupełnie nieświadomie. Zwróć uwagę na to, że dodatkowa praca i czas, poświęcone na tworzenie testów jednostkowych we wcześniejszej części, pozwoliły na wprowadzenie zmian w obiekcie Osoba i upewnienie się, że zmiany, które wprowadziłeś nie tworzą nowych błędów. Jest to jeden z najcenniejszych (chociaż nie jedyny) benefitów pisania testów jednostkowych. Wszędzie tam, gdzie kod zmienia się z czasem, testy jednostkowe dają informację zwrotną, która informuje o powstawaniu przypadkowych błędów.

Dla celów edukacyjnych, utwórz teraz demonstracyjne źródło danych – z jednej strony aplikacja będzie miała dane, na których może pracować, z drugiej strony będziesz mógł zobaczyć, jak zrealizować podmianę źródła danych, co zostało wspomniane wcześniej.

Twoim celem teraz jest stworzenie obiektu, który będzie swoistym Proxy dla Twojej aplikacji. Chcesz, aby aplikacja korzystała z tego obiektu jako z jedynego źródła danych. To pozwoli Ci na podmianę kodu, odpowiedzialnego za komunikację z bazą danych, plikami czy usługami, bez konieczności zmiany całej aplikacji.

Zacznij zatem od testu. Tym razem spróbuj zrobić to w sposób, który bardzo ułatwia pisanie skomplikowanych testów – zdarza się, że napisanie testu nie jest sprawą trywialną. Wówczas warto zacząć od napisania samej asercji, aby później, krok po kroku, skonstruować działający test. Takie budowanie od tyłu, znacząco ułatwia budowanie skomplikowanych testów.

Zbuduj asercję:

Assert.IsInstanceOfType(zrodloDanych,typeof(FakeZrodloDanych));

która w kodzie będzie wyglądać tak:

using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace TDD_Demo
{
    [TestClass]
    public class ProxyTesty
    {
        [TestMethod]
        public void ProxyZwracaObiektZadanegoTypu()
        {
            Assert.IsInstanceOfType(zrodloDanych,typeof(FakeZrodloDanych));
        }

    }

}

Sprawdź, czy Twoja klasa Proxy zwróci obiekt typu FakeZrodloDanych. Proxy musi mieć możliwość podmiany obiektów – źródeł danych, czyli implementujących interfejs IZrodloDanych – więc zbuduj kolejne części testu:

using Microsoft.VisualStudio.TestTools.UnitTesting;
using TDD_Demo.Core;
namespace TDD_Demo
{

    [TestClass]
    public class ProxyTesty
    {
        private IZrodloDanych _zrodloDanych;

        [TestInitialize]
        public void Init(){

            ....
       }

        [TestMethod]
        public void ProxyZwracaObiektZadanegoTypu()
        {
            Assert.IsInstanceOfType(_zrodloDanych,typeof(FakeZrodloDanych));
        }

    }

}

w metodzie Init odpowiednio zainicjuj zmienną _zrodloDanych. Może to wyglądać tak:

var proxy = new Proxy(MetodyTestowe.FakeZrodloDanych());
 _zrodloDanych = proxy.Zrodlo();

Następnie wygeneruj klasę Proxy oraz metodę Zrodlo. Visual Studio daje narzędzia do generowania kodu z takich konstrukcji, zatem pozostałą część pracy w dużej mierze można zautomatyzować.

namespace TDD_Demo.Core
{
    public class Proxy
    {
       private readonly IZrodloDanych _zrodloDanych;
        public Proxy(IZrodloDanych zrodloDanych){
            _zrodloDanych = zrodloDanych;
        }

       public IZrodloDanych Zrodlo(){
            return _zrodloDanych;
        }

    }

}

Po dodaniu obiektu z danymi demonstracyjnymi, które będą się wyświetlać w aplikacji (zamiast pobierania ich z bazy, dla uproszczenia będziesz je pobierać z oddzielnego obiektu),będziesz miał wszystko, co będzie potrzebne, aby przygotować aplikację WPF do wyświetlania danych.

using System;
using System.Collections.Generic;

namespace TDD_Demo.Core
{
    public class DaneDemo : IZrodloDanych
    {
        public IEnumerable<IOsoba> PobierzOsoby()
        {
            return new List<IOsoba>
                       {new Osoba("Maria", "Skłodowska-Curie", new DateTime(1867, 11, 7)),
                      new Osoba("Alfred", "Tarski", new DateTime(1901, 01, 14)),
                      new Osoba("Stefan", "Banach", new DateTime(1945, 8, 31))
                       };

        }
   }

}

Do przykładowego projektu, został dodany projekt TDD_WPF, a w nim klasa ViewModel, która będzie wykorzystywana w tej konkretnej implementacji (ta część nie będzie omawiana, gdyż stanowi jedynie przykład implementacji wzorca MVVM, korzystając z Prism-a, MVVM Toolkit, MVVM Foundation, czy innych, ta część będzie różnie wyglądać). Klasa NaukowcyViewModel pobiera dane za pomocą wcześniej napisanego Proxy i udostępnia je dla widoków (Views) w formie ObservableCollection. Jeżeli będziesz potrzebował zmienić miejsce przechowywania danych, wystarczy, że wprowadzisz odpowiednie zmiany w klasie Proxy. Reszta aplikacji pozostanie nie ruszona.

using System.Collections.ObjectModel;
using TDD_Demo.Core;


namespace TDD_WPF.ViewModels
{
    public class NaukowcyViewModel
    {
        private readonly Proxy _dane = new Proxy();

        public NaukowcyViewModel()
        {
            Naukowcy = new ObservableCollection<IOsoba>(_dane.Zrodlo().PobierzOsoby());
        }

        public ObservableCollection<IOsoba> Naukowcy
        {
            get; private set;
        }

    }

}

Ostatnią częścią składanki są widoki. Główne okno aplikacji (MainWindow):

<Window xmlns:my="clr-namespace:TDD_WPF.Views"  x:Class="TDD_WPF.MainWindow"
        xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml" xmlns:ViewModels="clr-namespace:TDD_WPF.ViewModels" Title="MainWindow" Height="350" Width="525">
    <Window.DataContext>
        <ViewModels:NaukowcyViewModel/>
    </Window.DataContext>
    <Grid>
        <StackPanel Orientation="Horizontal">
            <my:ProstaLista />
            <my:LadnaLista />
        </StackPanel>
    </Grid>
</Window>

Zawiera DataContext, który następnie jest przekazywany do kontrolek leżących wewnątrz StackPanel. Kontrolki ProstaLista oraz LadnaLista, w różny sposób prezentują te same dane – pochodzące z ObservableCollection Naukowcy (klasy NaukowcyViewModel). Co więcej, patrząc na implementację ProstejListy:

<Grid d:DataContext="{d:DesignInstance {x:Type ViewModels:NaukowcyViewModel}}">        <DataGrid ItemsSource="{Binding Naukowcy}" AutoGenerateColumns="False">
            <DataGrid.Columns>
                <DataGridTextColumn Binding="{Binding Path=Imie, Mode=OneWay}" Header="Imie" />
                <DataGridTextColumn Binding="{Binding Path=Nazwisko, Mode=OneWay}" Header="Nazwisko" />
            </DataGrid.Columns>
        </DataGrid>
    </Grid>

widzisz, że kod ten poza nazwami pól Imie oraz Nazwisko, nie ma zupełnie nic wspólnego z logiką obiektu Osoba czy interfejsu IOsoba. MVVM pozwala w bardzo prosty sposób odseparować logikę aplikacji od logiki interfejsu użytkownika. Za pomocą testów jednostkowych jesteś w stanie przetestować każdy aspekt silnika aplikacji bez przywiązywania uwagi do UI. Ten ostatni, dzięki MVVM oraz WPF, może być tworzony przez oddzielny zespół grafików oraz specjalistów UI za pomocą innych, bardziej specjalizowanych narzędzi..

Podsumowanie

Warto pisać aplikacje w taki sposób, żeby można było je w całości obsługiwać za pomocą testów jednostkowych. Jak należy to rozumieć? Budując aplikację w taki sposób, że cała jej funkcjonalność dostępna jest bez interfejsu użytkownika, możesz każdy jej aspekt opisać za pomocą testów jednostkowych. To pozwala na wstępne testowanie zgodności aplikacji z wymaganiami (wstępne, bo cały czas należy pamiętać, że testy jednostkowe to narzędzie, wspierające programistów, a nie testerów i jako takie nie może zastąpić testów aplikacji).

Takie podejście pozwala również na wsparcie zarządzania błędami, zgłaszanymi przez użytkowników. Każdy znaleziony błąd powinien zostać opisany za pomocą testu jednostkowego, który wywołuje błędne zachowanie (błąd powoduje pozytywne przejście testu). Po poprawieniu aplikacji prawidłowo przygotowany test nie będzie już przechodził pozytywnie. W tym momencie należy test zanegować – czyli najczęściej zmienić rodzaj asercji z IsTrue na IsFalse etc. Takie podejście do błędów chroni przed nawracaniem tych samych błędów (z powodu błędów, mergowania, czy użycia nieaktualnego kodu).

Aplikacja, którą w całości możesz uruchamiać z poziomu testów jednostkowych, pozwala się wstępnie przetestować, bez mozolnego uruchamiania i „przeklikiwania” jej kolejnych okienek. Pozwala to oszczędzać czas i pieniądze, a parafrazując Roberta C. Martina z tegorocznej konferencji Norwegian Developers Conference – ręczne testowanie jest nie tylko głupie, ale jest przede wszystkim niemoralne.

W następnej części poznasz sposoby testowania kodu, który tylko z pozoru wydaje się nie do testowania.