Jak wykorzystać TDD w codziennym życiu  Udostępnij na: Facebook

Autor: Arkadiusz Benedykt

Opublikowano: 2011-07-20

Znamy już podstawy test driven development, pora wykorzystać tę wiedzę praktycznie. Do stworzenia prostego kodu będziemy potrzebowali Visual Studio 2010 oraz dostępne w nim środowisko testów jednostkowych – MS Tests.

 

Tworzymy nowy projekt testowy. Poniżej znajduje się kod automatyczne generowany przez narzędzie:

 

using System;
using System.Text;
using System.Collections.Generic;
using System.Linq;
using Microsoft.VisualStudio.TestTools.UnitTesting;
 
namespace TestProject1
{
    [TestClass]
    public class UnitTest1
    {
        [TestMethod]
        public void TestMethod1()
        {
        }
    }
}

Co tutaj mamy

Publiczna klasa o dowolnej nazwie, opatrzona atrybutem [TestClass]. Atrybut ten mówi nam, że w tej klasie znajdują się testy jednostkowe. Klasa ta ma jedną (na chwilę obecną) metodę publiczną, Metoda ta opatrzona atrybutem [TestMethod] jest tak naprawdę naszym testem. Warto zwrócić uwagę, że przed wykonaniem testu mechanizm testów jednostkowych MS Test uruchamia metodę z atrybutem TestInitialize (jeśli istnieje), a po wykonaniu testu uruchamia metodę opatrzoną atrybutem TestCleanup (również jeśli istnieje).

[TestClass]
    public class UnitTest1
    {
        [TestInitialize]
        public void Startup()
        {
            
        }
 
        [TestMethod]
        public void TestMethod()
        {
        }
 
        [TestCleanup]
        public void Teardown()
        {
            
        }
    }

Zgodnie z zasadami, stwórzmy pierwszy test jednostkowy. Dla przykładu stworzymy klasę opisującą pracownika zawierającą: imię, nazwisko, datę urodzenia, datę zatrudnienia oraz zawierającą metody ToString zwracającą imię i nazwisko oraz metodę Wiek zwracającą liczbę lat.

Na początek zajmijmy się imieniem i nazwiskiem oraz metodą ToString. Test mógłby wyglądać tak:

[TestMethod]
 public void MetodaToStringZwracaImieOrazNazwisko()
 {
      var osoba = new Osoba()
                       {
                           Imie = "Jan",
                           Nazwisko = "Kowalski"
                       };
     Assert.AreEqual("Jan Kowalski", osoba.ToString());
 }

Ponieważ nazwa metody może być dowolna, warto nadawać nazwy, które mówią czego oczekujemy od testu, aby zakończył się pozytywnie. W przyszłości może to stanowić nieocenioną dokumentację oraz swoistą formę listy wymagań.

Elementy czerwone to elementy, które nie istnieją na chwilę obecną, ale tym zajmiemy się później.  Nasz test składa się z przygotowania obiektu oraz asercji – funkcji porównującej wartość oczekiwaną z wartością rzeczywistą. W tym momencie należy się zastanowić (i podjąć decyzję), czy wolimy, aby ustawiać parametry obiektu przez metody, właściwości, czy przez konstruktor.

Mając przygotowany test, możemy przystąpić do wygenerowania klasy Osoba – w tej czynności rewelacyjnie wspiera nas Visual Studio za pomocą skrótu Alt+Ctrl+F10 (i powtarzamy tę czynność dla „Imię” oraz „Nazwisko”)

 

dzięki czemu otrzymamy:

class Osoba
    {
        public string Imie { get; set; }
 
        public string Nazwisko { get; set; }
    }

W tym momencie możemy skompilować projekt, jednak test nie zostanie poprawnie wykonany. Wykonując operację All Tests In Solution (Ctrl + R, A), otrzymamy:

 

Test nie wykonuje się prawidłowo. Metoda ToString zwraca TestProject1. Osoba zamiast Jan Kowalski. Zatem nadpisujemy metodę ToString:

public override string ToString()
{
    return string.Format("{0} {1}", Imie, Nazwisko);
}

Test wykonuje się pozytywnie:

 

W tym momencie mamy czas na refactoring. Możemy pozbyć się niepotrzebnych usingów.

Krok drugi, obliczanie wieku.

Dodając kolejną funkcjonalność, rozpoczynamy niejako od początku. Piszemy test:

[TestMethod]
 public void MetodaWiekZwracaIloscLatOsoby(){
    var osoba = new Osoba{
                Imie = "Jan",
                    Nazwisko = "Kowalski",
                 DataUrodzenia = new DateTime(1991, 05, 20)
    };
        Assert.AreEqual(20,osoba.Wiek);
}

A następnie implementacje. Nasza klasa Osoba wygląda aktualnie tak:

class Osoba
    {
        
        public string Imie { get; set; }
 
        public string Nazwisko { get; set; }
 
        public DateTime DataUrodzenia;
 
        public override string ToString()
        {
            return string.Format("{0} {1}", Imie, Nazwisko);
        }
        
        public int Wiek
        {
            get
            {
                return DateTime.Now.Year - DataUrodzenia.Year;
            }   
        }
    }

W tym momencie proponuję chwilę przerwy na zastanowienie się co jest nie tak z powyższym kodem oraz testem.

