Testowanie statycznych wywołań na przykładzie DateTime.Now

25

Testy jednostkowe “czasu” nie są tematem łatwym. Rozsiane po całej aplikacji wywołania DateTime.Now (które i tak powinny być odwołaniami do DateTime.UtcNow) nie upraszczają tej kwestii.

Problem ten można rozwiązać na kilka sposobów. Można na przykład opakować statyczne metody w dedykowane obiekty implementujące proste interfejsy (jak pisał niedawno Michał Franc). Czyli:

public interface ICurrentTimeProvider
{
    DateTime GetCurrentTime();
}
 
public class CurrentTimeProvider : ICurrentTimeProvider
{
    public DateTime GetCurrentTime()
    {
        return DateTime.UtcNow;
    }
}

I następnie dorzucać to jako constructor dependency do każdej klasy używającej czasu…

To podejście stosuję często, na przykład dla wszystkich operacji ze statycznej klasy File. Ale dla DateTime.Now mi to nie pasuje. To jest jeden z niewielu przypadków, kiedy nie mam nic przeciwko wykorzystywaniu statycznej właściwości/metody.

Pozostając przy używaniu DateTime.(Utc)Now można by było przetestować taki kod za pomocą Microsoft Fakes czy Typemock Isolator, ale to rozwiązanie podoba mi się jeszcze mniej (o czym wspomniałem w postach “Mocki” oraz – trochę już nieaktualnym – “Wybór mock-object framework“).

Moja propozycja to napisanie własnego statycznego odpowiednika skoncentrowanego właśnie na zwracaniu daty/czasu z możliwością podmiany jego implementacji w runtime na potrzeby testów. Coś takiego:

public static class ApplicationTime
{
    private static readonly Func<DateTime> _defaultLogic =
          () => DateTime.UtcNow;
 
    private static Func<DateTime> _current = _defaultLogic;
 
    /// <summary>
    /// Returns current date/time, correct in application context
    /// </summary>
    /// <remarks>Normally this would return <see cref="DateTime.UtcNow" />
    /// , but can be changed to make testing easier.</remarks>
    public static DateTime Current
    {
        get { return _current(); }
    }
 
    /// <summary>
    /// Changes logic behind <see cref="Current" />.
    /// Useful in scenarios where time needs to be defined upfront, like unit tests.
    /// </summary>
    /// <remarks>Be sure you know what you are doing when calling this method.</remarks>
    [EditorBrowsable(EditorBrowsableState.Advanced)]
    public static void _replaceCurrentTimeLogic(Func<DateTime> newCurrentTimeLogic)
    {
        _current = newCurrentTimeLogic;
    }
 
    /// <summary>
    /// Reverts current logic to the default logic.
    /// Useful in scenarios where unit test changed logic and should rever system to previous state.
    /// </summary>
    /// <remarks>Be sure you know what you are doing when calling this method.</remarks>
    [EditorBrowsable(EditorBrowsableState.Advanced)]
    public static void _revertToDefaultLogic()
    {
        _current = _defaultLogic;
    }
}

Dzięki takiemu podejściu możemy:

  1. łatwo znaleźć w całym systemie niepoprawne korzystanie z daty (wszystkie odwołania do DateTime.Now i UtcNow poza tą klasą są niepoprawne)
  2. zapewnić, że każde miejsce w systemie używa tej samej metody do wygenerowania aktualnego czasu (znalezienie buga spowodowanego tym, że jeden programista użył .Now a drugi .UtcNow nie jest proste, a miałem niedawno wątpliwą przyjemność właśnie z czymś takim się zmagać)
  3. sterować w testach pojęciem “aktualnego czasu”

Przykład zastosowania:

public class FeeCalculator
{
    public const decimal DailyFee = 50.0m;
 
    public decimal FeeFor(DateTime startTime)
    {
        DateTime now = ApplicationTime.Current;
 
        if (startTime > now)
        {
            throw new ArgumentException("Cannot calculate fee for future time");
        }
 
        int daysCount = (int)(now - startTime).TotalDays;
 
        return DailyFee * daysCount;
    }
}

public class FeeCalculatorTests : IDisposable
{
    private FeeCalculator _calculator;
 
    public FeeCalculatorTests()
    {
        ApplicationTime._replaceCurrentTimeLogic(() => new DateTime(2011, 03, 14, 13, 00, 00));
 
        _calculator = new FeeCalculator();
    }
 
