Archive for Luty, 2009

Testowanie konfiguracji routingu w ASP.NET MVC

Jedną z największych zalet architektury ASP.NET MVC jest prostota pisania testów jednostkowych, co umożliwia łatwą pracę tym wszystkim, którzy stosują TDD. Zdarzają się jednak przypadki, w których wymagana jest od programisty znajomość „zakamarków” frameworka w celu napisania, wydawałoby się prostego, testu. Jednym z takich przypadków jest testowanie routingu zapytań do odpowiedniej akcji kontrolera.

Głównym problemem jest sprzężenie komponentów routingu z klasą HttpContext. Klasa HttpContext moim zdaniem jest zmorą w przypadku wszystkich testów jednostkowych funkcjonalności wykorzystujących klasy z przestrzeni System.Web. W przeszłości, pisząc aplikacje oparte o ASP.NET Web Forms zmuszony byłem do tworzenia klasy adoptującej HttpContext, którą w prosty sposób potrafiłem zastąpić na potrzeby testów obiektami Test Double. W ASP.NET MVC problem ten został wyeliminowany poprzez wprowadzenie klasy HttpContextBase. Mimo to nadal pozostaje problem z tym, że klasa ta posiada zbyt wiele metod i właściwości zwracających często nam potrzebne obiekty. Najczęściej chodzi tutaj o obiekty klas HttpResponse i HttpRequest. Klasy te, podobnie jak klasa HttpContext, posiadają swoje odpowiedniki pozwalające na łatwiejsze testowanie – HttpResponseBase i HttpRequestBase.

To wszystko prowadzi do tego, że testowanie routingu w ASP.NET MVC wymaga z naszej strony dużego nakładu pracy by przygotować odpowiednio obiekty potrzebne do wykonania się testu. Na dodatek to nie koniec problemów. Wykorzystując routing w ASP.NET MVC, jako programiści nie jesteśmy zmuszeni do pisania skomplikowanej logiki, gdyż jedyne co musimy zrobić, to dodać jedną linię kodu domyślnie w klasie Application (global.asax) i już nasze zapytanie HTTP zostanie przekierowane do odpowiedniej akcji. W testach już tak łatwo nie jest, dlatego, że nie jesteśmy w stanie w prosty sposób sprawdzić, która z metod, będąca naszą akcją, zostanie wywołana. Aby dojść do sposobu pozwalającego na sprawdzenie wyniku, wymagane od nas jest zapoznanie się z operacjami wchodzącymi w skład routingu zapytania, które zostały przeze mnie opisane w jednym z poprzednich postów, oraz z paroma innymi klasami takimi jak ControllerActionInvoker czy MvcHandler.

W efekcie końcowym stworzymy test podobny do tego jak ten, poniżej, który weryfikują poprawność domyślnej konfigurację routingu.

//routes.MapRoute("Default",  "{controller}/{action}/{id}", new { controller = "Home", action = "Index", id = "" }

[Test]
public void should_route_to_the_home_page()
{
var httpContext = MockRepository.GenerateStub();
var httpRequest = MockRepository.GenerateStub();
httpContext.Stub(x => x.Request).Return(httpRequest);
string testedRequestUrl = "~/Home";
httpRequest.Stub(x => x.AppRelativeCurrentExecutionFilePath).Return(testedRequestUrl);
var routeData = RouteTable.Routes.GetRouteData(httpContext);
var controllerName = routeData.GetRequiredString("controller");
var actionName = routeData.GetRequiredString("action");
Assert.IsTrue(string.Equals(controllerName, "Home",StringComparison.OrdinalIgnoreCase));
Assert.IsTrue(string.Equals(actionName, "Index", StringComparison.OrdinalIgnoreCase));
}

W sumie okazuje się, że test nie jest znów tak trudny jak to się zapowiadało, ale… Zawsze musi być jakieś, ale:). Powyższy test może stwarzać problemy w trakcie refaktoryzacji. Kryteria powyższego testu polegają na porównywaniu ciągu znaków określających nazwę akcji (metody) i nazwę klasy kontrolera, który posiada daną akcję. Problem leży w tym, że ciągi te nie zostaną automatycznie zastąpione przez narzędzia refaktoryzujące takie jak naprzykład Resharper w przypadku zmiany nazwy akcji lub kontrolera.

