DateTime.Now?
Tytułową właściwość zna każdy. Służy ona do pobrania aktualnej daty lokalnej. Jest jedną z najczęściej używanych właściwości systemowych, a zarazem jedną z bardziej problematycznych. Część z Was zapewne powie, że nie powinno się jej używać i lepiej użyć DateTime.UtcNow, aby nie mieć problemów, gdy mamy użytkowników w różnych strefach czasowych. Ale o tym kiedy indziej, dzisiaj chciałbym się skupić na testowaniu kodu, który potrzebuje informacji o aktualnym czasie (DateTime.Now).
Testowanie
Testowanie kodu korzystającego z DateTime.Now (lub DateTime.UtcNow) jest problematyczne, szczególnie w przypadku testów jednostkowych. Bo jak efektywnie przetestować kod:
public User Update(User user) | |
{ | |
user.UpdatedDate = DateTime.Now; | |
//zapis użytkownika | |
return user; | |
} |
Lub taki kod:
public User Create(string name) | |
{ | |
if (DateTime.Now < new DateTime(2017, 9, 1)) | |
{ | |
throw new Exception("Rejestracja użytkowników jest zamknięta do 1 września 2017."); | |
} | |
User user = new User(); | |
user.Name = name; | |
//dodanie użytkownika | |
return user; | |
} |
Założeniem testów jednostkowych jest przede wszystkim to, że wykonanie testu jest powtarzalne i przewidywalne. To w przypadku DateTime.Now (DateTime.UtcNow) jest niestety złamane, ponieważ za każdym razem otrzymujemy inną wartość. W szczególności drugi przykład spowoduje, że w pewnym momencie testy przestaną działać, bo osiągniemy określoną datę.
Rozwiązanie?
Dobre testy jednostkowe powinny testować klasę w odizolowaniu od innych elementów aplikacji, w tym od elementów systemowych, takich jak DateTime.Now (DateTime.UtcNow). Z pomocą przychodzi nam wrapper w postaci na przykład interfejsu IDateService, który opakuje nam wywołanie DateTime.Now (DateTime.UtcNow). Wrapper będzie wstrzykiwany w formie zależności (na przykład przez konstruktor), dzięki czemu na poziomie testów jednostkowych będziemy mogli utworzyć atrapę z określonym czasem.
IDateService mógłby wyglądać następująco:
public interface IDateService | |
{ | |
DateTime Now { get; } | |
DateTime UtcNow { get; } | |
} |
Natomiast sama implementacja tak:
public class DateService : IDateService | |
{ | |
public DateTime Now | |
{ | |
get | |
{ | |
return DateTime.Now; | |
} | |
} | |
public DateTime UtcNow | |
{ | |
get | |
{ | |
return DateTime.UtcNow; | |
} | |
} | |
} |
Dzięki temu dla pierwszej naszej metody możemy napisać taki test:
[TestFixture] | |
public class UserLogicTests | |
{ | |
private Mock<IDateService> _dateServiceMock; | |
private UserLogic Create() | |
{ | |
_dateServiceMock = new Mock<IDateService>(); | |
return new UserLogic(_dateServiceMock.Object); | |
} | |
[Test] | |
public void Update_SetUpdatedDate() | |
{ | |
var userLogic = Create(); | |
_dateServiceMock.Setup(u => u.Now) | |
.Returns(new DateTime(2017, 1, 1)); | |
var user = userLogic.Update(new User()); | |
Assert.AreEqual(new DateTime(2017, 1, 1), user.UpdatedDate); | |
} | |
} |
A zmieniona implementacja klasy logiki biznesowej będzie wyglądała tak:
public class UserLogic | |
{ | |
private IDateService _dateService; | |
public UserLogic(IDateService dateService) | |
{ | |
_dateService = dateService; | |
} | |
public User Update(User user) | |
{ | |
user.UpdatedDate = _dateService.Now; | |
//zapis użytkownika | |
return user; | |
} | |
} |
Podróż w czasie?
A co z tytułową podróżą w czasie? Kilka razy zdarzyło mi się tworzyć system, w którym aktualny czas był jednym z kluczowych elementów aplikacji. Na przykład był to portal z reklamami, w którym reklama po określonym czasie musiała przestać się pokazywać. Niestety testowanie takiej aplikacji przez testera jest dość problematyczne, w momencie gdy korzystamy z aplikacji z DateTime.Now (DateTime.UtcNow). Na szczęście z pomocą ponownie przychodzi nam DateService.
W swojej implementacji dodaję możliwość przesuwania się w czasie. DateService ma dwie dodatkowe metody. Pierwszą z nich jest SetNow, która otrzymuje czas określający nowy czas, od którego trzeba liczyć aktualny czas. Przykładowo, gdy tester dodał nowe ogłoszenie na 30 dni, może wykorzystać SetNow, aby przenieść się w czasie o te 30 dni. Dzięki temu, że cała aplikacja korzysta z DateService, worker, który testowo odpala się co 5 minut, przy kolejnym działaniu pobierze przyszłą datę i wyłączy wyświetlenie ogłoszenia. Dzięki temu tester może przetestować wygaszenie reklam w ciągu kilku minut, bez konieczności czekania, aż określony czas rzeczywiście minie.
Druga metoda usługi to metoda Reset, która przywraca aktualny czas. Właściwa implementacja DateService to:
public class DateService : IDateService | |
{ | |
private TimeSpan _offset = new TimeSpan(); | |
public DateTime Now | |
{ | |
get | |
{ | |
return DateTime.Now + _offset; | |
} | |
} | |
public DateTime UtcNow | |
{ | |
get | |
{ | |
return DateTime.UtcNow + _offset; | |
} | |
} | |
public void SetNow(DateTime date) | |
{ | |
_offset = date - DateTime.Now; | |
} | |
public void Reset() | |
{ | |
_offset = new TimeSpan(); | |
} | |
} |
Jak widać, użyłem typu TimeSpan w formie prywatnego pola, który zawiera różnicę między aktualnym czasem a czasem ustawionym przez użytkownika. Oczywiście aby wszystko działało poprawnie, nasza usługa musi być rejestrowana w kontenerze dependency injection jako singleton.
Chciałbym zaznaczyć jeszcze, że sam interfejs dla usługi (IDateService) nie zawiera dodatkowych metod, które są specyficzne tylko dla tej konkretnej implementacji.
Testowanie
W swoich aplikacjach ASP.NET MVC mam specjalny kontroler (DebugController), który służy do konfigurowania aplikacji na potrzeby testów. Kontroler ten nie jest dostępny w środowisku produkcyjnym.
W naszym przykładzie DebugController posiada akcje, które umożliwiają zmianę aktualnego czasu w systemie na podstawie tego, co użytkownik wprowadzi. Sam kontroler może również zawierać inne funkcjonalności przydatne przy testowaniu – na przykład widok, który wymusi uruchomienie workera wyłączającego reklamy, których data minęła.
Oczywiście ze zmianą czasu warto uważać w przypadku, gdy z jednej aplikacji testowej korzysta wielu testerów, ponieważ może się okazać, że poszczególni testerzy będą sobie nawzajem zmieniać czas. U nas w projekcie w takiej sytuacji każdy z testerów miał własną instancję aplikacji, w której mógł sobie dowolnie zmieniać czas.
Na githubie jest dostępny projekt, w którym możecie przetestować działanie DateService. Szczególnie zachęcam do zajrzenia do kontrolera DebugController. Aby przetestować aplikację, wystarczy wejść na stronę /Debug/Date, na której jest wyświetlany aktualny czas pobierany z serwera za pomocą ajax co jedną sekundę. Dodatkowo widok umożliwia również ustawienie nowej daty.
A jak Wy radzicie sobie w takich sytuacjach?
fajny artykuł dzięki. będę używał.