Przejdź do treści

DevStyle - Strona Główna
Testowanie statycznych wywołań na przykładzie DateTime.Now

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

Maciej Aniserowicz

14 lutego 2013

Backend

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?

Zobacz również