меню

Тестирование компонентов в 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. Однако, это будет достаточно редко, когда потребность в тестировании, например вложенных компонентов будет оправдана или же тестирование и проверка корректности нажатия всех кнопок и сопоставления всех методов.

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

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


Возможно, вам будет интересно

Связывание свойств и событий

Создание кастомных событий и их обработчиков. Импорт свойств родительского компонента.

Route Resolving

Разрешение данных это процесс их получения перед активацией роута. В приложении возможна задержка перед тем, как данные будут предоставлены сервером.

Angular Signals

Версия Angular 16 была выпущена в начале мая, и эта версия полна новых функций. Но одной из особенностей, о которой все говорят, является новая возможность - Сигналы. Это настоящий поворотный момент, который находится в стадии предварительного просмотра разработчиками и будет выпущен позже в этом году, в версии 17.

Маршрутизация Angular подробное руководство

Маршрутизация Angular подробное руководство. Без маршрутизации (роутинга) вы никогда не сделаете качественное приложение.

Оформление заявки

Документы на создание сайта

Изучите наше коммерческое предложение, заполните БРИФ и отправьте его на почту maxidebox@list.ru. Изучив все пожелания из БРИФ-а, обратным ответом оповестим Вас по стоимости разработке, ответим на вопросы.

КП на создание сайта Коммерческое предложение на созданеи сайта

Мы берем на себя ответственность за все стадии работы и полностью избавляем клиентов от забот и необходимости вникать в тонкости.

Скачать БРИФ (акета) на создание сайта Скачать БРИФ (акета) на создание сайта

Зополните у БРИФ-а все необходимые поля. Сделайте краткое описание к каждому из пунктов анкеты, привидите примеры в соответсвующий пунктах - это позволит лучше понять Ваши ожидания и требования к сайту