4

I have the following Angular 2.0.0 component:

import { Component, OnInit } from '@angular/core';
import { Http } from '@angular/http';

@Component({
  selector: 'app-book-list',
  templateUrl: './book-list.component.html',
  styleUrls: ['./book-list.component.css']
})
export class BookListComponent implements OnInit {
  books: any;

  constructor(private http: Http) { }

  ngOnInit() {
    this.http.get('/api/books.json')
      .subscribe(response => this.books = response.json());
  }

}

How would I test the ngOnInit() function?

I don't want to include the test I've tried so far because I suspect I'm way off the right track and I don't want to bias the answers.

Paul Samsotha
  • 205,037
  • 37
  • 486
  • 720
Jason Swett
  • 43,526
  • 67
  • 220
  • 351

2 Answers2

1

To mock Http, you need to configure the MockBackend with the Http provider in the TestBed. Then you can subscribe to its connections an provide mock responses

beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        {
          provide: Http, useFactory: (backend, options) => {
            return new Http(backend, options);
          },
          deps: [MockBackend, BaseRequestOptions]
        },
        MockBackend,
        BaseRequestOptions
      ]
    });
});

How would I test the ngOnInit() function?

The problem is the asynchronous nature of the Http.get call. ngOnInit will be called when you call fixture.detectChanges(), but the the asynchronous nature of Http causes the test to run before the Http is finished. For that we an use fakeAsync, as mentioned here, but the next problem is that you can't use fakeAsync and templateUrl. You can hack it with a setTimeout and test in there. That would work. I personallu don't like it though.

it('', async(() => {
  setTimeout(() => {
     // expectations here
  }, 100);
})

Personally, I think you have a design flaw to begin with. The Http calls should be abstracted into a service, and the component should interact with the service, not directly with the Http. If you change your design to this (which I would recommend), then you can test the service like in this example, and for the component testing, create a synchronous mock, as mentioned in this post.

Here's a complete example of how I would do it

import { Component, OnInit, OnDestroy, DebugElement, Injectable } from '@angular/core';
import { CommonModule } from '@angular/common';
import { By } from '@angular/platform-browser';
import { Http, BaseRequestOptions, Response, ResponseOptions } from '@angular/http';
import { MockBackend, MockConnection } from '@angular/http/testing';
import { async, fakeAsync, inject, TestBed } from '@angular/core/testing';
import { Observable } from 'rxjs/Observable';
import { Subscription } from 'rxjs/Subscription';

@Injectable()
class BooksService {
  constructor(private http: Http) {}

  getBooks(): Observable<string[]> {
    return this.http.get('')
      .map(res => res.json() as string[]);
  }
}

class MockBooksService {
  subscription: Subscription;
  content;
  error;

  constructor() {
    this.subscription = new Subscription();
    spyOn(this.subscription, 'unsubscribe');
  }
  getBooks() {
    return this;
  }
  subscribe(next, error) {
    if (this.content && next && !error) {
      next(this.content);
    }
    if (this.error) {
      error(this.error);
    }
    return this.subscription;
  }
}

@Component({
  template: `
    <h4 *ngFor="let book of books">{{ book }}</h4>
  `
})
class TestComponent implements OnInit, OnDestroy {
  books: string[];
  subscription: Subscription;

  constructor(private service: BooksService) {}

  ngOnInit() {
    this.subscription = this.service.getBooks().subscribe(books => {
      this.books = books;
    });
  }

  ngOnDestroy() {
    this.subscription.unsubscribe();
  }
}

describe('component: TestComponent', () => {
  let mockService: MockBooksService;

  beforeEach(() => {
    mockService = new MockBooksService();

    TestBed.configureTestingModule({
      imports: [ CommonModule ],
      declarations: [ TestComponent ],
      providers: [
        { provide: BooksService, useValue: mockService }
      ]
    });
  });

  it('should set the books', () => {
    mockService.content = ['Book1', 'Book2'];
    let fixture = TestBed.createComponent(TestComponent);
    fixture.detectChanges();

    let debugEls: DebugElement[] = fixture.debugElement.queryAll(By.css('h4'));
    expect(debugEls[0].nativeElement.innerHTML).toEqual('Book1');
    expect(debugEls[1].nativeElement.innerHTML).toEqual('Book2');
  });

  it('should unsubscribe when destroyed', () => {
    let fixture = TestBed.createComponent(TestComponent);
    fixture.detectChanges();
    fixture.destroy();
    expect(mockService.subscription.unsubscribe).toHaveBeenCalled();
  });
});

describe('service: BooksService', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        {
          provide: Http, useFactory: (backend, options) => {
            return new Http(backend, options);
          },
          deps: [MockBackend, BaseRequestOptions]
        },
        MockBackend,
        BaseRequestOptions,
        BooksService
      ]
    });
  });

  it('should return mocked content',
    async(inject([MockBackend, BooksService],
                 (backend: MockBackend, service: BooksService) => {

    backend.connections.subscribe((conn: MockConnection) => {
      let ops = new ResponseOptions({body: '["Book1", "Book2"]'});
      conn.mockRespond(new Response(ops));
    });

    service.getBooks().subscribe(books => {
      expect(books[0]).toEqual('Book1');
      expect(books[1]).toEqual('Book2');
    });
  })));
});
Community
  • 1
  • 1
