Меню ×
Angular тестирование component с помощью Jest

Angular тестирование component с помощью Jest

Тестирование компонентов в Angular формально является задачей тестирования двух сущностей: Html шаблона и Typescript класса. И адекватное тестирование компонента заключается проверке корректной работы шаблона и класса.

Angular тестирование component с помощью Jest

При тестировании компонента необходимо иметь доступ к DOM и поэтому необходимо использовать TestBed, который позволит создать тестовый компонент в Angular, которому будут доступны DI.

Создадим компонент, который будет отображать сообщение в зависимости нажата была кнопка или нет:

lightswitch.component.html

	
<div>
  <button (click)="clicked()">Click me!</button>
  <span>{{ message }}</span>
</div>
	

lightswitch.component.ts

	
import { ChangeDetectionStrategy, Component } from '@angular/core';

@Component({
  selector: 'medium-stories-lightswitch',
  templateUrl: './lightswitch.component.html',
  styleUrls: ['./lightswitch.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class LightswitchComponent {
  isOn = false;

  clicked() {
    this.isOn = !this.isOn;
  }

  get message() {
    return `The light is ${this.isOn ? 'On' : 'Off'}`;
  }
}
	

И так как класс не имеет зависимостей, его можно тестировать как сервис, простым созданием:

lightswitch.component.spec.ts

	
import { LightswitchComponent } from './lightswitch.component';

describe('LightswitchComponent', () => {
  it('#clicked() should toggle #isOn', () => {
    const comp = new LightswitchComponent();
    expect(comp.isOn).toBeFalsy();
    comp.clicked();
    expect(comp.isOn).toBeTruthy();
    comp.clicked();
    expect(comp.isOn).toBeFalsy();
  });

  it('#clicked() should set #message to "is on"', () => {
    const comp = new LightswitchComponent();
    expect(comp.message).toMatch(/is off/i);
    comp.clicked();
    expect(comp.message).toMatch(/is on/i);
  });
});
	

Простые компоненты с Input/Output

Компоненты, которые содержат Input’ы и Output’ы также легко тестировать. Создадим компонент, который получает на вход объект, а при клике на кнопку отдаёт этот объект родителю:

dashboard-hero.component.html

	
<div>
  <button (click)="onSelectHero()">Select hero</button>
</div>
	

dashboard-hero.component.ts

	
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';

export interface Hero {
  /**
   * Hero id
   */
  id: number;

  /**
   * Hero name
   */
  name: string;
}

@Component({
  selector: 'medium-stories-dashboard-hero',
  templateUrl: './dashboard-hero.component.html',
  styleUrls: ['./dashboard-hero.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class DashboardHeroComponent {
  @Input() hero: Hero;

  @Output() selected = new EventEmitter();

  onSelectHero(): void {
    this.selected.emit(this.hero);
  }
}
	
Тесты также можно использовать без TestBed:
	
import { DashboardHeroComponent, Hero } from './dashboard-hero.component';

describe('DashboardHeroComponent', () => {
  it('raises the selected event when clicked', () => {
    const comp = new DashboardHeroComponent();
    const hero: Hero = { id: 42, name: 'Test' };
    comp.hero = hero;

    comp.selected.subscribe(selectedHero => expect(selectedHero).toBe(hero));
    comp.onSelectHero();
  });
});
	

Как видно из примера, мы сначала создали компонент, а затем свойству hero присвоили объект, соответствующий данному типу.

Так как eventEmitter это observable, то подпишемся на изменения и в результате произошедшего события, selected должен испустить одно событие, аргументами которого будет наш ранее инициализированный объект Hero.

Компоненты с зависимостями

При тестировании компонента, в котором есть зависимости необходимо использовать TestBed.

Создадим простой компонент Welcome, который либо отображает имя авторизированного пользователя или предлагает авторизоваться.

Для определения авторизации пользователя в компоненте будем использовать сервис.

welcome.component.html

	
<div *ngIf="welcome">{{ welcome }}</div>
	

welcome.component.ts

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

import { FakeUserService } from '../../services/fake-user.service';

@Component({
  selector: 'medium-stories-welcome',
  templateUrl: './welcome.component.html',
  styleUrls: ['./welcome.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class WelcomeComponent implements OnInit {
  /**
   * Welcome
   */
  welcome: string;

  constructor(private userService: FakeUserService) {}

  ngOnInit(): void {
    this.welcome = this.userService.isLoggedIn() ? 'Welcome, ' + this.userService.getUser().username : 'Please log in.';
  }
}
	

FakeUserService — будет простой эмуляцией сервиса пользователя:

fake-user.service.ts

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

import { User } from '@medium-stories/entities';

@Injectable()
export class FakeUserService {
  /**
   * User
   */
  private user: User;

  constructor() {
    this.user = {
      created: '2020-03-06',
      email: 'ivan@dorn.ru',
      phone: '9231002020',
      id: 1,
      username: 'Ivan Dorn',
      updated: '2020-03-06'
    };
  }

  getUser(): User {
    return this.user;
  }

  isLoggedIn(): boolean {
    return this.user != null;
  }

  logout(): void {
    this.user = null;
  }
}
	

user.interface.ts

	
/**
 * User interface
 */
export interface User {
  /**
   * Created at
   */
  created: string;

  /**
   * Email
   */
  email?: string;

  /**
   * Phone
   */
  phone: string;

  /**
   * ID
   */
  id: number;

  /**
   * Password
   */
  password?: string;

  /**
   * Username
   */
  username: string;

  /**
   * Updated at
   */
  updated: string;
}
	

Создадим тесты, который будет проверять работоспособность нашего компонента:

welcome.component.spec.ts

	
import { TestBed, async } from '@angular/core/testing';
import { FakeUserService } from '../../services/fake-user.service';

import { WelcomeComponent } from './welcome.component';

describe('WelcomeComponent', () => {
  let component: WelcomeComponent;
  let userService: FakeUserService;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      providers: [WelcomeComponent, FakeUserService]
    }).compileComponents();
    component = TestBed.inject(WelcomeComponent);
    userService = TestBed.inject(FakeUserService);
  }));

  it('should not have welcome message after construction', () => {
    expect(component.welcome).toBeUndefined();
  });

  it('should welcome logged in user after Angular calls ngOnInit', () => {
    component.ngOnInit();
    expect(component.welcome).toContain(userService.getUser().username);
  });

  it('should ask user to log in if not logged in after ngOnInit', () => {
    userService.isLoggedIn = jest.fn(() => false);
    component.ngOnInit();
    expect(component.welcome).not.toContain(userService.getUser().username);
    expect(component.welcome).toContain('log in');
  });
});
	

Как видно из теста, сначала идёт объявление всех используемых сервисов и компонент:

	
TestBed.configureTestingModule({
  providers: [WelcomeComponent, FakeUserService]
}).compileComponents();
	

Затем, мы получаем экземпляры сервисов, которые будут участвовать в тестировании:

	
component = TestBed.inject(WelcomeComponent);
userService = TestBed.inject(FakeUserService);
	
Отметим, что использование Angular Component’а как провайдера нужно лишь с той целью, чтобы максимально соответствовать официальной документации. В реальных проектах этого делать не стоит.

В данном случае, было рассмотрено 3 случая. Первый, когда компонент ещё не инициализирован:

	
it('should not have welcome message after construction', () => {
  expect(component.welcome).toBeUndefined();
});
	

Второй, когда пользователь авторизирован и и компонент должен отобразить приветствие:

	
it('should welcome logged in user after Angular calls ngOnInit', () => {
  component.ngOnInit();
  expect(component.welcome).toContain(userService.getUser().username);
});
	

Третий, когда пользователь не авторизован и компонент должен вывести сообщение, что пользователь должен авторизоваться:

	
it('should ask user to log in if not logged in after ngOnInit', () => {
  userService.isLoggedIn = jest.fn(() => false);
  component.ngOnInit();
  expect(component.welcome).not.toContain(userService.getUser().username);
  expect(component.welcome).toContain('log in');
});
	

В рассмотренном примере, у нас был простой случай, где FakeUserService не имел зависимостей. Но если таковые имелись, то тогда нужно было бы импортировать в TestBed зависимости для FakeUserService. Однако, подобный подход является плохой практикой, так как при тестировании компонента не нужно воспроизводить реальный сервис. Основная цель — проверить корректность работы компонента, а не зависимостей.

Для этого, можно использовать моки сервисов.

Создадим усложнённый вариант WelcomeComponent’а, где будет использоваться FakeHardUserService:

welcome-mock.component.html

	
<div *ngIf="welcome">{{ welcome }}</div>
	

welcome-mock.component.ts

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

import { FakeHardUserService } from '../../services/fake-hard-user.service';

@Component({
  selector: 'medium-stories-welcome-mock',
  templateUrl: './welcome-mock.component.html',
  styleUrls: ['./welcome-mock.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class WelcomeMockComponent implements OnInit {
  /**
   * Welcome
   */
  welcome: string;

  constructor(private userService: FakeHardUserService) {}

  ngOnInit(): void {
    this.welcome = this.userService.isLoggedIn() ? 'Welcome, ' + this.userService.getUser().username : 'Please log in.';
  }
}
	

Сервис FakeHardUserService будет использовать FakeUserService:

fake-hard-user.service.ts

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

import { User } from '@medium-stories/entities';

import { FakeUserService } from './fake-user.service';

@Injectable()
export class FakeHardUserService {
  constructor(private userService: FakeUserService) {}

  getUser(): User {
    return this.userService.getUser();
  }

  isLoggedIn(): boolean {
    return this.userService.isLoggedIn();
  }

  logout(): void {
    this.userService.logout();
  }
}
	

Как видно из реализации, FakeHardUserService является фасадом над FakeUserService.

Сам тест будет выглядеть так:

welcome-mock.component.spec.ts

	
import { TestBed, async } from '@angular/core/testing';

import { userStub } from '@medium-stories/users/testing';

import { FakeHardUserService } from '../../services/fake-hard-user.service';
import { WelcomeMockComponent } from './welcome-mock.component';

describe('WelcomeMockComponent', () => {
  let component: WelcomeMockComponent;
  let userService: FakeHardUserService;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      providers: [
        WelcomeMockComponent,
        {
          provide: FakeHardUserService,
          useValue: {}
        }
      ]
    }).compileComponents();
    component = TestBed.inject(WelcomeMockComponent);
    userService = TestBed.inject(FakeHardUserService);
  }));

  it('should not have welcome message after construction', () => {
    expect(component.welcome).toBeUndefined();
  });

  it('should welcome logged in user after Angular calls ngOnInit', () => {
    userService.getUser = jest.fn(() => userStub);
    userService.isLoggedIn = jest.fn(() => true);
    component.ngOnInit();
    expect(component.welcome).toContain(userService.getUser().username);
  });

  it('should ask user to log in if not logged in after ngOnInit', () => {
    userService.getUser = jest.fn(() => null);
    userService.isLoggedIn = jest.fn(() => false);
    component.ngOnInit();
    expect(component.welcome).not.toContain(userStub.username);
    expect(component.welcome).toContain('log in');
  });
});
	