Istnieje jednak alternatywa i jest ona dostępna w ramach projektu MVC Contrib. Biblioteka MvcContrib.TestHelper wchodząca w skład tego projektu posiada klasę RouteTestingExtensions z metodami rozszerzającymi pozwalającymi w bardzo prosty sposób dokonać odrobinę magii opartej o lambda expressions, co w efekcie pozwoli nam pisać testy takie jak ten poniższy.

[Test]
public void should_route_to_the_home_page()
{
"~/home".Route().ShouldMapTo(h => h.Index());
}

Jak widać test ten jest banalnie prosty i nie wymaga z naszej strony prawie że żadnego nakładu pracy, a co najważniejsze każdy jest w stanie rozpocząć przygodę z testowaniem przez siebie zdefiniowanych route.

1 comment Luty 20, 2009

Model Domeny – sercem aplikacji w metodologii Domain Driven Design

W tym poście, zgodnie z wcześniejszą obietnicą, przedstawię podejście do tworzenia modelu domenowego wyrażonego w postaci kodu. Model ten jest sercem każdej aplikacji tworzonej w oparciu o Domain Driven Design, gdyż w jego odpowiedzialności leży realizowanie funkcjonalności, którymi cechuje się ta aplikacja. Martin Fowler w swojej książce opisuje model domenowy jako sieć połączonych ze sobą obiektów, przy czym każdy obiekt reprezentuje jednostkę istniejąca w realnym obszarze biznesowym. Niektóre z tych obiektów przedstawiają dane, inne natomiast reguły biznesowe. Najczęściej jednak występują obiekty, które posiadają obie te cechy.

W każdej z aplikacji, z którymi miałem dotychczas do czynienia, również występowały modele domenowe. W większości przypadków modele te jednak nie realizowały opisywanych przez Martina Fowlera cech, dlatego, że składały się one z klas przedstawiających tylko i wyłącznie dane obiektów domenowych wyrażonych w postaci właściwości(get; set;). Rozwiązanie to zostało okrzyknięte antywzorcem – Anemiczny Model Domenowy dlatego, że wszelkie reguły biznesowe związane z tymi obiektami są umieszczane w odrębnych klasach. Klasy te najczęściej reprezentują wzorzec Transaction Script, gdyż każda operacja biznesowa jest izolowana w jednej metodzie (transakcji). Rozwiązanie to może być skuteczne w przypadku prostych aplikacji, zaś w przypadku bardziej skomplikowanych doprowadzi ono do częstej duplikacji logiki biznesowej rozsianej pomiędzy wielu klasach.

Jednym z najczęstszych powodów powstawania anemicznych modeli domenowych jest sposób projektowania aplikacji w oparciu o wcześniej stworzony schemat bazy danych. To prowadzi to do tego, że model domenowy powstaje w skutek mapowania tabel do klas posiadających odzwierciedlone kolumny w postaci właściwości. Jedynym wyzwaniem dla niektórych osób może być modelowanie relacji, które w przypadku bazy danych są kluczami obcymi, zaś w przypadku obiektowego modelu powinny być relacjami do innych obiektów. Nie brakuje oczywiście przykładów, gdzie relacje w modelu domenowym tworzone są również w oparciu o klucze obce, co całkowicie zaprzecza obiektowemu programowaniu. Idealnym przykładem jest aplikacja .Net Petshop, która ma na celu przedstawienie najlepszych praktyk pisania aplikacji webowych w oparciu o technologię ASP.NET. Cóż za ironia :)

W poście przedstawiającym znaczenie języka “ubiquitous language” przedstawiłem przykład rozmowy pomiędzy programistami a ekspertem domeny aukcji internetowych. Wytworzony, w trakcie tej przykładowej rozmowy, wspólny język uczestników jak i diagramy klas posłużą do stworzenia przykładowego modelu domeny. Jako, że jestem wielkim zwolennikiem praktyki Test Driven Development, każdy przykład zostanie poprzedzony testem, pozwalającym na zweryfikowanie poprawności zaprojektowanego kodu i funkcjonalności.

W przykładowych wymaganiach zostały wyróżnione trzy kluczowe obiekty domenowe: Użytkownik(User), Aukcja(Auction) i Podbicie(Bid). Użytkownik, mimo, że jest kluczowym obiektem w domenie aukcji, w przedstawionych wymaganiach nie posiada jak na razie żadnych reguł biznesowych. Dlatego też skoncentruję się na początku na obiektach Podbicie i Aukcja.

