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
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ć:
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.
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.
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.
•
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:
-
- Koloruje na czerwono nazwy funkcji:
catch
imap
podczas korzystania zObservable
: - 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 funkcjacatch
, czymap
pochodzą z tego obiektu. Aby to zrobić trzeba ponowić takie same kroki jak przy pierwszym punkcie, tylko jako katalog trzeba wybraćnode_modules/rxjs
.
- Koloruje na czerwono nazwy funkcji:
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.