fbpx
devstyle.pl - Blog dla każdego programisty
devstyle.pl - Blog dla każdego programisty
4 minut

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


14.02.2013

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=&quot;DateTime.UtcNow&quot; />
    /// , but can be changed to make testing easier.</remarks>
    public static DateTime Current
    {
        get { return _current(); }
    }
 
    /// <summary>
    /// Changes logic behind <see cref=&quot;Current&quot; />.
    /// 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(&quot;Cannot calculate fee for future time&quot;);
        }
 
        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?

0 0 votes
Article Rating
25 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
trackback
11 years ago

Testowanie statycznych wywołań na przykładzie DateTime.Now | Maciej Aniserowicz o programowaniu…

Dziękujemy za dodanie artykułu – Trackback z dotnetomaniak.pl…

unodgs
unodgs
11 years ago

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)

unodgs
unodgs
11 years ago
Reply to  procent

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.

tchrikch
tchrikch
11 years ago

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.

Gutek
11 years ago

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 :)

Tadek
11 years ago

Ayende proponował coś takiego już w 2008 roku :) http://ayende.com/blog/3408/dealing-with-time-in-tests .
Generalnie zgadzam się, dodawanie wprapper’a DateTime.Now poprzez constructor injection to nie jest ciekawe rozwiązanie i w praktyce statyczny podmienialny delegat to dużo lepsze rozwiązanie.

Bartek
Bartek
11 years ago

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ć.

Gutek
11 years ago

@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 :)

tchrikch
tchrikch
11 years ago

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

orientman
11 years ago

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.

Julek
Julek
11 years ago

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.

Przemysslaw
Przemysslaw
11 years ago

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

orientman
11 years ago
Reply to  Przemysslaw

@Przemysslaw To mi wygląda na identyczne podejście.

Przemysslaw
Przemysslaw
11 years ago

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?

Przemysslaw
Przemysslaw
11 years ago

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 ;)

orientman
11 years ago

@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.

Przemysslaw
Przemysslaw
11 years ago

@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

Kurs Gita

Zaawansowany frontend

Szkolenie z Testów

Szkolenie z baz danych

Książka

Zobacz również