Zgodnie z wcześniej opisanymi wymaganiami, podbicie dokonywane jest przez użytkownika, który musi podać odpowiednią kwotę. Dlatego pierwszy test zdefiniuje sposób tworzenia nowego podbicia.

        [Test]
        public void Can_create_a_bid()
        {
            var amount = new Money(20);
            var user = A.RichUser;
            var bid = new Bid(user, amount);
            Assert.That(bid.WasBiddedBy, Is.EqualTo(user));
            Assert.That(bid.Amount, Is.EqualTo(amount));
        }

Jak widać obiekt klasy Bid tworzony jest w prosty sposób przy użyciu konstruktora. Informacja o użytkowniku, który dokonał podbicia jest przekazywana w postaci parametru typu User. Do stworzenia obiektu User została wykorzystana klasa statyczna A (Matka Obiektów) służąca do tworzenia powtarzających się obiektów wykorzystywanych w wielu testach. Bardziej interesującym parametrem jest drugi parametr typu Money. Służy on do przedstawienia proponowanej ceny. Można by w tym przypadku wykorzystać jeden z wbudowanych typów języka C#, np. decimal. Należy się jednak zastanowić, czy typ wbudowany sprosta wymaganiom biznesowym i czy w ogóle będzie on zrozumiały dla biznesu. Tworząc specjalny typ Money zapewniamy, że wszelka logika biznesowa związana z operacjami pieniężnymi zostanie zawarta w tym typie. Jedną z jego odpowiedzialności w naszym przypadku, będzie zapewnienie, że wartość pieniężna podawana przez klienta będzie musiała być większa niż 0. Oczywiście należy przedyskutować wszelkie szczegóły na temat obiektu Money z ekspertem domenowym, dlatego że mogą ujawnić się dodatkowe wymagania, takie jak wspieranie wielu walut.

Następnym wymaganiem związanym z podbiciem, którym się zajmiemy, jest data i czas dodania podbicia. Pierwsze pytanie, jakie się nasuwają to: czy informacja ta może być równoznaczna z aktualną datą i czasem stworzenia obiektu typu Bid? Czy może musi to być informacja o tym, kiedy ten obiekt zostanie przypisany do obiektu reprezentującego aukcję(Auction)? Druga opcja nieco komplikuje sprawę, dlatego, że odpowiedzialnością obiektu Auction będzie ustawienie daty i czasu dodania obiektu Bid. Dlatego w celu uniknięcia nadmiernego sprzężenia tych dwóch obiektów, skorzystamy wstępnie z rozwiązania pierwszego, a następnie przedyskutujemy problem daty i czasu z klientem.

        [Test]
        public void a_newly_created_bid_should_have_the_current_date_and_time()
        {
            var theTimeBefore = DateTime.Now.AddMilliseconds(-1);
            var bidder = A.RichUser;
            var twentyZloties = new Money(20);
            var aBid = new Bid(bidder, twentyZloties);
            Assert.That(aBid.AddedTime, Is.GreaterThan(theTimeBefore));
            Assert.That(aBid.AddedTime, Is.LessThan(DateTime.Now.AddMilliseconds(1)));
        }

To już chyba wszystko związane z wymaganiami dotyczących obiektu Bid. Oczywiście brakuje jeszcze kodu, który pozwoli nam wykonać poprawnie testy.

    public class Bid
    {
        public Bid(User biddedBy, Money amount)
        {
            if (biddedBy == null) throw new ArgumentNullException("biddedBy");
            if (amount == null) throw new ArgumentNullException("amount");
            WasBiddedBy = biddedBy;
            Amount = amount;
            AddedTime = DateTime.Now;
        }   

        public Money Amount { get; private set; }

        public User WasBiddedBy { get; private set; }

        public DateTime AddedTime { get; private set; }
    }

Jak widać, kod jest bardzo prosty i realizuje wymagania wyrażone w postaci testów, ale co najważniejsze, zapewnia on stworzenie zawsze poprawnego obiektu typu Bid. Możliwe jest to poprzez wyeliminowanie publicznych setterów i sprawdzanie warunków wstępnych w ramach wywołania konstruktora.