В данном случае, для FakeHardUserService используется пустой объект.

Отметим, вместо пустого объекта можно использовать реальный класс — FakeHardUserServiceMock:

fake-hard-user.service.spec.ts

	
import { User } from '@medium-stories/entities';

import { userStub } from '@medium-stories/users/testing';

import { FakeHardUserService } from './fake-hard-user.service';

class FakeHardUserServiceMock extends FakeHardUserService {
  constructor() {
    super(null);
  }

  getUser(): User {
    return userStub;
  }

  isLoggedIn(): boolean {
    return true;
  }

  logout(): void {}
}

describe('FakeHardUserService', () => {
  let service: FakeHardUserService;

  beforeEach(() => {
    service = new FakeHardUserServiceMock();
  });

  it('getUser() should return user', () => {
    expect(service.getUser()).toEqual(userStub);
  });
});
	
	
providers: [
  {
    provide: FakeHardUserService,
    useClass: FakeHardUserServiceMock
  }
]
	
Как показывает практика — лучше использовать простой объект, так как проще с помощью Jest задавать для данного сервиса поведение, чем оборачивать методы мок класса.

Примеры тестов для WelcomeMockComponent почти не изменились, только теперь для каждого случая задаются необходимые значения для сервиса:

	
it('should welcome logged in user after Angular calls ngOnInit', () => {
  userService.getUser = jest.fn(() => userStub);
  userService.isLoggedIn = jest.fn(() => true);
  component.ngOnInit();
  expect(component.welcome).toContain(userService.getUser().username);
});
	

