Daty – jak sobie z nimi radzić

Często w naszych aplikacjach mamy do czynienia z datami. Najczęściej używamy ich przy polach takich jak data utworzenia (np. CreatedAt) lub data modyfikacji (np. ModifiedAt), ale są też inne miejsca. Wtedy z reguły stosujemy DateTime.UtcNow.

Niestety w testach ciężko jest potem coś z taką datą zrobić. Nie mamy żadnej możliwości ustawienia tej daty na jakąś inną, aby przetestować czy nasza aplikacja działa poprawnie. Sprawa się jeszcze bardziej komplikuje, gdy mamy logikę biznesową uzależnioną od takiej daty. Wtedy już nie wypada, aby taki kod nie był przetestowany, a testy w tej sytuacji napisać trudno.

Postaram się podać kilka pomysłów, jak można sobie radzić w takiej sytuacji.

IDateTime

Pierwszym rozwiązaniem, jakie się nasuwa, jest stworzenie prostego interfejsu opakowującego DateTime. Może on wyglądać tak:

public interface IDateTime
{
    DateTime UtcNow { get; }
}

A jego przykładowa implementacja tak:

public class SystemDateTime : IDateTime
{
    public SystemDateTime()
    {
        UtcNow = DateTime.UtcNow;
    }
    
    public DateTime UtcNow { get; }
}

Taki interfejs musimy oczywiście jeszcze zarejestrować w naszym kontenerze wstrzykiwania zależności (z reguły jest to metoda ConfigureServices w klasie Startup):

public void ConfigureServices(IServiceCollection services)
{
    ...
    services.AddTransient<IDateTime, SystemDateTime>();
    ...
}

Teraz za każdym razem, gdy będziemy chcieli użyć DateTime, powinniśmy pamiętać o tym, aby zamiast klasy statycznej DateTime, wykorzystywać nasz nowy interfejs IDateTime:

public class SomeClass
{
    ...
    private readonly IDateTime _dateTime;
    ...

    public SomeClass(
        ...
        IDateTime dateTime
        ...
    )
    {
        ...
        _dateTime = dateTime;
        ...
    }

    ...
    public void SomeMethod()
    {
        ...
        someObject.CreatedAt = _dateTime.UtcNow;
        ...
    }
    ...
}

IScopedDateTime

Czasem możemy mieć potrzebę, żeby wszystkie daty, jakie ustawiamy w obrębie jednego żądania (request) miały ustawioną tę samą datę. W takiej sytuacji możemy powielić nasz interfejs, ale z inną nazwą i zarejestrować go jako scoped.

Interfejs:

public interface IScopedDateTime
{
    DateTime UtcNow { get; }
}

Jeśli chodzi o jego implementację, możemy stworzyć nową klasę implementującą ten interfejs albo rozszerzyć naszą klasę SystemDateTime:

public class SystemDateTime : IDateTime, IScopedDateTime
{
    public SystemDateTime()
    {
        UtcNow = DateTime.UtcNow;
    }
    
    public DateTime UtcNow { get; }
}

Jeszcze rejestracja w kontenerze wstrzykiwania zależności:

public void ConfigureServices(IServiceCollection services)
{
    ...
    services.AddScoped<IScopedDateTime, SystemDateTime>();
    ...
}

Testy

Takie podejście w zupełności nam wystarcza. Teraz w testach możemy zmockować nasze interfejsy z datą (IDateTime i IScopedDateTime) lub dostarczyć ich specjalną implementację przygotowaną na potrzeby testów, np.:

public class TestDateTime : IDateTime
{
    public DateTime UtcNow { get; set; } = DateTime.UtcNow;
}

Wszystko fajnie działa. Takie podejście ma jednak jeden minus. Za każdym razem, gdy w danej klasie chcemy wykorzystać datę (DateTime), musimy wstrzyknąć odpowiedni interfejs.

Pochylmy się teraz nad rozwiązaniem, które nie wymaga wstrzykiwania interfejsów.

Clock

Stwórzmy własną klasę statyczną, którą będziemy używać jako substytut do DateTime – nazwijmy tę klasę Clock. Ta klasa będzie zawierać w sobie prywatne pole typu IDateTime oraz publiczną właściwość zwracającą DateTime. Może to wyglądać tak:

public static class Clock
{
    private static IDateTime _dateTime;

    internal static IDateTime InternalDateTime
    {
        get { return _dateTime ??= new SystemDateTime(); }
        set => _dateTime = value;
    }

    public static DateTime UtcNow => InternalDateTime.UtcNow;
}

Właściwość InternalDateTime jest po to, abyśmy mogli podmienić naszą systemową datę na jakąś inną, np. testową. Domyślnie ustawiamy, aby zmienna _dateTime zawierała naszą systemową datę.