Bardziej ciekawą klasą jest klasa Auction, która definiuje dotychczas większość reguł biznesowych. Jedną z nich jest możliwość dodawania nowych podbić do aukcji.

        [Test]
        public void should_accept_a_new_bid()
        {
            var testedAuction = An.OngoingAuctionWithoutBids;
            var anUser = A.RichUser;
            var twentyZloties = new Money(20);
            var newBid = new Bid(anUser, twentyZloties);
            testedAuction.Place(newBid);
            Assert.That(testedAuction.Bids.Contains(newBid));
        }

Na daną chwilę nie jest istotne jak zostanie zdefiniowane tworzenie obiektu Auction, dlatego obiekt aukcji tworzony jest przez klasę statyczną An(Matka Obiektów). Powyższy test definiuje sposób użycia metody Place odpowiedzialnej za dodania podbicia do aukcji oraz sposób dostępu do wszystkich dostępnych podbić. Aby przejść dany test, napisana zostanie prosta implementacja, która rozwijana będzie z dodaniem każdego następnego wymagania

    public class Auction
    {
        private IList bids;

        public ICollection Bids
        {
            get { return new ReadOnlyCollection(bids); }
        }

        public void Place(Bid newBid)
        {
            if (newBid == null) throw new ArgumentNullException("newBid");
            bids.Add(newBid);
        }
    }

Metoda Place ogranicza się do dodawania obiektu typu Bid do prywatnej listy przechowującej wszystkie podbicia. Nie jest wymagane by została sprawdzana poprawność obiektu Bid, gdyż leży to w odpowiedzialności tego obiektu. Ciekawostką może być właściwość Bids, która nie udostępnia bezpośrednio prywatnej listy. Rozwiązanie powyżej przedstawione, ogranicza możliwość dodania nowego podbicia wyłącznie przez metodę Place.

Pierwsze koty za płoty, teraz przejdźmy do następnego wymagania. Poniższy test zapewnia odrzucanie podbić, których wartość nie przekracza ostatnio przyjętego podbicia.

        [Test]
        [ExpectedException(typeof(PlaceBidOperationException))]
        public void should_not_accept_a_new_bid_because_the_amount_is_lower_then_the_of_the_previous_bid()
        {
            var testedAuction = An.OngoingAuctionWithTheHighestBidEqualTo20Zloties;
            var anUser = A.RichUser;
            var tenZloties = new Money(10);
            var newBid = new Bid(anUser, tenZloties);
            testedAuction.Place(newBid);
        }

Test jest dosyć prosty, dlatego przejdziemy bezpośrednio do implementacji, która zapewni poprawne wykonanie testu.

…
        public void Place(Bid newBid)
        {
            if (newBid == null) throw new ArgumentNullException("newBid");
            Bid lastBid = LastBid;
            if (WasBidded)
            {
                if (lastBid.Amount.IsGraterThen(newBid.Amount))
                {
                    throw new PlaceBidOperationException(
                    "The amount of this bid is lower then of the previously placed bid");
                }
            }
            bids.Add(newBid);
        }

        private bool WasBidded
        {
            get { return bids.Count > 0; }
        }

        private Bid LastBid
        {
            get
            {
                return bids.OrderByDescending(b => b.AddedTime)
                    .Take(1).SingleOrDefault();
            }
        }
…

Tu już się robi ciekawiej. Stworzone zostały dwie właściwości prywatne: WasBidded, która pozwala na sprawdzenie, czy aukcja posiada jakiekolwiek podbicia i LastBid, która kryje logikę dostępu do najwyższego podbicia. Sama logika sprawdzenia, czy dane podbicie jest większe niż ostatnio dodane jest bardzo prosta, gdyż opiera się ono na porównywaniu dwóch wartości pieniężnych.

Hmm… Szczerze, to nie podoba mi się sposób dostępu do ostatniego podbicia. Może by tak zastosować do przechowywania wszystkich podbić zamiast zwykłej listy, sortowaną listę SortedList. Na razie może niech zostanie tak jak jest.

