Перейти к содержанию

Unit-тестирование. Компоненты. Практические примеры

Компонент с вызовом асинхронного метода

Практически в каждом приложении имеются компоненты, которые зависимы от сервисов, которые хранят асинхронные методы, инициирующие HTTP-запросы к удаленному серверу и возвращающие определенные данные.

Поскольку основное назначение unit-тестов проверка не работоспособности API, а результата преобразования и (или) отображения полученных данных, то вызовы этих методов и их данных эмулируются через константы или Spy объекты.

info-message.component.ts

@Component({
  selector: 'info-message',
  template: `
    <h1>Attention!</h1>

    <p>{{ appService.message }}</p>
  `
})
export class InfoMessageComponent {
  constructor(public appService: AppService) {}
}

info-message.component.spec.ts

describe('InfoMessageComponent', () => {
  let fixture: ComponentFixture<InfoMessageComponent>

  beforeEach(() => {
    const appServiceStub = { message: 'Out of service' }

    TestBed.configureTestingModule({
      declarations: [InfoMessageComponent],
      providers: [{ provide: AppService, useValue: appServiceStub }]
    })

    fixture = TestBed.createComponent(InfoMessageComponent)
  })

  it('should get message from AppService stub', () => {
    fixture.detectChanges()
    const infoMessageEl: HTMLElement = fixture.debugElement.nativeElement
    const p = infoMessageEl.querySelector('p')
    expect(p.textContent).toContain('Out of service')
  })
})

Не используйте реальные сервисы в тестах компонентов. Их внедрение может оказаться крайне сложным, или вообще невозможным, поскольку в сервисах могут быть проверки, которые пройдут только в реально работающем приложении.

В приведенном примере Angular тестирования константа appService предоставляет все необходимые данные и методы для их преобразования подобно тому, как это делает реальный сервис.

Для эмуляции асинхронных методов сервисов лучше подойдут Spy объекты.

app.service.ts

@Injectable({providedIn: 'root'})
export class AppService {

    constructor(private http: HttpClient) {}

    getData(): Observable<any>{
    return this.http.get('/api/data);
    }
}

app.service.spec.ts

describe('InfoMessageComponent', () => {
  let fixture: ComponentFixture<InfoMessageComponent>
  let appService

  beforeEach(() => {
    TestBed.configureTestingModule({
      declarations: [InfoMessageComponent],
      providers: [AppService]
    })

    fixture = TestBed.createComponent(InfoMessageComponent)
    appService = jasmine.createSpyObj('AppService', { getData: 'Out of service' })
  })

  it('should get message from AppService getData()', () => {
    const comp = fixture.componentInstance
    comp.message = appService.getData()
    expect(comp.message).toBe('Out of service')
  })
})

Объект Spy позволяет эмулировать обращение к асинхронному методу (название). Но сам тест выполняется синхронно, внутри него не выполняется никаких асинхронных действий.

При использовании в тестах Angular компонентов сервисов следует помнить, что Angular имеет иерархическое построение injector-ов. Так, если сервис был определен на уровне компонента, то при тестировании он должен быть взят не из корневого injector-а, а из injector-а самого компонента.

appService = fixture.debugElement.injector.get(AppService)

Но если сервис определен именно в модуле, т. е. находится в корневом injector-е, то можно использовать более простой в восприятии способ получения сервиса с использованием TestBed.get().

appService = TestBed.get(AppService)

Для асинхронности теста используйте функцию fakeAsync() библиотеки @angular/core/testing.

describe('InfoMessageComponent', () => {
  let fixture: ComponentFixture<InfoMessageComponent>
  let appService

  beforeEach(() => {
    TestBed.configureTestingModule({
      declarations: [InfoMessageComponent],
      providers: [AppService]
    })

    fixture = TestBed.createComponent(InfoMessageComponent)
    appService = jasmine.createSpyObj('AppService', { getData: 'Out of service' })
  })

  it('should get message from AppService getData()', fakeAsync(() => {
    const comp = fixture.componentInstance

    setTimeout(() => (comp.message = appService.getData()), 180)

    tick(180)

    expect(comp.message).toBe('Out of service')
  }))
})

Важным здесь является функция tick(), без которой использование fakeAsync() было бы бессмысленным. В качестве аргумента она принимает количество миллисекунд, на которое выполнение теста приостановиться. Так, в примере дальнейшие операции в тесте зависимы от вызова асинхронного метода (название), который выполняется за 180 миллисекунд.

Но данный подход не подойдет, если вы не знаете точное время исполнения метода. Самый простой пример - обращение к удаленному API, где время исполнения зависит от множества неконтролируемых факторов: стабильность соединения, пропускная способность канала, нагрузка на сервер и т. д. Здесь необходимо использовать функцию async().

info-message.component.ts

constructor(public appService: AppService){}

message: string = '';

ngOnInit(){
    this.appService.getData().subscribe(message => this.message = message);
}

info-message.component.spec.ts

describe('InfoMessageComponent', () => {
  let fixture: ComponentFixture<InfoMessageComponent>

  beforeEach(() => {
    TestBed.configureTestingModule({
      declarations: [InfoMessageComponent],
      providers: [AppService]
    })

    fixture = TestBed.createComponent(InfoMessageComponent)
  })

  it('should get message from AppService getData()', async(() => {
    const comp = fixture.componentInstance
    fixture.detectChanges() //Вызов ngOnInit()

    fixture.whenStable().then(() => {
      expect(comp.message).toBe('Out of service')
    })
  }))
})

async() запускает тест в специальной среде исполнения. Но главное здесь - метод whenSable(), возвращающий объект Promise, который выполнится после того, как очередь задач JavaScript станет пустой, т. е. будут исполнены все асинхронные и синхронные действия. Здесь отпадает необходимость в знании точного времени исполнения.

Взаимодействие компонентов

Когда вы передаете данные из одного компонента в другой через @Output() свойство, вам понадобится в тесте получить доступ к двум компонентам одновременно. Пример тестирования для такого случая.

parent.component.ts

@Component({
  selector: 'parent-component',
  template: `
    <child-component (message)="setMessage($event)"></child-component>
  `
})
export class ParentComponent {
  message: string = ''

