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="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?

Nie przegap kolejnych postów!

Dołącz do ponad 9000 programistów w devstyle newsletter!

Tym samym wyrażasz zgodę na otrzymanie informacji marketingowych z devstyle.pl (doh...). Powered by ConvertKit
Notify of
trackback

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

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)

tchrikch
tchrikch

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

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?… Read more »

Tadek

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

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

@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

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

orientman

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… Read more »

Julek
Julek

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

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

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

Przemysslaw
Przemysslaw

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

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

@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ę… Read more »

Przemysslaw
Przemysslaw

@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ę… Read more »

Moja książka

Facebook

Zobacz również