Czy obsługa daty i czasu w testach jednostkowych w JavaScript może czymś zaskoczyć? Okazuje się, że jak najbardziej. Jeśli obsługa daty i czasu w testach jednostkowych sprawia Ci problem lub gości w nich sinon.useFakeTimers
, to ten artykuł jest dla Ciebie. W artykule pokażę Ci przykładowe testy wykorzystujące useFakeTimers
oraz podpowiem co z nimi jest nie tak. Następnie przedstawię Ci proponowane rozwiązanie tego problemu, które możesz w łatwy sposób wykorzystać w swoim kodzie. Opisany sposób pracy z datami i czasem w JavaScript wykorzystuję na co dzień w swojej pracy i jak dotąd sprawdza się doskonale.
Przykład kodu do testów
W celu lepszego objaśnienia omawianego zagadnienia przygotowałem prostą klasę z funkcją, która zwraca datę n dni od momentu wywołania funkcji. Dodatkowo przygotowałem metodę, która ma symulować dodawanie nowego użytkownika.
class ExampleClass {
getExpirationDate ( days ) {
const now = new Date();
return new Date( now.getTime() + days * 24 * 60 * 60 * 1000 );
}
createUser( name ) {
return {
name,
createdAt: new Date().toISOString()
}
}
}
const obj = new ExampleClass();
console.log( obj.getExpirationDate( 30 ).toISOString() ); // "2023-09-02T17:26:32.646Z"
console.log( obj.createUser( 'joe' ) ); // [object Object]
Przetestowanie tak wyglądającego kodu może być trudne, jeśli chcemy uzyskać precyzyjną asercję. Można spróbować przygotować asercję na zasadzie akceptowalnego przedziału dla zwróconej daty. Przykładowo, można zapisać timestamp przed wywołaniem getExpirationDate()
a po wywołaniu spodziewać się otrzymania daty timestamp + n dni
w pewnym przedziale czasu, np. +/- kilka milisekund. Nie brzmi to zbyt dobrze i nie polecam takiego rozwiązania. Taka asercja byłaby nieprecyzyjna, a wybranie zbyt małego przedziału powodowałaby jej losowe faile. Analogicznie sytuacja wygląda dla metody createUser
. Uzyskanie precyzyjnej asercji dla pola createdAt
będzie trudne. Zadanie jest szczególnie utrudnione, gdyż tutaj metoda z przedziałem nie zadziała, ponieważ zwracana data jest w formie stringa.
Najczęściej spotykane podejście
Szukając rozwiązania rozwiązującego problem pracy z datą i czasem, prawdopodobnie natkniesz się na rozwiązania proponujące wykorzystanie biblioteki Sinon. Biblioteka Sinon jest narzędziem do tworzenia stubów i fake’ów w testach w środowisku JavaScript. Jedną z funkcji biblioteki sinon jest useFakeTimers()
. Funkcja ta umożliwia manipulowanie natywnymi klasami i metodami związanymi z zarządzaniem datami i czasem w JavaScript. Cytując fragment dokumentacji Sinona:
Causes Sinon to replace the global setTimeout, clearTimeout, setInterval, clearInterval, setImmediate, clearImmediate, process.hrtime, performance.now (when available) and Date with a custom implementation which is bound to the returned clock object.
Pierwszy problem widzę już w samej treści dokumentacji Sinona. Zgodnie z przedstawionym fragmentem dokumentacji Sinon modyfikuje globalne obiekty w runtime. Takie podejście określane jest mianem monkey patchingu i w mojej opinii stanowi antywzorzec. Modyfikowanie natywnych komponentów języka to według mnie proszenie się o kłopoty. Modyfikacja obiektów nie tylko sprawia, że inny programista może nie być świadom, że pracuje ze zmodyfikowanym obiektem, ale też może znacznie utrudniać proces szukania i łatania potencjalnych błędów w kodzie.
Dodatkowym problemem, jaki widzę w tym podejściu, jest złamanie zasady Isolation z reguł testów FIRST. Nie będzie to zauważalne w momencie, gdy testy uruchamiane są sekwencyjnie. Problem powstanie przy próbie równoległego uruchomienia testów. Takim sposobem uruchamiania testów charakteryzuje się np. test runner ava. Użycie metody useFakeTimers()
w jednym teście będzie mogło mieć wpływ na rezultat innego testu.
Przykład testów wykorzystujących podejście z użyciem Sinona przedstawia poniższy fragment kodu. Dla czytelności przykładu nie opakowywałem testów dla poszczególnych metod w osobne describe
.
const assert = require( 'assert' );
const sinon = require( 'sinon' );
const ExampleClass = require('../src/ExampleClass');
describe( 'ExampleClass', () => {
const obj = new ExampleClass();
let clock;
beforeEach( () => {
clock = sinon.useFakeTimers();
} );
afterEach( () => {
clock.restore();
} );
it( 'createUser(): should return created user', () => {
const name = 'joe';
const createdAt = new Date();
const createdUser = obj.createUser( name );
assert.deepEqual( createdUser, { name, createdAt: createdAt.toISOString() } );
} );
it( 'getExpirationDate(): should return date n days after the passed date', () => {
const days = 30;
const expirationDate = obj.getExpirationDate( days );
assert( expirationDate.getTime() === days * 24 * 60 * 60 * 1000 );
} );
} );
Dzięki wykorzystaniu useFakeTimers()
przygotowanie dobrej asercji jest stosunkowo proste. Każdy nowy obiekt Date
będzie zwracał datę o identycznej wartości (w przedstawionym fragmencie kodu zwróci datę o wartości new Date(0)
).
Rekomendowane podejście
Zamiast modyfikować globalny obiekt Date
, można wykorzystać wzorzec Dependency Injection i przygotować zależność, która dostarczy obiekt Date
o oczekiwanej wartości. Wykorzystanie Dependency Injection umożliwia wstrzyknięcie innego obiektu w kodzie źródłowym a innego w testach. Sama zależność nie jest skomplikowana, a jej struktura znacznie zwiększa elastyczność programisty.
class DateProvider {
getDate() {
return new Date();
}
}
Wykorzystanie klasy DateProvider
w kodzie testowanej klasy wygląda następująco:
const DateProvider = require('./DateProvider')
class ExampleClass {
constructor( dateProvider = new DateProvider() ) {
this._dateProvider = dateProvider;
}
getExpirationDate( days ) {
return new Date( this._dateProvider.getDate().getTime() + days * 24 * 60 * 60 * 1000 );
}
createUser( name ) {
return {
name, createdAt: this._dateProvider.getDate().toISOString()
}
}
}
Dzięki wstrzyknięciu obiektu klasy DateProvider
możliwe jest zastąpienie go w testach zmienioną implementacją.
class TestDateProvider {
constructor( timestamp = 0 ) {
this._date = new Date( timestamp );
}
getDate() {
return this._date;
}
setDate ( timestamp ) {
this._date = new Date( timestamp );
}
}
W przeciwieństwie do DateProvider
obiekt klasy TestDateProvider
operuje na statycznej dacie, podobnie jak w przypadku zastosowania Sinona. W tym podejściu nie jest modyfikowany globalny obiekt Date
a jedynie podmieniana implementacja wstrzykiwanej zależności. Po wprowadzonych modyfikacjach testy dla przygotowanych metod wyglądają następująco:
const assert = require( 'assert' );
const ExampleClass = require('../src/ExampleClass');
const TestDateProvider = require( './TestDateProvider' );
describe( 'ExampleClass', () => {
const testDateProvider = new TestDateProvider();
const obj = new ExampleClass( testDateProvider );
it( 'createUser(): should return created user', () => {
const name = 'joe';
const createdUser = obj.createUser( name );
const createdAt = testDateProvider.getDate();
assert.deepEqual( createdUser, { name, createdAt: createdAt.toISOString() } );
} );
it( 'getExpirationDate(): should return date n days after the passed date', () => {
const days = 30;
const expirationDate = obj.getExpirationDate( days );
assert( expirationDate.getTime() === days * 24 * 60 * 60 * 1000 );
} );
} );
Moim zdaniem wygląda to znacznie ładniej niż kod z Sinonem. Do tego jest bezpieczniejsze z punktu widzenia izolacji testów. W podobny sposób można zaprojektować abstrakcje dla pozostałych metod i klas, które modyfikuje Sinon. Kolejną zaletą wykorzystania dodatkowej warstwy abstrakcji jest możliwość wprowadzania w niej zmian, które będą miały natychmiastowe odzwierciedlenie w całym systemie. W przeciwieństwie do monkey patchingu nie dotyka to natywnych komponentów języka i dzieje się na osobnym poziomie abstrakcji co zwiększa przejrzystość.
Podsumowanie
Jestem ogromnie ciekaw, co sądzisz o opisanym przeze mnie problemie i jego rozwiązaniu. Czy również uważasz monkey patching robiony przez Sinona za problem?
Sam korzystam z takiego podejście od kilku lat i nie widzę w nim istotnych wad. Widzę natomiast mnóstwo zalet. Zachęcam do przetestowania proponowanego przeze mnie podejścia w praktyce.
Zachęcam też do zapoznania się z materiałami dodatkowymi i źródłami.
Materiały dodatkowe i źródła
- Sinon.JS – Standalone test spies, stubs and mocks for JavaScript
- Fake timers – Sinon.JS
- TypeScript Stub Date and timer friends functions with sinon
- How do I stub new Date() using sinon?
- Is „monkey patching” really that bad?
- Monkey patching: the good or bad?
- Monkey patching in NodeJS
- The Case Against Monkey Patching, From a Rails Core Team Member
- avajs – Node.js test runner that lets you develop with confidence
Zapisz się na mailing i odbierz e-booka
Odbierz darmowy egzemplarz e-booka 106 Pytań Rekrutacyjnych Junior JavaScript Developer i realnie zwiększ swoje szanse na rozmowie rekrutacyjnej! Będziesz też otrzymywać wartościowe treści i powiadomienia o nowych wpisach na skrzynkę e-mail.