    public void Dispose()
    {
        ApplicationTime._revertToDefaultLogic();
    }
 
    [Fact]
    public void returns_multiplication_of_days()
    {
        decimal fee = _calculator.FeeFor(new DateTime(2011, 03, 10));
 
        Assert.Equal(200m, fee);
    }
 
    [Fact]
    public void returns_0_if_the_same_day()
    {
        decimal fee = _calculator.FeeFor(new DateTime(2011, 03, 14, 10, 13, 00));
 
        Assert.Equal(0.0m, fee);
    }
 
    [Fact]
    public void throws_if_future_time()
    {
        Assert.Throws<ArgumentException>(
            () => _calculator.FeeFor(new DateTime(2011, 05, 03))
        );
    }
}

Niejeden już raz ten mechanizm bardzo uprościł kod moich testów.
Jak Wy sobie z tym radzicie?

Share.

About Author

Programista, trener, prelegent, pasjonat, blogger. Autor podcasta programistycznego: DevTalk.pl. Jeden z liderów Białostockiej Grupy .NET i współorganizator konferencji Programistok. Od 2008 Microsoft MVP w kategorii .NET. Więcej informacji znajdziesz na stronie O autorze. Napisz do mnie ze strony Kontakt. Dodatkowo: Twitter, Facebook, YouTube.

