Przejdź do treści

Angular: Jak zamockować dane do usługi HTTP?

Mokowałeś kiedyś dane w projekcie Angularowym na wypadek braku komunikacji z back-endem? Jeśli nie to zapraszam Cię do lektury. W kilku krótkich krokach pokazuje Ci jak dodać do aplikacji (od samego początku) możliwość przepięcia zapytań HTTP na odpowiedzi ze statycznych plików JSON.

Zbudowanie projektu

Projekt, który będziemy budować dostępny jest na GitHubie: github.com/piecioshka/test-angular-mocks

Copy + paste

ng new test-angular-mocks
cd test-angular-mocks
# jeśli używasz npm v5+ to warto wykonać:
git add .
git commit package-lock.json -m "Add pkg lock file"
ng serve

Dzięki temu poleceniu, aplikacja zostanie zbudowana i wystawiona do przeglądania za pomocą przeglądarki na adresie: localhost:4200/.

Polecenie uruchamia narzędzie typu “server + watcher”, czyli:

  • server
    daje to, że aplikacja ciągle jest dostępna za pomocą browsera
  • watcher
    narzędzie nasłuchuje na zmiany w dowolnym pliku w projekcie

Niech ciągle będzie włączone non stop. Nie będzie to nam przeszkadzać, a tylko pomagać, ponieważ zawsze będziemy mieli dostęp do aplikacji zaserwowanej dla klienta końcowego.

Uwaga

Pamiętajcie, żeby dać CLI czas na zbudowanie aplikacji. Tym samym, po zapisaniu pliku i odczekaniu niewystarczającej liczby sekund, możesz mieć ciągle starą wersję aplikacji!

Krok 1: Nakreślenie architektury komunikacji z back-endem

Schemat 1.

Krok 2: Implementacja komunikacji między obiektami

Copy + paste

ng g service services/data
git commit -am 'Create directory for services'

Nie chcę od razu w nowym serwisie komunikować się z back-end.

Jeśli bym to zrobił, to nie mam możliwości stworzenia middleware-u, takim jakim jest np. proxy. Dlatego też stworzę nowy serwis który będzie odpowiedzialny za realizację zapytania HTTP.

DataService jest odpowiedzialny za pobranie danych. Jednak nie jest jasno powiedziane skąd te dane powinny pochodzić.

Uważam jest to kwestią samego DataService-u skąd będzie pobierał sobie dane. Może będzie pobierał je z kilku miejsce? Np. może sprawdzić na początku LocalStorage i jeśli w nim nie znajdzie to fallbackiem będzie request HTTP.

Stworzenie nowego serwisu, który będzie odpowiedzialny za komunikację z zewnętrznym API:

Copy + paste

ng g service services/rest
git add .
git commit -am 'Add RestService'

Krok 2.1: Łączenie serwisów

W projekcie istnieją 2 serwisy - należy je połączyć w taki sposób jak na pierwszym schemacie. Do DataService jest wstrzyknięty RestService.

Aby to zrobić należy w konstruktorze DataService zdefiniować zmienną prywatną restService:

import { Injectable } from '@angular/core';
import { RestService } from './rest.service';

@Injectable()
export class DataService {
    constructor(private restService: RestService) { }
}

Krok 2.2: Łączenie komponentu z serwisem

Podobnie jak w przypadku łączenia serwisów i w tym przypadku trzeba zdefiniować w konstruktorze klasy “oczekiwanie” na wybrany serwis.

Chcę doprowadzić do sytuacji nakreślonej w pierwszym schemacie, czyli komponent (w tym przypadku jedyny komponent czyli AppComponent) komunikuje się z DataService, który do dopiero wewnętrznie będzie komunikował się z RestService. Jednak ze względu na to, że DataService sam posiada zależność, to tą zależność trzeba zdefiniować w providerach komponentu AppComponent. W przeciwnym przypadku na stronie pojawi się komunikat

ERROR

ERROR Error: No provider for RestService!
Taki komunikat będzie się pojawiał w DevToolsach (zakładka Console) w aplikacji (localhost:4200/) albo w testach jednostkowych (localhost:9876/) jeśli nie zdefiniujesz wszystkich zależności modułu.

import { Component } from '@angular/core';
import { DataService } from './services/data.service';
import { RestService } from './services/rest.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
  providers: [
    DataService,
    RestService
  ]
})
export class AppComponent {
  title = 'app works!';

  constructor(private dataService: DataService) {
    this.dataService.fetch();
  }
}

W zakładce Console okna w którym uruchomiona jest aplikacja powinieneś zobaczyć:

Pusty obiekt stworzony na podstawie klasy `RestService`.

Jeśli jest inaczej, to gdzieś popełniłeś błąd.
Daj mi znać w komentarzu to z chęcią Ci pomogę.