Testy jednostkowe muszą uruchamiać się na każdej maszynie, w każdym czasie. Dzięki temu zawsze dają prawidłowy feedback dla zmian wprowadzanych w kodzie. Powyższy test ma zasadniczą wadę. O ile w roku 2011 test będzie działał, to rok później już nie. Jest też drugi problem z kodem. Mianowicie złamana została pierwsza zasada SOLID, czyli Single Responsibility Principle (SRP). Klasa powinna mieć jeden powód do zmiany. Nasza klasa Osoba przechowuje dane osoby i to jest jej podstawowa (i jedyna) funkcja. Wyciągnięcie logiki obliczania wieku do osobnej klasy z jednej  strony ułatwi nam testowanie, a z drugiej pozwoli na zachowanie zasady pojedynczej odpowiedzialności.

Napiszmy zatem taki test:

[TestClass]
    public class KalkulatorWiekuTesty
    {
        private static int RokTestowy = 1981;
        private static int MiesiacTestowy = 05;
        private static int DzienTestowy = 29;
 
        private int OczekiwanaIloscLat = DateTime.Now.Year - RokTestowy;
        private IWiek _osoba;
 
        [TestInitialize]
        public void Setup()
        {
            _osoba = new Osoba()
            {
                Imie = "Jan",
                Nazwisko = "Kowalski",
                DataUrodzenia = new DateTime(RokTestowy, MiesiacTestowy, DzienTestowy)
            };
        }
 
        [TestMethod]
        public void MetodaObliczZwracaIloscLat()
        {
            var kalkulatorWieku = new KalkulatorWieku(_osoba);
            Assert.AreEqual(OczekiwanaIloscLat, kalkulatorWieku.Oblicz());
        }
    }

Do obliczeń będziemy posługiwać się interfejsem IWiek. Dzięki temu będziemy mogli obliczać wiek dowolnego obiektu, który go implementuje. Takie podejście pozwala łatwiej testować budowany system, pozwala też na wymienianie poszczególnych elementów wraz ze zmieniającymi się potrzebami i upraszcza utrzymanie systemu w późniejszym czasie.

Podejście takie ma jeszcze jedną zaletę. Możemy w późniejszym czasie tworzyć klasy do obliczania wieku w postaci dni, miesięcy czy pełni księżyca (lub cokolwiek innego, co wyobraźnia klienta przyniesie) bez modyfikacji klasy Osoba i bez dotykania kodu już istniejącego, sprawdzonego i przetestowanego – to zminimalizuje nam możliwość tworzenia błędów w istniejącym już kodzie.

Wracając zatem do pracy, potrzebujemy zdefiniować interfejs IWiek:

public interface IWiek
    {
        DateTime DataUrodzenia { get; }
    }

Klasa Osoba od tej chwili będzie go implementować:

class Osoba : IWiek
    {
        
        public string Imie { get; set; }
 
        public string Nazwisko { get; set; }
 
        public DateTime DataUrodzenia { get; set; }
 
        public override string ToString()
        {
            return string.Format("{0} {1}", Imie, Nazwisko);
        }
        
        public int Wiek
        {
            get
            {
                return DateTime.Now.Year - DataUrodzenia.Year;
            }   
        }
 
        
    }

Pozostaje stworzyć klasę KalkulatorWieku:

public class KalkulatorWieku
    {
        private readonly IWiek _osoba;
 
        public KalkulatorWieku(IWiek osoba)
        {
            _osoba = osoba;
        }
 
        public int Oblicz()
        {
            return DateTime.Now.Year - _osoba.DataUrodzenia.Year;
        }
    }

Ponownie ostatnim krokiem jest uporządkowanie projektu. W tym celu dodamy nowy projekt do solution, w którym będziemy przechowywać logikę. Dla większej poprawności w klasie Osoba property publiczne zrobimy częściowo prywatne i ustawiane przez konstruktor. Testy jednostkowe pozostawimy w projekcie testowym. To pozwoli w przyszłości na dostarczenie klientowi samej aplikacji bez testów jednostkowych.

Powyższe podejście pozwala nam, oprócz zachowania zasad SOLID, na łatwe testowanie aplikacji. Co więcej, dzięki zastosowaniu interfejsów będzie można wykorzystać biblioteki do mockowania (udawania) obiektów. Jedną z tego typu bibliotek dostępnych dla deweloperów jest Pex and Moles udostępniona przez Microsoft® Reaserch do pobrania tutaj (https://research.microsoft.com/en-us/projects/pex/).

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

Kolejną bardzo cenną zaletą TDD oraz pisania testów jednostkowych jest możliwość uruchamiania kodu bez potrzeby uruchamiania aplikacji. Programista nie musi marnować czasu na uruchomienie aplikacji, a następnie na jej przeklinanie w celu sprawdzenia nowo dodanej funkcjonalności. Dzięki solidnemu tworzeniu testów jednostkowych możemy uruchamiać wybrane elementy aplikacji i je testować kosztem kilku sekund (potrzebnych na uruchomienie testów), a nie minut (potrzebnych na uruchomienie aplikacji). To podejście jest nie do przecenienia  w przypadku tworzenia oprogramowania, które nie posiada interfejsu użytkownika, takich jak choćby usługi sieciowe czy systemowe.