25 Comments

  1. Pingback: dotnetomaniak.pl

  2. Dla mnie ten przykład to typowy over-engineering. Równie dobrze można zrobić szukanie po wszystkich plikach, czy gdzieś nie występuje DateTime.now (to apropo zalety nr 1 i 2). Poza tym w typowych aplikacjach korzystających z baz danych lepiej korzystać z wartości domyślnych dla kolumn (i mieć pewność, że baza zawsze wstawi bieżący czas utc)

  3. unodgs,
    Jeśli to jest overengineering to bardzo chętnie poznam prostszy sposób. Zalety 1) i 2) są poboczne i mało istotne w kontekście tego posta… i nie wiem skąd tu nawiązanie do baz danych, nie widzę zależności między opisywaną metodą a generowanie dat w bazie.
    Dzięki zaproponowanemu tutaj rozwiązaniu mogę przetestować taki scenariusz: system ma wysyłać SMSy tylko w godzinach 8-16, a w niedzielę 10-16. W Wielkanoc ma nie wysyłać SMSów. To wszystko ma oczywiście uwzględniać przesunięcia spowodowane Daylight Saving Time.
    Nie widzę innego sensownego sposobu na przetestowanie takiej logiki. Skompilowanie i uruchomienie systemu i czekanie do niedzieli albo Wielkanocy nie wchodzi raczej w grę.

    • Procent, trochę za szybko przeczytałem twojego posta i dopiero teraz sobie uświadomiłem co masz na myśli i że nie chodzi tylko o now vs utcnow. Moja wina :) Cofam swoje zdanie o overengineering! Dzięki za posta.

  4. tchrikch on

    Pomysl mi sie podoba ale wygodny jest (jak zreszta zauwazyles) w przypadku gdy chcesz oszukac tylko jedna metode , w przeciwnym wypadku adapter i dodanie nowej zaleznosci do klasy jest wygodniejsze (np. dla File) bo nie trzeba powielac przywracania domyslnej logiki plus kod jest spojny bo wszedzie korzystamy z tego samego podejscia.

  5. tchrikch,
    To stosuję chyba wyłącznie przy datetime.now z tego co w tej chwili kojarzę. A “spójność” kodu na takim poziomie nie jest dla mnie zaletą. Wadą też nie, po prostu nie ma znaczenia:).

  6. Ja podobnie jak %, ale jeszcze mam taka klase do HttpContext i to wszystko. Dla file juz mam odpowiednie wrappery/adaptery.

    Przywracanie domyslnej logiki jest tylko i wylacznie w testach. i jak % pokazal jest to dispose.

    Jak sie nie chce powielac to wystarczy napsiac bazowa klase testowa dla elementow zwiazanych z czasem i miec jeden dispose, a potem jedynie pamietac o zwracaniu odpowiedniego czasu.

    Dodatkowo po co mam robic inject adaptera do czasu do klasy? nagle sprawy sie troche bardziej komplikuja. a co jak nie korzystam z DI bo projekt tego nie potrzebuje? ze wzgledu na czas mam dodac DI? jakos to mi nie pasuje :)

  7. Takie podejście stosuje też Ayende. Też używam takiego sposobu na testowanie komponentów zależnych od czasu. Wydaje mi się to dużo lepsze niż stosowanie typemock isolator lub innych narzedzi. Po prostu szkoda kasy na to. Moze ktos sie zapytac: a co jezeli programista w produkcyjnym kodzie ustawi datę? Testy powinny to wykryć.

  8. @Bartek

    #if DEBUG albo jakis inny trick.

    przy release po prostu metody te nie beda dostepne.

    jezeli zas ktos wrzuca wersje debug do produkcji to sam sobie jest juz winny :)

  9. Bartek, Gutek,
    Dokładnie to samo chciałem zaproponować:). Nikt chyba nie będzie na tyle szalony żeby zmieniać funkcję przypisaną pod _defaultLogic samym pliku ApplicationTime.cs. A resztę opędzą dyrektywy #if DEBUG.

  10. Tadek,
    Ano Ayende o lata mnie wyprzedził:). Chociaż na usprawiedliwienie dodam że ten post w szkicach ze 2 lata leżał zanim go w końcu napisałem.
    Zaraz się okaże że ktoś wcześniej Devkalog wymyślił i będzie wstyd na maxa:).

  11. tchrikch on

    Chyba nikt nie wymyslil ale po Ayende sie wszystkiego mozna spodziewac :)

  12. Jak zwykle świetny post :). Doszedłem do podobnego rozwiązania parę lat temu (być może źródłem był właśnie blog Ayende), ale miałem trochę inną motywację – testy ręczne.

    Kontekst: istniejący, kobylasty system w WebForms, testowany wyłącznie ręcznie przez grupę testerów. I nagle duża zmiana w algorytmach, która wchodzi w życie od 1-szego stycznia. Testerzy mieli zagwozdkę – jak to przetestować np.: wprowadzamy dane na poprzedni rok, ale chcemy wyliczyć stan na 5-tego następnego roku – część starym, część nowym algorytmem (chodziło o billing). Zatem dodało się opcję “Time Machine”, która przestawiała czas aplikacji +/- opisaną przez ciebie metodą (tyle, że zamiast fiksować na konkretny moment następowało przesunięcie o zadaną deltę i było do tego UI). Po stronie klienta działał też hack podmieniający w JavaScript konstruktor Date dający czas zsynchronizowany z serwerem (co by kontrolki dat nie wariowały) – może nawet ten kod się komuś przyda:

    https://gist.github.com/orient-man/4977668

    Dopiero później dojrzeliśmy do wykorzystania tej metody w testach automatycznych.

  13. Bardzo dobre podejście do tematu. Sam zacząłem jakiś czas temu wykorzystywać wstrzykiwanie “Serwisu do czasu” w systemie opartym na Azure. Rzeczywiście, wstrzykiwanie do dużej liczby komponentów takiego interfejsu bywa problematyczne.
    Dzięki za podzielenie się pomysłem.

  14. Przemysslaw on

    Pozwolę sobie nieśmiało dorzucić swoje (no może nie do końca swoje :P) 3 grosze do tematu. W książce Dependency Injection in .NET Mark Seemann przedstawiając koncepcję Ambient Context podaje przykład jak można za jej pomoca “sterować” czasem w aplikacji no i przede wszystkim w unit testach – aktualnie staram się czytać tę książkę ;) więc temat rzucił mi się od razu w oczy ;) Może ktoś miałby czas żeby przyjrzeć się temu rozwiązaniu i ocenić jego przydatnośc fachowym okiem ;) Tak się szczęśliwie składa, że ta koncepcja jest omawiana w rozdziale, który jest dostępny za free tutaj -> http://www.manning.com/seemann/DIi.NET_sample_ch04.pdf
    Pozdrawiam

  15. Przemysslaw,
    A to jest właściwie to samo rozwiązanie, tylko w książce autor ubiera to w bardziej “fancy” nazwę :)

  16. Przemysslaw on

    Damn nie spodziewałem się tak szybkiej odpowiedzi :P Nazwa faktycznie “fancy” ;) Swoja drogą chodzi mi po głowie ostatnio pytanie, które jest poniekąd z tej samej “parafii” ;) Czy każdy publiczny “member” klasy powinien wywodzić się za jakiegoś typu abstrakcji (interfejs, klasa abstrakcyjna)? Czy jest to trochę zbyt daleko idące stwierdzenie?

  17. Przemysslaw,
    Jak dla mnie to samo słowo “każdy” w pytaniu implikuje odpowiedź: “nie” :). Takie stwierdzenia są z zasady zbyt daleko idące.

  18. Przemysslaw on

    Jeszcze raz ja z kolejnym naiwnym pytaniem ;) Czy w takim razie jeśli używam typu zdefiniowanego w zewnętrznym assembly, który nie posiada żadnej abstrakcji, to czy powinienem sam taką abstrakcję dodać w postaci jakiegoś wrapper’a? Przynajmniej taka strategię chcę właśnie obrać ponieważ mam do czynienia aktualnie z API Tfs’a, a używanie, a zwłaszcza bezpośrednie tworzenie obiektów z zewnętrznych assembly źle mi sie kojarzy (zwłaszcza w kontekście unit testów) ;) Z odpowiedziami można zaczekac do poniedziałku oczywiście :P Pozdrawiam i życzę miłego weekendu ;)

  19. Przemysslaw,
    Znowu: “to zależy”. Własnej abstrakcji nad frameworkiem do logowania bym nie pisał. Własnego data access – też nie. A API TFSa… no, to zależy. Jeśli programujesz pod konkretną wersję TFSa to nie ma chyba takiej potrzeby. Ale z drugiej strony – jeśli napiszesz własne klasy reprezentujące obiekty w TFSie i “adapter” łączący twój kod z dllką TFSową to obsługa kolejnej wersi TFSa to tylko kolejny adapter…
    Z tym że to dodatkowa robota, często żmudna. Ja bym pewnie tego nie robił dla jakiegoś projektu “internal”. Dla produktu zewnętrznego pewnie już jednak warto byłoby się nad tym zastanowić.

  20. @Przemysslaw, ważne pytanie, to czy kod ma być testowany automatycznie czy nie (zakładem, że raczej tak). Testowanie kodu, który używa API TFS-a będzie trudne. Moja reguła grubego palucha: jeśli używasz API, którego rezultaty działania niełatwo powtórzyć (“rm -f *” ;) lub jest drogie (np. każde odwołanie kosztuje 0,01$) lub po prostu powolne w działaniu – to robi się wrappery. W przypadku bazy danych jest zawsze opcja testów na bazie w pamięci. Pisałem kiedyś kawałek kodu, który ściągał katalogi TERYT ze strony GUS. Trzeba było sparsować tabelkę HTML-ową, aby odczytać datę modyfikacji pliku. I aby móc to swobodnie testować ukryłem klasę HttpClient za ta wrapperem – od tej pory zawsze to robię. W przypadku takich API jak DateTime.Now czy HttpClient wrappery robi się łatwo. Gorzej, gdy API ma 30+ metod i wszystkich używasz. Na szczęście potrzeba tworzenia abstrakcji (lub chociaż rezygnacja z oznaczania klas jako sealed) dotarła także do Microsoftu.

  21. Przemysslaw on

    @orientman Bardzo dobre pytanie. Napiszę tak: bardzo bym chciał testować ten kod automatycznie i bardzo możliwe, że wygospodaruję na to czas i przekonam kogo trzeba, że ma to sens ;) Może trochę zbyt ogólnie i dramatycznie napisałem o mojej przygodzie z API TFS’a – oczywiście nie mam zamiaru korzystać z całości, a jedynie z bardzo małego fragmentu, także w grę nie wchodzi naście klas po naście metod każda, a góra 4 – 5 ;)
    @Procent Jest to pewien wewnętrzny projekt i wydawało sie, że problematyczny fragment będzie wykorzystywany tylko w jednym miejscu. Okazało się jednak, że ta funkcjonalność pojawi się też gdzie indziej. Zmiany wersji TFS’a to chyba tylko w górę wraz z pojawieniem się nowszych wersji – o ile takie w ogóle są w planie :P
    Pozdrawiam

Newsletter: devstyle weekly!
Dołącz do 1000 programistów!
  Zero spamu. Tylko ciekawe treści.
Dzięki za zaufanie!
Do przeczytania w najbliższy piątek!
Niech DEV będzie z Tobą!