Clone wiki

TDD Training / Home

Pragmatyczne TDD

Kontekst

Sytuacja geopolityczna na świecie jest gorąca. Jednym z mechanizmów terroru obywateli jest losowe niszczenie obiektów w okolicach miast, w których mieszkańcy nie chcieli podporządkować się Wielkiemu Wujowi Woogle. Jako działacze programistycznego podziemia macie za zadanie rozwinąć popularny wśród obywateli system przechowywania zdjęć o funkcje obliczania spodziewanej daty zniszczenia obiektów na zdjęciach. Informację tą należy wyświetlić w panelu metadanych zdjęcia.

Przewidywaną datę zniszczenia obiektu można obliczyć na podstawie informacji o lokalizacji obiektu zawartych w metadanych zdjęcia (geotagi), oraz informacji o bazach i ich uzbrojeniu dostępnych w Global Annihilation System Server (http://gass.herokuapp.com).

Kilka wskazówek:

  • System GAS zawiera dane rakiet dla każdej bazy. Wśród tych danych jest promień zasięgu rakiety, promień obszaru rażenia zdetonowanej rakiety oraz ilość rakiet danego typu wystrzeliwana na miesiąc.
  • Przy wyznaczaniu daty należy wziąć pod uwagę tylko te rakiety, które mogą dotrzeć do celu.
  • Prawdopodobieństwo zniszczenia celu przez jedną rakietę można wyznaczyć dzieląc pole obszaru rażenia przez całkowite pole zasięgu rakiety. Pole obszaru rażenia rakiety oraz pole zasięgu rakiety można obliczyć ze wzoru na pole powierzchni koła:

P_{razenia} = \pi R_{razenia}^{2}

P_{zasiegu} = \pi R_{zasiegu}^{2}

p_{trafienia} = \frac{P_{razenia}}{P_{zasiegu}}

p_{pudla} = 1-p_{trafienia}

  • Obliczenia można uprościć przyjmując miesiąc jako jednostkę czasu oraz posiłkując się prawdopodobieństwem przetrwania obiektu. Dla danego miesiąca można obliczyć prawdopodobieństwo przetrwania obiektu p_{pm} ze wzoru:

p_{pm} = p_{pudla(1)}^{n1}p_{pudla(2)}^{n2}p_{pudla(3)}^{n3}...

gdzie:

p_{pudla(1)},p_{pudla(2)},p_{pudla(3)} - prawdopodobieństwo spudłowania rakietą danego typu,

n1,n2,n3 - ilość rakiet danego typu wystrzelona w miesiącu.

  • Prawdopodobieństwo przetrwania obiektu po N miesiącach można obliczyć mnożąc prawdopodobienistwa przetrwania z poszczególnych miesięcy. Jeżeli miesięczne prawdopodobieństwo przetrwania jest stałe można użyć wzoru:

p_{N} = p_{pm}^{N}

  • Przypomnienie z matematyki (dla 0 < p_{pm} < 1 ):

p_{N}\geq p_{pm}^N \equiv N\geq log_{p_{pm}}p_{N}

  • Przewidywana data zniszczenia to dzień po upływie miesiąca, w którym prawdopodobieństwo przetrwania p_{N} jest mniejsze lub równe 50% (jest to współczynnik wyznaczony magicznymi metodami przez Podziemny Komitet Statystyków), zatem liczba miesięcy, które obiekt przetrwa to:

N = ceiling(log_{p_{pm}}0,5)

  • Wyjątek: z okazji urodzin Wielkiego Wuja Woogle, które wypadają w sierpniu, Global Annihilation System zawiesza działanie, dlatego prawdopodobieństwo zniszczenia obiektu w tym miesiącu wynosi 0. Zatem jeśli okres od teraz do przewidywanej daty przechodzi przez sierpień, należy ten miesiąc pominąć.

Ćwiczenie 1 - Adapter do serwisu GAS

Testy eksploracyjne

W pierwszej kolejności chcemy poznać działanie serwisu GAS, z którego mamy skorzystać tworząc docelowe rozwiązanie. W tym celu wykorzystamy ideę testów eksploracyjnych. Testy umieścimy w klasie GASExploratoryTests. Testy te będą wywoływały różne metody z serwisu GAS i dokumentowały format otrzymywanych danych. Pracę rozpoczniemy od stworzenia prostego testu i pomocniczych metod wykorzystywanych w większości testów.

// checkpoint
git checkout ex1_1

W kolejnym kroku należy samodzielnie zaimplementować pozostałe testy.

Kilka wskazówek:

  • Deserializator z JSONa generuje liczby najmniejszego możliwego typu. Aby ułatwić sobie testowanie można stworzyć metodę pomocniczą:
bool IsANumber(object val) {
    return val is int || val is decimal || val is double || val is float;
}
  • Można testować konkretne dane zwracane przez serwis, ale lepszym rozwiązaniem (bardziej odpornym na zmiany) jest testowanie struktury zwróconych danych, niezależnej od ich wartości.

  • NUnit dostarcza kilka ciekawych klas do asercji, m.in.: Assert, StringAssert, CollectionAssert. Dla przykładu żeby sprawdzić czy kolekcja zawiera dane elementy można posłużyć się kodem:

CollectionAssert.IsSubsetOf(new[] { "dog", "cat", "elephant" }, animals);

Adapter do serwisu GAS

Gdy już wiemy, jakie funkcje oferuje nam serwis GAS i wiemy, jakich danych potrzebujemy, możemy przystąpić do implementacji klasy adaptera (GASGateway), która dostarczy nam danych w wygodnym dla nas formacie i przykryje szczegóły komunikacji z zewnętrznym serwisem. Aby uniezależnić się od zewnętrznego serwisu na czas developmentu oprócz adaptera do rzeczywistego serwisu stworzymy obiekt udający jego działanie (GASGatewayStub). Oba te obiekty będą implementować wspólny interfejs IGASGateway. Oba te obiekty powinny również spełniać zestaw przygotowanych przez nas testów. Klasa GASGateway jest przykładem zastosowania wzorca projektowego Gateway, a klasa GASGatewayStub to wzorzec Service Stub.

Diagram klas modułu GASGateway

Interfejs będzie miał kształt:

public interface IGASGateway
{
    SiteDistance QuerySites(double latitude, double longitude);
    IList<Asset> GetSiteAssets(string siteId);
}

public struct SiteDistance
{
    public string Id { get; set; }
    public double Distance { get; set; }
}

public struct Asset
{
    public double Range { get; set; }
    public double Power { get; set; }
    public double MonthlyQuantity { get; set; }
}
// checkpoint
git checkout ex1_3

W kolejnym kroku należy samodzielnie zaimplementować pozostałą metodę i testy.

Ćwiczenie 2 - Implementacja logiki komponentu Annihilation Date Calculator

Celem ćwiczenia jest stworzenie komponentu obliczającego spodziewaną datę destrukcji danego miejsca na podstawie jego współrzędnych geograficznych. Komponent należy rozwinąć w separacji od istniejącego systemu i weryfikować jego działanie przy pomocy testów automatycznych. Interfejsem komponentu powinna być klasa AnnihilationDateCalculator z metodą o następującej sygnaturze:

public string GetAnnihilationDate(double? latitude, double? longitude)

Komponent powinien zwracać:

  • Tekst zawierający datę zniszczenia obiektu obliczoną wg. wskazówek w treści zadania głównego.
  • Tekst „Unknown” w przypadku, kiedy nie są znane współrzędne obiektu lub data spodziewanego zniszczenia jest odległa o 12 lub więcej miesięcy.

Little up front design design

Diagram klas

Diagram sekwencji

Ćwiczenie polega na pisaniu testów i implementacji logiki odpowiadającej za ich poprawne wykonanie.

Kalkulator prawdopodobieństwa

Rozpoczynamy od implementacji kalkulatora prawdopodobieństwa (AnnihilationProbabilityCalculator):

  • (Demo) CalculateMonthlySurvivalProbability_WhenServiceReturnsSingleRocket_ThenProperlyCalculateProbability
// checkpoint
git checkout ex2_1
  • CalculateMonthlySurvivalProbability_WhenServiceReturnsMultipleRocketsOfTheSameType_ThenProperlyCalculateProbability
  • CalculateMonthlySurvivalProbability_WhenServiceReturnsMultipleRocketsOfDifferentTypes_ThenProperlyCalculateProbability
  • CalculateMonthlySurvivalProbability_WhenRocketPowerIsZero_ThenSurvivalProbabilityIsOne
  • CalculateMonthlySurvivalProbability_WhenServiceReturnsInvalidAssetData_ThenThrowsException
  • CalculateMonthlySurvivalProbability_WhenServiceReturnsDistanceGreaterThenAllRocketsRange_ThenSurvivalProbabilityIsOne

Kalkulator daty zniszczenia

Dalej implementujemy kalkulator daty zniszczenia. Również rozpoczynamy od stworzenia testu:

  • (Demo) GetAnnihilationDate_WhenMonthlySurvivalProbabilityIsBelow05_ThenReturnsNextMonth
// checkpoint
git checkout ex2_3

Ćwiczenie należy rozpocząć od wprowadzenia interface’u IDateProvider w miejsce bezpośredniego odwołania do systemowej klasy DateTime. Następnie kontynuować tworząc poniższe testy i ich implementacje.

  • GetAnnihilationDate_WhenMonthlySurvivalProbabilityIs09AndIsSeptember_ThenReturnsSevenMonthsFromNow
  • GetAnnihilationDate_WhenMonthlySurvivalProbabilityIs09AndIsJuly_ThenOmitsAugustReturnsEightMonthsFromNow
  • GetAnnihilationDate_WhenMonthlySurvivalProbabilityIs095_ThenReturnsUnknown
  • GetAnnihilationDate_WhenCoordinatesAreUnknown_ThenReturnsUnknown
  • Testy na warunki brzegowe – jak się zachowa?

Test integracyjny komponentu

// checkpoint
git checkout ex2_4

Kolejny krok to weryfikacja czy cały komponent działa zgodnie z założeniami. W tym celu wspólnie stworzymy klasę AnnihilationDateCalculatorIntegrationTests i napiszemy kolejno dwa testy:

  • AnnihilationDateCalculator_WithRealGAS_ShouldReturnProperDate
  • AnnihilationDateCalculator_WithFakeGAS_ShouldReturnProperDate

Ćwiczenie 3 – Integracja nowego komponentu z aplikacją

W poprzednim ćwiczeniu stworzyliśmy działający komponent do obliczania daty anihilacji, który teraz należy zintegrować z resztą aplikacji. Aby to zrobić musimy wykonać kilka operacji. Najpierw należy dodać do typu wyliczeniowego MetadataItemName nową metadaną EstimatedAnnihilationDate, zaktualizować metodę MetadataItemNameEnumHelper.IsValidFormattedMetadataItemName, uruchomić aplikację i w panelu administracyjnym metadanych skonfigurować nowo dodaną pozycję. Metadane w aplikacji ekstrahowane są przez klasę ImageMetadataReadWriter. Ponieważ nie chcemy modyfikować bezpośrednio tej klasy to zastosujemy wzorzec dekoratora i stworzymy klasę AnnihilationMetadataReadWriterDecorator, która będzie obliczała odpowiednią metadaną przy użyciu stworzonego wcześniej kalkulatora, a wszystkie pozostałe żądania będzie przekazywała do klasy dekorowanej.

Diagram sekwencji

Implementację rozpoczynamy oczywiście od testów w klasie AnnihilationMetadataReadWriterDecoratorTests.

  • (Demo) GetMetaValue_ForUnsupportedMetadataItem_CallsDecoratedClass
// checkpoint
git checkout ex3_1

W kolejnym kroku należy samodzielnie stworzyć testy i implementację do pozostałych metod dekoratora.

  • GetMetaValue_ForEstimatedAnnihilationDateMetadataItem_ReturnsExpectedDate
  • UnsupportedMethodsAndProperties_CallDecoratedClass

Gdy mamy już działający dekorator to należy go wstrzyknąć w metodzie Factory.GetMetadataReadWriter. Po tej operacji powinniśmy mieć skończoną aplikację działającą w połączeniu z zewnętrznym serwisem.

// checkpoint
git checkout ex3_3

Zdjęcia do testów systemu można pobrać z sample_pictures.zip.

Ćwiczenie 4 – Regresja

W tym ćwiczeniu zbadamy czy nasze podejście do tworzenia oprogramowania poradzi sobie z niespodziewaną zmianą w zewnętrznym serwisie wykorzystywanym przez naszą aplikację. Serwis GAS został zaktualizowany do wersji 2.0. Możemy się o tym boleśnie przekonać próbując dodać nowe zdjęcie do działającej aplikacji. Próba taka kończy się komunikatem błędu. Aby zbadać, co jest nie tak możemy odpalić zestaw wcześniej przygotowanych testów. Testy korzystające wyłącznie z elementów naszego systemu (np. z mocków zewnętrznych serwisów) nadal przechodzą bezbłędnie. Jednak próba uruchomienia testów korzystających z prawdziwego serwisu kończy się błędami na wielu poziomach (testy integracyjne, testy adaptera, testy eksploracyjne). Przyjrzenie się nieprzechodzącemu testowi eksploracyjnemu ukaże, że metoda query serwisu GAS zwraca w nowej wersji tablicę baz, a nie pojedynczą bazę jak to było w wersji pierwszej. Pracę rozpoczniemy od naprawy testu eksploracyjnego i udokumentowaniu w nim nowego działania metody query.

// checkpoint
git checkout ex4_1

W dalszej części należy zastanowić się, jakie logiczne konsekwencje wnosi do naszego systemu wprowadzona zmiana. Następnie należy dostosować aplikację do zmiany poprawiając komponenty od dołu, zaczynając na każdym poziomie od testów (proponowana kolejność - GASGatewayTests, IGASGateway, GASGateway, GASGatewayStub, AnnihilationProbabilityCalculatorTests, AnnihilationProbabilityCalculator).

Ćwiczenie 5 – Zmiana wymagań i wewnętrzny DSL

Po kilku miesiącach działania systemu, Wielki Wuj Woogle stwierdził, że społeczeństwo za bardzo demoralizuje się w sierpniu, w związku z czym postanowił zmienić wcześniejsze założenia i zamiast całkowitego braku aktywności systemu GASS używać tylko rakiet o niskiej mocy (czyli mocy niższej lub równej 40 jednostkom).

To dodatkowo wymaganie zwiększa złożoność implementacji i testów. Aby ukryć tą złożoność i zwiększyć czytelność testów wprowadzimy do nich wewnętrzny DSL, który dostarczy nam tzw. Fluent Interface, dzięki któremu będzemy mogli zapisać testy w bardziej czytelnej postaci, np.

Given.AnnihilationProbabilityCalculator
    .ReturningStandardProbability(0.6)
    .And.ReturningAugustProbability(0.8)
    .And.CurrentDateIs("2010-07-01")
.When.AnnihilationDateIsCalculated
.Then.CalculatedDateIs("2010-09-01");
// checkpoint
git checkout ex5_1

Kolejnym krokiem jest zaimplementowanie własnego DSLa w klasie AnnihilationProbabilityCalculatorTests, refaktoryzacja istniejących testów tak by korzystały z tego DSLa oraz implementacja nowych wymagań, począwszy od nowych testów.

Updated