Paul Samsotha
  • 205,037
  • 37
  • 486
  • 720
  • I accepted your answer because it led me to a solution, although what I ultimately ended up doing was something substantially different. I added my own answer so people can see the code I ended up with. – Jason Swett Sep 21 '16 at 14:06
1

I actually ended up doing something a little different.

Per peeskillet's advice, I refactored my code to use a service. Here's what the service looks like.

// src/app/book.service.ts

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

@Injectable()
export class BookService {

  constructor(private http: Http) { }

  getList() {
    return this.http.get('/api/books.json');
  }
}

And here's the modified BookListComponent that uses the service.

// book-list.component.ts

import { Component, OnInit } from '@angular/core';
import { BookService } from '../book.service';

@Component({
  selector: 'app-book-list',
  templateUrl: './book-list.component.html',
  styleUrls: ['./book-list.component.css'],
  providers: [BookService]
})
export class BookListComponent implements OnInit {
  books: any;

  constructor(private bookService: BookService) { }

  ngOnInit() {
    this.bookService.getList()
      .subscribe(response => this.books = response.json());
  }

}

Lastly, here's the working test.

// book-list.component.spec.ts

/* tslint:disable:no-unused-variable */

import { TestBed, async } from '@angular/core/testing';
import { MockBackend } from '@angular/http/testing';
import { Observable } from 'rxjs/Observable';

import {
  Http,
  Response,
  ResponseOptions,
  BaseRequestOptions,
  ConnectionBackend
} from '@angular/http';

import { BookListComponent } from './book-list.component';
import { BookService } from '../book.service';

describe('Component: BookList', () => {
  let fixture;
  let component;
  let bookService;
  let spy;
  let testList;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        MockBackend,
        BaseRequestOptions,
        {
          provide: Http,
          useFactory: (backend: MockBackend, defaultOptions: BaseRequestOptions) => {
            return new Http(backend, defaultOptions);
          },
          deps: [MockBackend, BaseRequestOptions]
        },
        BookService
      ],
      declarations: [BookListComponent]
    });

    fixture = TestBed.createComponent(BookListComponent);
    component = fixture.debugElement.componentInstance;

    bookService = fixture.debugElement.injector.get(BookService);

    let observable: Observable<Response> = Observable.create(observer => {
      let responseOptions = new ResponseOptions({
        body: '[{ "name": "Whiteboard Interviews" }]'
      });

      observer.next(new Response(responseOptions));
    });

    spy = spyOn(bookService, 'getList')
      .and.returnValue(observable);
  });

  it('should create an instance', () => {
    expect(component).toBeTruthy();
  });

  it('should return a response', () => {
    fixture.detectChanges();
    expect(component.books).toEqual([
      { 'name': 'Whiteboard Interviews' }
    ]);
  });
});
Jason Swett
  • 43,526
  • 67
  • 220
  • 351
  • I'm not completely sure, but I wanna say that there is still a possible race condition. I'm no expert with observables, but is how you are creating the observable causing it to be synchronous? It kinda looks like it me. – Paul Samsotha Sep 21 '16 at 14:31
  • I believe the key is `detectChanges()`. My understanding is that it causes the observable to resolve. Could be wrong - I'm new to this stuff. – Jason Swett Sep 21 '16 at 16:25