Użycie tej klasy jest bardzo proste. Nie musimy nic wstrzykiwać. Powinniśmy jedynie pamiętać, aby zawsze używać klasy Clock zamiast DateTime:

public class SomeClass
{
    ...
    public void SomeMethod()
    {
        ...
        someObject.CreatedAt = Clock.UtcNow;
        ...
    }
    ...
}

Testy

W testach przyda nam się możliwość zwracania przez klasę Clock oczekiwanej przez nas daty. W tym celu stwórzmy klasę, która nam to umożliwi:

public class ClockOverride : IDisposable
{
    private readonly IDateTime _previousClock;
    
    public ClockOverride(DateTime utcNow)
    {
        _previousClock = Clock.InternalDateTime;
        Clock.InternalDateTime = new FakeDateTime(utcNow);
    }

    public void ForwardBy(TimeSpan timeSpan)
    {
        Clock.InternalDateTime = new FakeDateTime(Clock.UtcNow.Add(timeSpan));
    }

    public void Dispose()
    {
        Clock.InternalDateTime = _previousClock;
    }
}

Tutaj od razu zrobiliśmy jeszcze jedno usprawnienie. Aby lepiej podkreślić upływ czasu w testach, stworzyliśmy metodę ForwardBy. Przy jej pomocy, będziemy ręcznie przesuwać czas. Dzięki takiemu zabiegowi testy czyta się dużo lepiej. Dodatkowo w klasie ClockOverride zaimplementowaliśmy interfejs IDisposable, aby przy zwalnianiu obiektu (Dispose) przywracać stary zegar – to też się czasem w testach przydaje.

Jeszcze tylko implementacja klasy FakeDateTime:

public class FakeDateTime : IDateTime
{
    public FakeDateTime(DateTime utcNow)
    {
        UtcNow = utcNow;
    }

    public DateTime UtcNow { get; }
}

Użycie takiego zegara może być następujące:

...
var dateFromThePast = new DateTime(2020, 08, 08);
using (var clockOverride = new ClockOverride(dateFromThePast))
{
    ...
    someObject.CreatedAt = Clock.UtcNow;
    ...
    clockOverride.ForwardBy(new TimeSpan(1, 0, 0, 0));
    ...
    someOtherObject.CreatedAt = Clock.UtcNow;
    ...
}
...

Pierwsze wywołanie Clock.UtcNow zwróci datę 2020-08-08 – niezależnie od tego, jaki jest obecnie dzień. Następnie przesuwamy nasz zegar o 1 dzień. W związku z tym drugie wywołanie Clock.UtcNow zwróci datę 2020-08-09. Na końcu tego bloku kodu klasa ClockOverride zostanie zwolniona (wywoła się metoda Dispose) i zegar wróci do normalności – przy kolejnym wywołaniu Clock.UtcNow otrzymamy obecną datę.

5 myśli na “Daty – jak sobie z nimi radzić”

  1. Pingback: dotnetomaniak.pl

  2. Ja mam trochę inną implementację:

    class MyDateTime: IDateTime
    {
    DateTime Now=>DateTime.UtcNow;
    }

    W ten sposób mam zawsze aktualną godzine, jeśli zainicjalizujesz w ctor to będziesz mieć zawsze stałą godzinę. Myślałem o tym kiedyś i to też jest taka opcja.

    Druga uwaga:
    Po co robić własne FakeTime skoro są mocki?

    1. Masz rację odnośnie implementacji MyDateTime. Twoja implementacja jest również poprawna. W niektórych sytuacjach przyda się Twoja implementacja, a w niektórych moja. Fajnie, że o tym wspomniałeś.

      Jeśli chodzi o „FakeTime”, to mogą być one użyte w testach integracyjnych czy e2e.
      W przypadku „TestDateTime” – rejestrujemy taką klasę w naszym kontenerze IoC, a potem możemy zmieniać jej wartość, bo ma dostępny publiczny setter. W testach jednostkowych jest ona niepotrzebna.
      W przypadku „FakeDateTime” to tutaj nie ma żadnej zależności do „zmockowania”. Ta klasa jest używana do nadpisania (przy użyciu klasy ClockOverride) naszego zegara, czyli statycznej klasy Clock. Moim zdaniem jest to o tyle fajne, że bez tworzenia dodatkowej zależności w klasach do interfejsu IDateTime, w testach mamy wpływ na datę i czas.

  3. Ja interfejs nazywam ITimeMachine i służy do abstrakcji różnych rzeczy związanych z czasem (DateTime.Now, Task.Delay, Thread.Sleep itp) i moja implementacja konstruktora nie ma (po prostu jest abstrakcją zwykłych klas).
    Wadą rozwiązania jest złamanie SRP, ale nie odczułem jeszcze negatywnych tego skutków.
    Myślałem nad podjęciem w stylu klasy typu Clock, ale nigdy na nie się nie zdecydowałem.

Leave a Reply