Jednym z ważniejszych wymagań jest to by właściciel aukcji nie brał udziału w licytacji, co zapobiega sztucznym podbiciom.

        [Test]
        [ExpectedException(typeof(PlaceBidOperationException))]
        public void should_not_accept_the_bid_because_it_is_placed_by_the_owner_of_the_auction()
        {
            var testedAuction = An.OngoingAuctionOwnedByPoorUser;
            testedAuction.Place(new Bid(A.PoorUser, new Money(20)));
        }

Test jest tak samo prosty jak kod implementujący wymagania.

…
        public void Place(Bid newBid)
        {
            if (newBid == null) throw new ArgumentNullException("newBid");
            if (newBid.WasBiddedBy == Owner)
            {
                throw new PlaceBidOperationException("The owner is not allowed to place bids on his auction");
            }
            Bid lastBid = LastBid;
            if (WasBidded)
            {
                if (lastBid.Amount.IsGraterThen(newBid.Amount))
                {
                    throw new PlaceBidOperationException("The amount of this bid is lower then of the previously placed bid");
                }
            }
            bids.Add(newBid);
        }

        public User Owner { get; private set; }
…

Klasa Auction została uzupełniona o właściwość reprezentującą użytkownika, który jest właścicielem aukcji. Jako że aukcja zawsze musi mieć swojego właściciela, który się nigdy nie zmienia, właściwość Owner nie posiada publicznego settera. Dlatego też zostanie ona ustawiana przez konstruktor lub klasę faktoryzującą obiekt Auction. Wybranie odpowiedniego rozwiązania w tym momencie nie jest kluczowe.

Dalszymi wymaganiami było zapewnienie, że aukcja nie może być podbita przez użytkownika, który dodał dotychczasowo najwyższe podbicie oraz zabranianie dodawania podbić przed rozpoczęciem aukcji i po jej skończeniu. Ze względu na obszerność tego posta pominę testy i przedstawię już końcową wersje metody Place

…
        public void Place(Bid newBid)
        {
            if (newBid == null) throw new ArgumentNullException("newBid");
            if (Ends  DateTime.Now)
            {
                throw new PlaceBidOperationException("The auction has not yet begun");
            }
            if (newBid.WasBiddedBy == Owner)
            {
                throw new PlaceBidOperationException("The owner is not allowed to place bids on his auction");
            }
            Bid lastBid = LastBid;
            if (WasBidded)
            {
                if (lastBid.Amount.IsGraterThen(newBid.Amount))
                {
                    throw new PlaceBidOperationException(
                        "The amount of this bid is lower then of the previously placed bid");
                }
                if (lastBid.WasBiddedBy == newBid.WasBiddedBy)
                {
                    throw new PlaceBidOperationException("The last bid was placed by the same user");
                }
            }
            bids.Add(newBid);
        }

        public DateTime Begins { get; private set; }

        public DateTime Ends { get; private set; }
…

Poprzez enkapsulację w klasie Auction reguł biznesowych z nią związanych, uzyskamy łatwy w utrzymaniu i zrozumieniu kod. Co najważniejsze, model domenowy wyrażony w postaci kodu jest spójny z tym przedstawionym w trakcie rozmowy pomiędzy ekspertem a programistami. Oczywiście należy być świadomym tego, że to tylko namiastka tego, co nas czekałoby w rzeczywistym przypadku. Poza tym ze względu na długość wpisu pominąłem niektóre z wymagań.

Zdaję sobie sprawę z tego, że pozostaje wiele pytań związanych z dalszym rozwojem aplikacji opartej na przedstawionym modelu domenowym, jak choćby sposób zapisywania stanu obiektów. Mam nadzieję, że te i inne kwestie będę mógł opisać w dalszych wpisach.

4 comments Luty 12, 2009


Aktualnie czytam

  • Enterprise Integration Patterns

Tagi

About Agile ALT.NET ASP.NET MVC DDD Domain Driven Design Domain Model Exceptions Front Controller Logika biznesowa NHibernate OOP ORM Pair Programming Podstawy Prezentacja Projekty Informatyczne Routing SQL TDD Wroc.NET Wzorce XP

Dodatki

Blogroll

Znajomi

Najnowsze komentarze

zajefajnyx on Czy ty też nadużywasz procedur…
jenrom on Logowanie i obsługa wyjątków …
am on Fluent Nhibernate Rocks!!…
Jarek on Logowanie i obsługa wyjątków …
jenrom on Logowanie i obsługa wyjątków …

Archiwa