Krok 3: Stworzenie zapytania HTTP

Definicja zapytania HTTP będzie w serwisie RestService. Takie też było jego przeznaczenie.

import { Injectable } from '@angular/core';
import { Http, Response } from '@angular/http';

import { Observable } from 'rxjs/Rx';

@Injectable()
export class RestService {

  constructor(private http: Http) {
  }

  makeRequest(url: string): Observable<Response> {
    return this.http.get(url)
      .catch((err) => {
        console.error('Request to "%s" failed', url);
        return Observable.throw(err);
      });
  }
}

Teraz aby wykorzystać nowo stworzoną funkcję to w DataService należy ją użyć:

import { Injectable } from '@angular/core';
import { RestService } from './rest.service';

@Injectable()
export class DataService {

  constructor(private restService: RestService) {
  }

  fetch(url: string) {
    return this.restService.makeRequest(url);
  }

}

Ostatni krok to uruchomić funkcję DataService#fetch, aby pobrać dane.

// ...
export class AppComponent {
  title = 'app works!';

  constructor(private dataService: DataService) {
    this.dataService.fetch('/')
      .subscribe((response) => {
        console.log(response);
      });
  }
}

Psst… W plikach z testami (*.spec.ts) należy zaimportować moduł Http, aby testy jednostkowe nie generowały błędów:

// ...
import { HttpModule } from '@angular/http';
// ...

TestBed.configureTestingModule({
  imports: [
    HttpModule
  ],
  // ...
}).compileComponents();

// ...

Krok 4: Pobranie realnych danych

Posłużymy się portalem dla programistów tj. GitHub-em i jego publicznym API skąd pobierzemy wszelkie publiczne dane na temat dowolnego użytkownika

Zgłaszam się na ochotnika!

W takim razie zaktualizujmy URLa do zasobu z którego pobieramy odpowiedź na następujący: api.github.com/users/piecioshka.

Wyświetlony obiekt zawiera odpowiedź z zapytania o publiczne dane nt. mojego konta na GitHubie. Treść (body) jest w formie tekstowej. Lepiej by było, gdyby to była forma JSONa. Trzeba zatem sparsować!

Od teraz będziemy posługiwali się tylko URL-ami do zasobów, które będę zwracać dane w formacie JSON, warto więc wykorzystać natywną funkcję Response#json(). Proponuję, aby w serwisie RestService dodać sparsowanie zwracanego obiektu typu Response na obiekt JavaScript-owy. Wszystko to da się zrealizować za pomocą kilku linijek:

// ...

@Injectable()
export class RestService {
  // ...

  makeRequest(url: string): Observable<Response> {
    return this.http.get(url)
      .map((res) => {
        return res.json();
      })
      // ...
  }
}

Krok 5: Prezentacja danych

Prezentacja to najprzyjemniejsza część w Angularze.

Na podstawie danych w komponencie za pomocą interpolacji albo dynamicznych atrybutów definiujesz w markupie co ma się wyświetlić użytkownikowi.

I tak plik: app.component.html zmienił się na taki:

<h1>
  { { title }}
</h1>

<img [src]="avatar_url" alt=""/>

Atrybut [src] jest w nawiasach kwadratowych ze względu na to, aby przeglądarka nie robiła request-u po obrazek, kiedy jeszcze właściwość komponentu (avatar_url) nie jest ustawiona.

Czas na główny plik, tj. komponent AppComponent - plik app.component.ts.

Na początku przechwytujemy podstawowe klucze, czyli: name i avatar_url. Podczas pobrania tych danych z obiektu response WebStorm podświetli, że w obiekcie tym nie znajdują się takie properties-y.

Aby rozwiązać problem z typami stworzyłem interfejs o nazwie np. GitHubProfileResponse. W którym zdefiniowałem 2 używane przeze mnie pola.

Oczywiście GitHub API zwraca nie tylko te pola, ale na ten moment nie ma sensu wypisywać wszystkich.

Hint: Nowy interfejs musi rozszerzać obiekt Response, ale uwaga! Nie natywny obiekt window.Response, tylko Angularowy. Stąd też importuje w pierwszej linijce obiekt Response.

import { Response } from '@angular/http';

// ...

interface GitHubProfileResponse extends Response {
  avatar_url: string;
  name: string;
}

// ...

export class AppComponent {
  title = '';
  avatar_url = '';

  constructor(private dataService: DataService) {
    this.dataService.fetch('https://api.github.com/users/piecioshka')
      .subscribe((response: GitHubProfileResponse): void => {
        this.title = response.name;
        this.avatar_url = response.avatar_url;
        console.log(response);
      });
  }
}