Тестирование DOM’а

В выше приведённых тестах проверяется логика работы свойств и методов компонента, а не его отображение. Для того, чтобы проверить корректность изменения DOM’а, необходимо использовать фикстуры.

Создадим простой компонент:

banner.component.html

	
<p>banner works!</p>
	

banner.component.ts

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

@Component({
  selector: 'medium-stories-banner',
  templateUrl: './banner.component.html',
  styleUrls: ['./banner.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class BannerComponent implements OnInit {
  constructor() {}

  ngOnInit(): void {}
}
	

Сгенерированный файл теста:

banner.component.spec.ts

	
import { ComponentFixture, TestBed, async } from '@angular/core/testing';

import { BannerComponent } from './banner.component';

describe('BannerComponent', () => {
  let component: BannerComponent;
  let fixture: ComponentFixture;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [BannerComponent]
    }).compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(BannerComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
  
  it('should contain "banner works!"', () => {
    const bannerElement = fixture.nativeElement;
    expect(bannerElement.textContent).toContain('banner works!');
  });
  
  it('should have 

with "banner works!"', () => { const bannerElement = fixture.nativeElement; const p = bannerElement.querySelector('p'); expect(p.textContent).toEqual('banner works!'); }); });

Для создания fixture компонента используется метод createComponent:

	
fixture = TestBed.createComponent(BannerComponent);
	

Объект класса берется из fixture:

	
component = fixture.componentInstance;
	

Для данного компонента есть три теста.

Первый тест проверяет корректное создание компонента:

	
it('should create', () => {
    expect(component).toBeTruthy();
});
	
Отметим, что в Jest нужно проверять именно — toBeTruthy(), а не toBeDefined(), как было в Jasmine.

Данный тест формально проверяет доступность всех зависимостей, директив, пайпов и компонент, которые были использованы. Часто это необходимо при рефакторинге, чтобы убедиться что все корректно работает.

Второй тест, проверяет правильность отображения:

	
it('should contain "banner works!"', () => {
    const bannerElement = fixture.nativeElement;
    expect(bannerElement.textContent).toContain('banner works!'); });
	

Так как это сгенерированный компонент, мы должны увидеть текст, который был добавлен в шаблон — banner works!.

Третий тест, показывает как выбирать элементы из шаблона:

	
it('should have 

with "banner works!"', () => { const bannerElement = fixture.nativeElement; const p = bannerElement.querySelector('p'); expect(p.textContent).toEqual('banner works!'); });

В данном случае, мы ищем селектор p и ожидаем, что он его текст равен banner works!.

Отметим, что во всех тестах используется — fixture.nativeElement. В данном случае, корректность работы тестов гарантируется только в для браузера. Если запускать Angular на других платформах (server, …), тогда возможны случаи где не все свойства и методы HTMLElement API будут корректно работать.

Для решения данной проблемы, можно использовать debugElement:

	
const bannerElement = fixture.debugElement;
	

Тестирование binding свойств

Создадим чуть усложнённую версию banner компонента, в котором будем использовать переменные для отображения в шаблоне компонента:

banner-title.component.html

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

banner-title.component.ts

	
import { ChangeDetectionStrategy, Component } from '@angular/core';

@Component({
  selector: 'medium-stories-banner-title',
  templateUrl: './banner-title.component.html',
  styleUrls: ['./banner-title.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class BannerTitleComponent {
  title = 'My title';
}
	

Создадим тест:

banner-title.component.spec.ts

	
import { ComponentFixture, TestBed, async } from '@angular/core/testing';

import { BannerTitleComponent } from './banner-title.component';

describe('BannerTitleComponent', () => {
  let component: BannerTitleComponent;
  let fixture: ComponentFixture;
  let h1: HTMLElement;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [BannerTitleComponent]
    }).compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(BannerTitleComponent);
    component = fixture.componentInstance;
    h1 = fixture.nativeElement.querySelector('h1');
    fixture.detectChanges();
  });

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

  it('should display original title', () => {
    expect(h1.textContent).toContain(component.title);
  });

  it('should display a different test title', () => {
    component.title = 'Test Title';
    fixture.detectChanges();
    expect(h1.textContent).toContain('Test Title');
  });
});
	

Так как createComponent, не связывает данные шаблона и компонента, необходимо вызвать метод detectChanges() в fixture каждый раз при изменении шаблона:

	
fixture.detectChanges();
	

Можно настроить автоматическое определение изменений:

ComponentFixtureAutoDetect.spec.ts

	
TestBed.configureTestingModule({
  declarations: [ BannerComponent ],
  providers: [
    { provide: ComponentFixtureAutoDetect, useValue: true }
  ]
});
	
Практика показывает, что ручной контроль изменений лучше. Это связано с тем, что связывать данные нужно только один раз, после выполнения всех требуемых операций. И нет проблем вызвать

Если для теста:

	
it('should display original title', () => {
    expect(h1.textContent).toContain(component.title);
});
	

Тест сработает из-за первичного detectChanges(), но второй тест уже явно вызывает detectChanges, иначе в фикстуре будет предыдущее значение и тест завершится ошибкой.

	
it('should display a different test title', () => {
  component.title = 'Test Title';
  fixture.detectChanges();
  expect(h1.textContent).toContain('Test Title');
});
	

Тестирование асинхронных binding свойств

При тестировании асинхронной логики есть 2 подхода:

  • fakeAsync & timer
  • marble tests

В первом случае описываются события, добавляются подписки и проверяются ожидаемые значения. Однако данный подход очень громоздкий и подходит лишь для простых случаев. Подробнее в документации angular.

Второй подход это использование Marble тестирования. Данный подход подходит для создания простых и сложных кейсов тестирования реактивной логики. Подробнее в статье — Тестирование сервисов в Angular с помощью Jest. Тестирование реактивной/асинхронной логики.

В данном примере мы будем использовать второй подход.

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

user-box.component.html

	
<ng-container *ngTemplateOutlet="userBoxTpl; context: { loading: preload$ | async }"></ng-container>

<ng-template #userBoxTpl let-loading="loading">
  <ng-container *ngIf="loading; else preloaderTpl">
    <ng-container *ngTemplateOutlet="userBoxCardTpl"></ng-container>
  </ng-container>
</ng-template>

<ng-template #userBoxCardTpl let-loading="loading">
  <h3>{{ user.username }}</h3>
</ng-template>

<ng-template #preloaderTpl>
  <h3>Loading...</h3>
</ng-template>
	

user-box.component.ts

	
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { combineLatest } from 'rxjs';
import { map } from 'rxjs/operators';

import { Event, User } from '@medium-stories/entities';
import { EventFacade } from '@medium-stories/events';
import { UserFacade } from '@medium-stories/users';

@Component({
  selector: 'medium-stories-user-box',
  templateUrl: './user-box.component.html',
  styleUrls: ['./user-box.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserBoxComponent {
  @Input() user: User;

  preload$ = combineLatest([this.eventFacade.eventLast$, this.userFacade.user$]).pipe(
    map<[Event, User], boolean>(data => {
      return data.every(value => !!value);
    })
  );

  constructor(private eventFacade: EventFacade, private userFacade: UserFacade) {}
}
	

В данном случае, мы проверяем корректность работы preload$. Для того чтобы просто проверить корректность создания компонента достаточно просто обернуть сервисы и вернуть Observable:

user-box.component.spec.ts

	
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { of } from 'rxjs';

import { EventFacade } from '@medium-stories/events';
import { UserFacade } from '@medium-stories/users';
import { userStub } from '@medium-stories/users/testing';

import { UserBoxComponent } from './user-box.component';

describe('UserBoxComponent', () => {
  let component: UserBoxComponent;
  let eventFacade: EventFacade;
  let userFacade: UserFacade;
  let fixture: ComponentFixture;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [UserBoxComponent],
      providers: [
        {
          provide: EventFacade,
          useValue: {
            eventLast$: of()
          }
        },
        {
          provide: UserFacade,
          useValue: {
            user$: of()
          }
        }
      ]
    }).compileComponents();
    eventFacade = TestBed.inject(EventFacade);
    userFacade = TestBed.inject(UserFacade);
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(UserBoxComponent);
    component = fixture.componentInstance;
    component.user = userStub;
    fixture.detectChanges();
  });

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

Вопрос: Нужно ли тестировать различные состояния preload$?

На самом деле этого делать не нужно, так как результат работы preload$ зависит от корректной работы сервисов EventFacade и UserFacade.

	
preload$ = combineLatest([
   this.eventFacade.eventLast$,
   this.userFacade.user$
]).pipe(
  map<[Event, User], boolean>(data => data.every(value => !!value))
);
	

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

Подобный тест будет выглядеть так:

user-box-reactive.component.spec.ts

	
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { eventStub } from '@medium-stories/events/testing';
import { cold, getTestScheduler } from '@nrwl/angular/testing';

import { EventFacade } from '@medium-stories/events';
import { UserFacade } from '@medium-stories/users';
import { userStub } from '@medium-stories/users/testing';

import { UserBoxComponent } from './user-box.component';

describe('UserBoxComponent', () => {
  let component: UserBoxComponent;
  let eventFacade: EventFacade;
  let userFacade: UserFacade;
  let fixture: ComponentFixture;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [UserBoxComponent],
      providers: [
        {
          provide: EventFacade,
          useValue: {
            eventLast$: cold('-a|', { a: eventStub })
          }
        },
        {
          provide: UserFacade,
          useValue: {
            user$: cold('-a|', { a: userStub })
          }
        }
      ]
    }).compileComponents();
    eventFacade = TestBed.inject(EventFacade);
    userFacade = TestBed.inject(UserFacade);
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(UserBoxComponent);
    component = fixture.componentInstance;
    component.user = userStub;
    fixture.detectChanges();
  });

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

  it('preload$ should return loading or username', () => {
    const compiled = fixture.nativeElement;
    expect(compiled.querySelector('h3').textContent).toContain('Loading...');

    // update observables
    getTestScheduler().flush();
    fixture.detectChanges();
    expect(compiled.querySelector('h3').textContent).toContain(userStub.username);
  });
});
	

