Daty w testach jednostkowych - okładka

Daty w testach jednostkowych w JavaScript

Opublikowano Kategorie JavaScriptCzas czytania 7min

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 Isolationreguł 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

Dominik Szczepaniak

Zawodowo Senior Software Engineer w CKSource. Prywatnie bloger, fan włoskiej kuchni, miłośnik jazdy na rowerze i treningu siłowego.

Inne wpisy, które mogą Cię zainteresować

Zapisz się na mailing i odbierz e-booka

Zapisując się na mój mailing, otrzymasz darmowy egzemplarz e-booka 106 Pytań Rekrutacyjnych Junior JavaScript Developer! Będziesz też otrzymywać wartościowe treści i powiadomienia o nowych wpisach na skrzynkę e-mail.

Subscribe
Powiadom o
guest

0 komentarzy
Inline Feedbacks
View all comments