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


 
  
  
 
Przygotuj się lepiej do rozmowy o pracę!
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.
Dlaczego warto?
E-booka odbierzesz korzystając z formularza poniżej 👇