Angular udostępnia mechanizmy pozwalające korzystać ze wzorca wstrzykiwania zależności (ang. dependency injection) (jedna z technik rozwiązywania zależności). Niestety, materiałów jak zaimplementować poprawnie zasady SOLID w frameworku stworzonym przez Google, jest jak na lekarstwo.

Zainspirowany prelekcją na temat testów jednostkowych w Angularze, na wrocławskiej grupie ng-wroclaw, postanowiłem napisać artykuł na temat zarówno testów jak i Dependency Injection.

Link do repozytorium jest tutaj

Kontekst

Załóżmy, że modelujemy system, w którym występują klienci (ang. customers). Możemy pobrać jednego klienta po jego id lub ich listę.

Przykłądowy model będzie więc wyglądać tak:  

export class Customer {
    public id: number;

    constructor(init?: Partial<Customer>) {
        Object.assign(this, init);
    }
}

Oczywiście by pobrać dane, należy stworzyć odpowiedni service:

@Injectable()
export class CustomerService {
    constructor() { }
}

na razie nie jest ważne skąd weźmiemy dane, dlatego nie wstrzykuję HttpClient.

Kontrakt

W SOLID'nym świecie backendowym kontrakt jak i dependency inversion rozwiązuje się za pomocą  interfejsów, które są następnie implementowane przez konretne klasy. Ostatecznie w kodzie operujemy typem interfejsu np.:

// c#
public interface ICalc
    {
        double Calc(double a, double b);
    }

public class HumanCalc: ICalc{
    public double Calc(double a, double b)
    {
        return a + b + 1;
    }
}

.
.
.

ICalc calc = new HumanCalc();

Niestety w TypeScript interface nie jest tym samym co w językach takich jak c# czy java. W javascripcie interfejs nie istnieje :( .
Na szczęście istnieje inne pojęcie, mianowicie chodzi o klase abstrakcyjną. Dzięki niej można odwzorować zachowanie interfejsów i ich implementacji.

Implementacja

Wracając do CustomerService, klasa abstrakcyjna może wyglądać tak:


export abstract class ICustomerService {
    abstract getCustomer(id: number): Observable<Customer>;
    abstract getCustomers(): Observable<Customer[]>;
}

VSCode również lubi takie podejście:

Uproszczona wersja service'u:

@Injectable()
export class CustomerService implements ICustomerService {
    customer = new Customer({ id: 1 });
    
    constructor() { }

    getCustomer(id: number): Observable<Customer> {
    // tutaj ląduje zapytanie np. http
        return of(this.customer);
    }

    getCustomers(): Observable<Customer[]> {
    // tutaj ląduje zapytanie np. http
        return of([this.customer]);
    }
}

Konfiguracja  modułu dla takiego service'u też jest prosta:

// app.module.ts

providers: [
{ provide: ICustomerService, useClass: CustomerService }
],

W ten sposób komponent używa typu ICustomerService, więc zmiana implementacji na np. UDPCustomerService będzie poprawna tak długo jak zostanie zostanie zachowany kontrakt (wszystkie metody zostaną poprawnie zaimplementowane) z ICustomerService.

Należy także pamiętać o wstrzyknięciu service'u do komponentu:

// customer.component.ts

constructor(private customerService: ICustomerService) {
}

Unit test

Jaka jest korzyść z zastosowania powyższej konstrukcji? Dlaczego w ten sposób warto pisać kod? Zacznę od początku. Odpalam test dla customer.component i widzę, że mój test nie działa. Szybki podgląd do konsoli i:


CustomerComponent should create FAILED
	Error: StaticInjectorError(DynamicTestModule)[CustomerComponent -> ICustomerService]: 
	  StaticInjectorError(Platform: core)[CustomerComponent -> ICustomerService]: 
	    NullInjectorError: No provider for ICustomerService!


okazuje się, że test module, nie został poprawnie skonfigurowany.
Chcąc naprawić błąd zacząłem od stworzenia mock service'u dla CustomerService:

export class CustomerMockService implements ICustomerService {
    private _customer: Customer

    constructor(customer: Customer) {
        this._customer = customer;
    }
    getCustomer(id: number): Observable<Customer> {
        return of(this._customer);
    }

    getCustomers(): Observable<Customer[]> {
        return of([this._customer]);
    }
}

Ostatnia część to konfiguracja test module:

// customer.component.spec.ts
.
.
.
  let customer = new Customer({ id: 1 });

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [CustomerComponent],
      providers: [
        { provide: ICustomerService, useValue: new CustomerMockService(customer) }
      ]
    })
      .compileComponents();
  }));
. 
. 
. 
  it('should get customer by id', () => {
    component.getCustomer(customer.id);
    fixture.detectChanges();
    expect(component.customer.id).toEqual(customer.id);
  });

Czas na finał:

Podsumowanie

Dependency injection jest przydatnym wzorcem, a gdy doda się do tego dependency inversion oraz kontrakty, pisanie aplikacji w angularze staje się przyjemniejsze. Dodatkowo podczas cyklu życia aplikacji, ktoś może stwierdzić, że pobieranie Customer'ów nie będzie odbywać się za pomocą zapytań REST lecz zostanie użyty inny mechanizm. Kontrakt zapewni to, że gdy zostanie podmieniony szczegół implementacyjny, aplikacja będzie działać tak samo jak wcześniej.

Przypisy