To tyle z aplikacji. Kiedy przeładujesz stronę (albo automat zrobi to za Ciebie, gdy będzie gotowy) to ujrzysz minimalistyczny mój profil GitHub-owy.

Z testów trzeba (plik app.component.spec.ts) trzeba usunąć 2 ostatnie testy, które sprawdzają istnienie stringa “app works!”. Ten ciąg znaków został usunięty z komponentu, aby nie pokazywał się na starcie przez ułamek sekundy (odpowiedź z GitHub API napisze właściwość title).

Krok 6: Mockowanie!

Wszystko pięknie działa. Z połączeniem do internetu. Teraz chciałbym ostylować mój komponent z kartą profilu. Ups… coś się stało… GitHub padł…

Tak, wiem. To rzadki przypadek ale może tak być. Co teraz? Nie mam danych, aby zasilić komponent więc nie wiem jak będzie wyglądał tekst i jak będzie komponował się przy nim obrazek! Wtopa.

Co należało zrobić kilka kroków wcześniej?

Warto było zapisać odpowiedź z GitHub API, aby teraz kontynuować pracę na poprawnym formacie danych.

Schemat 2.

Krok 6.1: Stworzenie pliku z poprawną odpowiedzią z GitHub API

Cofnijmy się do tego momentu, kiedy GitHub API jeszcze działał i zapiszmy z niego odpowiedź. Na początku stworzę plik src/app/mocks/piecioshka.json i wkleję do niego odpowiedź z GitHub API.

Krok 6.2. Nowe narzędzie: angular-in-memory-web-api

Zainstaluję narzędzie angular-in-memory-web-api. Oczywiście proces instalacji doda informację o paczce do dwóch plików:

  • package.json
  • package-lock.json

Warto stworzyć z tego rewizję.

Krok 6.3: Stworzenie serwisu, który będzie emulował zapytania HTTP

Za pomocą poniższego polecenia stworzę plik src/app/services/mock.service.ts, który będzie bratem serwisu RestService. Zgodnie z tym co jest zdefiniowane na drugiem schemacie.

Copy + paste

ng g service services/mock

W pliku (tak samo jak RestService) musi istnieć funkcja makeRequest, która będzie zwracała obiekt Observable.

import { Injectable } from '@angular/core';
import { Http, Response, ResponseOptions } from '@angular/http';

import { Observable } from 'rxjs/Rx';

@Injectable()
export class MockService {

  constructor(private http: Http) {
  }

  makeRequest(url: string): Observable<Response> {
    console.log('◉ Mock finished loading: GET "%s"', url);

    return this.http
      .get(url)
      .map((res) => {
        return res.json();
      })
      .catch((err) => {
        console.error('[Mock] Request to "%s" failed', url);
        return Observable.throw(err);
      });
  }
}

Krok 6.4: Wykorzystanie serwisu MockService

Teraz kiedy już mamy możliwość podmiany RestService na tzw. zaślepkowy to dodajmy możliwość podmiany w serwisie wraperze, czyli DataService:

import { Injectable } from '@angular/core';

import { RestService } from './rest.service';
import { MockService } from './mock.service';

@Injectable()
export class DataService {

  requestService: RestService | MockService;

  constructor(private restService: RestService,
              private mockService: MockService) {
    // this.useRestService();
    this.useMockService();
  }

  private useRestService() {
    this.requestService = this.restService;
  }

  private useMockService() {
    this.requestService = this.mockService;
  }

  fetch(url: string) {
    return this.requestService.makeRequest(url);
  }

}

Krok 6.5: Stworzenie serwisu do zarządzania pamięcią (in-memory)

W katalogu z mockami należy stworzyć plik src/app/mocks/in-memory.service.ts, w którym będzie definicja wszystkich mockowanych requestów.

Na ten moment jest tylko jeden, który kieruje do pliku obok z rozszerzeniem *.json.

import { InMemoryDbService } from 'angular-in-memory-web-api';

export class InMemoryService implements InMemoryDbService {
  createDb() {
    return {
      piecioshka: require('./piecioshka.json'),
    };
  }
}

Krok 6.6: Routing

Wszystko fajnie, ale aplikacja dalej nie ma pojęcia o mockach. Należy połączyć główny moduł aplikacji.

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpModule } from '@angular/http';

import { AppComponent } from './app.component';
import { InMemoryService } from './mocks/in-memory.service';

// Paczka, generuje problemy podczas importowania, dlatego zmieniamy ma require.
const mem = require('angular-in-memory-web-api');
const InMemoryWebApiModule = mem.InMemoryWebApiModule;

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    FormsModule,
    HttpModule,

    // INFO(piecioshka): routing dla mocków
    InMemoryWebApiModule.forRoot(InMemoryService, {
      // Flaga powoduje, że pomimo tego, że są włączone mocki, to
      // kiedy routing nie znajdzie URLa w swojej mapie
      // zmokowanych URLi to wyśle zwykły request HTTP.
      passThruUnknownUrl: true
    })
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Krok 6.7: Problem z data