Формально, в сервисы мы передали события, которые будут вызваны с течением времени:

	
{
  provide: EventFacade,
  useValue: {
    eventLast$: cold('-a|', { a: eventStub })
  }
},
{
  provide: UserFacade,
  useValue: {
    user$: cold('-a|', { a: userStub })
  }
}
	

То есть, через некоторое время одновременно произойдёт событие для каждого из сервисов и вернёт нужные нам данные.

	
getTestScheduler().flush();
fixture.detectChanges();
	

Вызов данных методов, позволит обновить все Observable в компоненте.

Тестирование компонентов с DOM событиями (input, change …)

Создадим компонент, в котором будет отображаться баннер, а также форма редактирования заголовка (по аналогии с hero-detail.component):

banner-edit.component.html

	
<div *ngIf="banner">
  <h2>{{ banner.name | uppercase }} Details</h2>
  <div><span>id: </span>{{ banner.id }}</div>
  <div>
    <label> name: <input [(ngModel)]="banner.name" placeholder="name" /> </label>
  </div>
</div>
	

banner-edit.component.ts

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

export interface Banner {
  id: number;
  name: string;
}

@Component({
  selector: 'medium-stories-banner-edit',
  templateUrl: './banner-edit.component.html',
  styleUrls: ['./banner-edit.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class BannerEditComponent implements OnInit {
  banner: Banner;

  constructor() {}

  ngOnInit(): void {
    this.banner = { id: 1, name: 'Simple Banner' };
  }
}
	

Создадим файл теста:

banner-edit.component.spec.ts

	
import { ComponentFixture, TestBed, async } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';

import { BannerEditComponent } from './banner-edit.component';

describe('BannerEditComponent', () => {
  let component: BannerEditComponent;
  let fixture: ComponentFixture;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [FormsModule],
      declarations: [BannerEditComponent]
    }).compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(BannerEditComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

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

  it('should convert hero name to Title Case', () => {
    const hostElement = fixture.nativeElement;
    const nameInput = hostElement.querySelector('input');
    const nameDisplay = hostElement.querySelector('h2');
    const newInputVal = 'quick BROWN fOx';
    nameInput.value = newInputVal;

    // dispatch a DOM event so that Angular learns of input value change.
    // use new Event utility function (not provided by Angular) for better browser compatibility
    nameInput.dispatchEvent(new Event('input'));

    fixture.detectChanges();

    expect(nameDisplay.textContent).toBe(newInputVal.toUpperCase());
  });
});
	

Как видно, сначала были получены HTMLElement’ы из фикстуры:

	
const hostElement = fixture.nativeElement;
const nameInput = hostElement.querySelector('input');
const nameDisplay = hostElement.querySelector('h2');
	

Затем мы создали новое значение input’а:

	
const newInputVal = 'quick BROWN fOx';
nameInput.value = newInputVal;
	

Для того, чтобы событие отработало корректно, нужно создать новое DOM событие, которое изменит шаблон:

	
nameInput.dispatchEvent(new Event('input'));
	

И соответственно, для обновления шаблона в фикстуре:

	
fixture.detectChanges();
	

Тестирование компонентов с event click

Создадим компонент, при клике на который меняется текст:

banner-toggle.component.html

	
<div (click)="onToggle()">{{ message }}</div>
	

banner-toggle.component.ts

	
import { ChangeDetectionStrategy, Component } from '@angular/core';

@Component({
  selector: 'medium-stories-banner-toggle',
  templateUrl: './banner-toggle.component.html',
  styleUrls: ['./banner-toggle.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class BannerToggleComponent {
  status = true;

  get message(): string {
    return this.status ? 'Active' : 'Disable';
  }

  onToggle(): void {
    this.status = !this.status;
  }
}
	

Файл теста:

banner-toggle.component.spec.ts

	
import { ComponentFixture, TestBed, async } from '@angular/core/testing';
import { By } from '@angular/platform-browser';

import { BannerToggleComponent } from './banner-toggle.component';

describe('BannerToggleComponent', () => {
  let component: BannerToggleComponent;
  let fixture: ComponentFixture;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [BannerToggleComponent]
    }).compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(BannerToggleComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

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

  it('should change messsage', () => {
    const divElement = fixture.debugElement.query(By.css('div'));
    divElement.triggerEventHandler('click', null);
    expect(component.message).toBe('Disable');
  });
});
	

Как видно из примера, в компоненте мы находим нужный элемент, при клике на который должен вызваться метод:

	
const divElement = fixture.debugElement.query(By.css('div'));
	

И с помощью метода triggerEventHandler, мы вызываем событие DOM:

	
divElement.triggerEventHandler('click', null);
	
triggerEventHandler — может испускать любые связанные события Angular.

Тестирование компонентов с внешними файлами

Возьмём компонент:

	
@Component({
  selector: 'medium-stories-banner-edit',
  templateUrl: './banner-edit.component.html',
  styleUrls: ['./banner-edit.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class BannerEditComponent {}
	

Данный компонент использует внешние файлы:

  • templateUrl — внешний html файл, в котором описан шаблон компонента
  • styleUrls — путь к массиву css (scss) файлов

Для того, чтобы TestBed корректно отработал во всех платформах, необходимо запускать метод — compileComponents().

Тестирование компонента с дочерними компонентами

Создадим компонент, который будет использовать другой компонент:

banner-list.component.html

	
<medium-stories-banner-details
  [banner]="banner"
  *ngFor="let banner of banners"
  (selected)="onSelectedBanner($event)"
></medium-stories-banner-details>
	

banner-list.component.ts

	
import { ChangeDetectionStrategy, Component } from '@angular/core';

import { Banner } from '../banner-edit/banner-edit.component';

@Component({
  selector: 'medium-stories-banner-list',
  templateUrl: './banner-list.component.html',
  styleUrls: ['./banner-list.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class BannerListComponent {
  banners: Banner[] = [
    {
      id: 1,
      name: 'First banner'
    },
    {
      id: 2,
      name: 'Second banner'
    }
  ];
  selectedBanner: Banner;

  onSelectedBanner(banner: Banner): void {
    this.selectedBanner = banner;
  }
}
	

И сам используемый компонент:

banner-details.component.html

	
<div class="banner-details" (click)="onSelected()">
  <div class="banner-details-id">{{ banner.id }}</div>
  <div class="banner-details-name">{{ banner.name }}</div>
</div>
	

banner-details.component.ts

	
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';

import { Banner } from '../banner-edit/banner-edit.component';

@Component({
  selector: 'medium-stories-banner-details',
  templateUrl: './banner-details.component.html',
  styleUrls: ['./banner-details.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class BannerDetailsComponent {
  @Input() banner: Banner;
  @Output() selected = new EventEmitter();

  onSelected(): void {
    this.selected.emit(this.banner);
  }
}
	

Файл теста:

banner-list.component.spec.ts

	
import { DebugElement } from '@angular/core';
import { ComponentFixture, TestBed, async } from '@angular/core/testing';
import { By } from '@angular/platform-browser';

import { BannerDetailsComponent } from '../banner-details/banner-details.component';
import { BannerListComponent } from './banner-list.component';

describe('BannerListComponent', () => {
  let component: BannerListComponent;
  let bannersDetails: DebugElement[];
  let fixture: ComponentFixture;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [BannerListComponent, BannerDetailsComponent]
    }).compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(BannerListComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
    bannersDetails = fixture.debugElement.queryAll(By.css('.banner-details'));
  });

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

  it('should raise selected event when clicked', () => {
    bannersDetails[0].triggerEventHandler('click', null);
    expect(component.selectedBanner.name).toBe(component.banners[0].name);
  });
});
	

Сначала мы подключаем компоненты:

	
TestBed.configureTestingModule({
  declarations: [BannerListComponent, BannerDetailsComponent]
}).compileComponents();
	

Затем, выбираем для удобства все созданные дочерние компоненты:

	
bannersDetails = fixture.debugElement.queryAll(By.css('.banner-details'));
	

И в тесте вызываем событие клика на первый элемент коллекции banners:

	
it('should raise selected event when clicked', () => {
  bannersDetails[0].triggerEventHandler('click', null);
   expect(component.selectedBanner.name).toBe(component.banners[0].name);
});
	

Как видно из примера, дочерний и родительский компоненты связаны и работают как единое целое.

Тестирование компонентов с Router

Создадим компонент, который при клике на кнопку будет перенаправлять на страницу с пользователями — /users.

user.component.html

	
<p>user works!</p>
<p>
  <button type="button" (click)="onClick()">Go to users</button>
</p>
	

user.component.ts

	
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { Router } from '@angular/router';

@Component({
  selector: 'medium-stories-user',
  templateUrl: './user.component.html',
  styleUrls: ['./user.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserComponent {
  constructor(private router: Router) {}

  onClick(): void {
    this.router.navigate(['/users']);
  }
}
	

В документации, приведены какие-то сложные пути за отслеживанием изменений путей. Вместо этого будем использовать RouterTestingModule.

user.component.spec.ts

	
import { ComponentFixture, TestBed, async } from '@angular/core/testing';
import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';

import { UserComponent } from './user.component';

describe('UserComponent', () => {
  let component: UserComponent;
  let fixture: ComponentFixture;
  let router: Router;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [RouterTestingModule],
      declarations: [UserComponent]
    }).compileComponents();
    router = TestBed.inject(Router);
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(UserComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

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

  it('should create', () => {
    fixture.ngZone.run(() => {
      component.onClick();
      fixture.detectChanges();
      fixture.whenStable().then(() => {
        expect(router.url).toBe('/users');
      });
    });
  });
});
	

Так как в тесте важно проверить корректность путей, а не сам механизм перенаправления, то достаточно следующего:

	
it('should create', () => {
  fixture.ngZone.run(() => {
    component.onClick();
    fixture.detectChanges();
    fixture.whenStable().then(() => {
      expect(router.url).toBe('/users');
    });
  });
});
	

Тест запускается в fixture.ngZone.run из-за того, что роутер связан c ngZone.

Вызов метода onClick — запускает перенаправление. И так как навигация не является синхронной операцией ждём выполнения всех задач и проверяем url:

	
fixture.whenStable().then(() => {
  expect(router.url).toBe('/users');
});
	

Итого

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

Все это приводит к тому, что должен быть баланс в написании тестов. Если тестов будет много это с одной стороны делает систему надежней, с другой увеличивает время поддержки и рефакторинга.

В идеале компоненты должны отображать данные и стараться не изменять их (для этого есть сервисы, пайпы и директивы). И если так будет, то количество тестов будет минимально и оптимально одновременно.

Похожие материалы

Директивы ng-template и связанная ngTemplateOutlet очень мощные инструменты Angular, которые часто используются с ng-container.

Компоненты ключевая особенность Angular. Приложение строится из компонентов.

Централизация логики и упрощение коммуникации между компонентами с помощью сервисов.

наверх