  constructor() {}

  setMessage(text): void {
    this.message = text
  }
}

child.component.ts

@Component({
  selector: 'child-component',
  template: `
    <div class="child">
      <button (click)="sendMessage()">Send message</button>
    </div>
  `
})
export class ChildComponent {
  @Output() message: EventEmitter<any> = new EventEmitter<any>()

  constructor() {}

  sendMessage(): void {
    this.message.emit('Child message')
  }
}

parent.component.spec.ts

describe('ParentComponent', () => {
  let fixture: ComponentFixture<ParentComponent>
  let parentComp: ParentComponent

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ParentComponent, ChildComponent]
    })
      .compileComponents()
      .then(() => {
        fixture = TestBed.createComponent(ParentComponent)
        parentComp = fixture.componentInstance
      })
  }))

  it('should get message from ChildComponent', () => {
    const childEl: HTMLElement = fixture.debugElement.nativeElement.query('.child')
    childEl.click()

    expect(parentComp.message).toBe('Child message')
  })
})

Основное внимание здесь нужно сосредоточить на блоке beforeEach(). При конфигурации модуля TestingModule в части providers необходимо указать все компоненты, к которым происходит обращение в процессе тестирования. При этом явно создается именно экземпляр класса того компонента, который является родительским ко всем другим.

Доступ к дочерним компонентам осуществляется с помощью селекторов, применяемых относительно свойства nativeElement объекта debugElement с использованием функции querySelector(). В качестве селектора следует использовать id-атрибуты или классы, которые однозначно идентифицируют нужный компонент. Метод detectChanges() вызывается для привязки переданных значений в HTML-шаблоне.

Задать значения свойств дочернего компонента без явного создания экземпляра класса не получится.

Организация кода

Даже при тестировании небольшого компонента часто приходится производить много манипуляций с его свойствами или осуществлять выборку HTML-элементов по селекторам в пределах шаблона.

Поэтому для упрощения Angular тестирования и концентрации в одном месте методов, реализующих наиболее часто используемый функционал, общепринято создавать вспомогательный класс Page.

page.class.ts

class Page {
  get links() {
    return this.queryAll<HTMLElement>('a')
  }

  get inputs() {
    return this.query<HTMLInputElement>('input')
  }

  fixture: ComponentFixture<TestComponent>

  constructor(fixture: ComponentFixture<TestComponent>) {
    this.fixture = fixture.componentInstance
  }

  private query<T>(selector: string): T {
    return this.fixture.nativeElement.querySelector(selector)
  }

  private queryAll<T>(selector: string): T[] {
    return this.fixture.nativeElement.querySelectorAll(selector)
  }
}

При написании сценариев тестирования экземпляр класса Page создается в блоке beforeEach().

import { Page } from './page.ts'

beforeEach(async(() => {
  TestBed.configureTestingModule({
    declarations: [TestComponent]
  })
    .compileComponents()
    .then(() => {
      fixture = TestBed.createComponent(TestComponent)
      page = new Page(fixture)
    })
}))