Odpowiedź z mocka jest w kluczu data. Należy to oczyścić! W pliku mock.service.ts należy wpiąć się w chaining:

    // ...
    return this.http
      .get(url)
      .map((res) => {
        const original = res.json().data;
        return new Response(new ResponseOptions({ body: original }));
      })
      .map((res) => {
        return res.json();
      })
    // ...

Krok 6.8: Problem z require

Ze względu na to, że domyślna konfiguracja projektu nie pozwala na korzystanie z funkcji require (nie jest znany taki typ, tym samym kompilacja TypeScripta krzyczy) należy dodać do pliku z konfiguracją, tj. src/tsconfig.app.json:

// ...
"types": ["node"]
// ...

Krok 6.9: Problem z testami

Na zakończenie rozwiążę problem: Cannot get name of null, który pojawia się po uruchomieniu testów jednostkowych.

W pliku app.component.ts należy przenieść wywołanie zapytania z konstruktora do nowej funkcji ngOnInit. Jest to funkcja w interfejsie OnInit. Funkcja ta zostanie uruchomiona w odpowiednim czasie.

Wskazówka

Wszystkie funkcje z prefiksem ng* jest to wewnętrzna nomenklatura Angulara dzięki której w szybki sposób wiemy po co jest dana funkcja.

Funkcja ngOnInit uruchomi się kiedy komponent będzie gotowy. W tym momencie uruchomi się hook (zdarzenie znane z Gita), który wywoła funkcję ngOnInit zdefiniowaną w komponencie.

Prezentacja komponentu, który jest zasilany mockiem. Cel osiągnięty 🎉

Zakończenie

Na zakończenie dodam trochę słów kluczowych, dzięki którym developerzy szukający w zakamarkach internetu przewodnika jak dodać mockowanie odpowiedzi dla usługi HTTP powinni przyjść do tego artykułu. Sam szukałem kilkadziesiąt minut jak poradzić sobie z tym zagadnieniem i nie znalazłem instrukcji mówiącej wprost jak należy sobie poradzić z mockowaniem.

Porady, dla programistów WebStorma

Jak Ci dobrze wiadomo WebStorm indeksuje pliki projektu w celu łatwego i szybkiego nawigowania po plikach. Nie ma żadnego powodu, aby WebStorm indeksował katalog node_modules/. Z tego powodu wyklucz ten katalog klikając na niego prawym przyciskiem myszy i wybierz opcję: Mark Directory as → Excluded.

Katalog node_modules/ powinien zaświecić się na czerwono. Jeśli nie zmienił swojego koloru to prawdopodobnie jest on uznany przez WebStorma za katalog z bibliotekami. Oznacza to, że została przyznana mu dodatkowa rola, która nie pozwala na to, aby stał się excludowany z procesu indeksowania.

Aby wyłączyć dodatkową rolę katalogu node_modules/ należy wejść do ustawień projektu: Preferences → Languages & Frameworks → JavaScript → Libraries, a następnie odznaczyć katalog node_modules/. Teraz na pewno wymieniony przeze mnie katalog będzie określony czerwonym kolorem. Świadczy to o tym, że ten katalog nie będzie brał udziału w indeksowaniu.

Coś za coś. WebStorm działa szybciej (nie ma tylu plików w pamięci), ale za to nie podkreśla znanej składni. I tak:

  • Koloruje na czerwono nazwy funkcji: expect, it, describe w plikach *.spec.ts:
    Aby wyeliminować ten problem kliknij prawym przyciskiem myszy na katalog node_modules/@types a następnie wybierz opcję Mark Directory As → Not Excluded. Tym samym WebStorm przeindeksuje pliki z definicjami typów. Słowa kluczowe w spec-ach powinny po chwili posiadać kolor typowy dla nich.
  • Koloruje na czerwono nazwy funkcji: catch i map podczas korzystania z Observable:
    Przy zapytaniu HTTP będziemy korzystali z funkcji, które zwracają Observable. Dlatego też trzeba dołączyć odpowiednie definicje typów aby WebStorm zaskoczył, że funkcja catch, czy map pochodzą z tego obiektu. Aby to zrobić trzeba ponowić takie same kroki jak przy pierwszym punkcie, tylko jako katalog trzeba wybrać node_modules/rxjs.

Rekomendacja

Jeśli szukasz jakiegoś publicznego API, które zwraca dowolne dane (z dowolnej kategorii tematycznej, np. filmy, finanse) to polecam skorzystać z repozytorium autorstwa Todda Motto - github.com/toddmotto/public-apis.