DevGang
Авторизоваться

Советы по тестированию Angular: Ng-Mocks

Написание модульных тестов во многом похоже на уборку вашей комнаты; это может быть утомительно и отнимать много времени, но в конечном итоге приводит к созданию более чистого и организованного пространства. Аналогичным образом, модульное тестирование может быть сложным и требовать значительных затрат времени и усилий, но в конечном итоге оно приводит к созданию более надежного и лучше организованного кода, отвечающего потребностям пользователей и заинтересованных сторон. Уборка вашей комнаты и написание тестов поначалу кажутся непосильными, но разбиение каждой задачи на более мелкие, более управляемые задачи может облегчить ее решение. Точно так же, как чистая комната может помочь вам чувствовать себя более продуктивным и сосредоточенным, надежный и хорошо протестированный код может помочь вам работать более эффективно и с большей уверенностью.

Справочный проект, демонстрирующий несколько простых примеров ng-mocks, доступен здесь:

Mocks

Согласно блогу CircleCI, “mocking означает создание поддельной версии внешнего или внутреннего сервиса, которая может заменить настоящую, помогая вашим тестам выполняться быстрее и надежнее. Когда ваша реализация взаимодействует со свойствами объекта, а не с его функцией или поведением, можно использовать макет”.

Чтобы писать быстрые и надежные тесты, важно сузить область применения тестируемой системы. Лучший способ сузить область применения тестируемого компонента - это смоделировать все зависимости компонента. Замена дочерних компонентов пустыми подделками не позволяет нам проводить тестирование за пределами нашего родительского компонента. Имитируя зависимости компонентов, мы можем заменить дочерние компоненты пустыми шаблонами, которые быстрее отрисовываются, что позволяет нам быстро выполнять итерации и сокращать цикл обратной связи при тестировании.

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

Приступая к работе

Документация Angular содержит небольшое руководство о том, как создавать макеты в модульных тестах. Согласно документам Angular, вы можете предоставить макет реализации сервиса, следуя одному из их примеров:

class MockUserService {
  isLoggedIn = true;
  user = { name: 'Test User'};
}

beforeEach(() => {
  TestBed.configureTestingModule({
    // provide the component-under-test and dependent service
    providers: [
      WelcomeComponent,
      { provide: UserService, useClass: MockUserService }
    ]
  });
  // inject both the component and the dependent service.
  comp = TestBed.inject(WelcomeComponent);
  userService = TestBed.inject(UserService);
});

Точно так же документы Angular демонстрируют, как объявлять реализации для фиктивных компонентов:

@Component({selector: 'app-banner', template: ''})
class BannerStubComponent { }

@Component({selector: 'router-outlet', template: ''})
class RouterOutletStubComponent { }

@Component({selector: 'app-welcome', template: ''})
class WelcomeStubComponent { }

TestBed
  .configureTestingModule({
    imports: [RouterLink],
    providers: [provideRouter([])],
    declarations:
        [AppComponent, BannerStubComponent, RouterOutletStubComponent, WelcomeStubComponent]
  })

Макетные реализации из Angular docs работают, но требуют большого количества шаблонного кода каждый раз, когда вы определяете новый макет. К счастью, ngmocks предоставляет несколько инструментов, которые облегчают создание поддельных реализаций.

Ng-Mocks

Согласно документам, “ng-mocks - это библиотека тестирования, которая помогает с имитацией сервисов, компонентов, директив, каналов и модулей в тестах для приложений Angular. Когда у нас есть зашумленный дочерний компонент или любая другая раздражающая зависимость, у ng-mocks есть инструменты для превращения этих объявлений в свои mocks, сохраняя интерфейсы такими, какие они есть, но подавляя их реализацию.”

MockComponent

Самый простой способ начать работу с ng-mocks — это заменить дочерние компоненты их эквивалентами MockComponent. Давайте посмотрим на фрагмент примера компонента Angular.

<div class="main-content">
  <div class="form">
    <div class="breeds">
      <app-form [breeds]="(breeds$ | async)!" [breed]="breed" [count]="count"
        (formChange)="onFormChange($event)"></app-form>
    </div>
  </div>
  <div class="cards">
    <mat-spinner *ngIf="loading$ | async"></mat-spinner>
    <app-card *ngFor="let dog of dogs$ | async" [imgSrc]="dog"></app-card>
  </div>
</div>

Обратите внимание, что у нас есть 3 дочерних компонента зависимости app-form, mat-spinner и app-card. Если бы мы определяли mocks вручную, они выглядели бы следующим образом.

@Component({selector: 'app-form', template: ''})
class MockAppFormComponent {
  @Input breeds: Array<any>;
  @Input breed: any;
  @Input count: any;
  @Output formChange = new EventEmitter<any>();
}

@Component({selector: 'mat-spinner', template: ''})
class MockMatProgressSpinnerComponent { }

@Component({selector: 'app-card', template: ''})
class MockAppCardComponent {
  @Input imgSrc: any;
}

describe('AppComponent', () => {
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [
        AppComponent,
        MockAppFormComponent,
        MockMatProgressSpinnerComponent,
        MockAppCardComponent
      ]
    }).compileComponents();
  
    fixture = TestBed.createComponent(AppComponent);
    app = fixture.componentInstance;
  });
});

Мы должны определить все те же входные данные, что и у реального компонента, иначе мы увидим несколько ошибок, Can't bind to 'count' since it isn't a known property of 'app-form'. Каждый раз, когда мы добавляем новые входные данные в наш реальный компонент, мы также должны помнить о добавлении входных данных в наш макет. Ручная синхронизация входных данных между нашими реальными и имитируемыми компонентами — это небольшая проблема, которая со временем приводит к большим потерям производительности.

С помощью ngmocks мы можем упростить приведенный выше фрагмент, сократив код и автоматически синхронизируя наши макеты с нашими реальными компонентами.

describe('AppComponent', () => { 
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [
        AppComponent,
        MockComponent(MatProgressSpinnerComponent),
        MockComponent(FormComponent),
        MockComponent(CardComponent),
      ]
    }).compileComponents();
  
    fixture = TestBed.createComponent(AppComponent);
    app = fixture.componentInstance;
  });
});

Используя MockComponent, мы полностью избавились от необходимости управлять фиктивными входными данными компонента!

MockProvider

Mocking services позволяет разработчикам замыкать глубокие, сложные деревья зависимостей и писать более простые тесты. Когда компонент зависит от службы, которая зависит от HttpClient от Angular, вы можете имитировать службу и отказаться от импорта HttpClientTestingModule.

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

let dogService: jasmine.SpyObj<DogService>;

describe('AppComponent', () => {
  const breeds = ['affenpinscher', 'african', 'airedale'];
  const dogs = ['https://images.dog.ceo/breeds/affenpinscher/n02110627_10047.jpg'];
  dogService = jasmine.createSpyObj('DogService', ['getBreeds', 'getDogs'];
  dogService.getBreeds.and.returnValue(of(breeds));
  dogService.getDogs.and.returnValue(of(dogs));
  
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      providers: [
        {
          provide: DogService,
          useValue: dogService
        }
      ]
    }).compileComponents();

    fixture = TestBed.createComponent(AppComponent);
    app = fixture.componentInstance;
  });
});

Мы можем упростить конфигурацию тестового стенда в приведенном выше примере, используя MockProvider.

await TestBed.configureTestingModule({
  providers: [
    MockProvider(DogService, dogService)
  ]
}).compileComponents();

Есть еще один трюк, который мы можем применить — мы можем использовать jasmine-auto-spies, чтобы сэкономить несколько нажатий клавиш. Вот версия DogService, созданная jasmine-auto-spies.

import { createSpyFromClass, Spy } from 'jasmine-auto-spies';

let dogService: Spy<DogService>;

dogService = createSpyFromClass(DogService);
dogService.getBreeds.and.returnValue(of(breeds));
dogService.getDogs.and.returnValue(of(dogs));

Это немного, но это честный труд.

MockProvider чище и короче, чем его аналог provide/useValue, а createSpyFromClass позволяет нам создавать подсмотренные объекты без необходимости вводить имена каждого метода.

MockBuilder

Мы можем продолжать улучшать чистоту кода с помощью MockBuilder. MockBuilder использует шаблон построителя и свободный синтаксис для цепочки вызовов функций и лаконичного создания ng-mocks, эквивалентного TestBed. Взгляните на следующий пример, который настраивает AppComponent в нашем сопутствующем репозитории через configureTestingModule.

describe('AppComponent', () => {
  breeds = ['affenpinscher', 'african', 'airedale'];
  dogs = ['https://images.dog.ceo/breeds/affenpinscher/n02110627_10047.jpg'];
  dogService = createSpyFromClass(DogService);
  dogService.getBreeds.and.returnValue(of(breeds));
  dogService.getDogs.and.returnValue(of(dogs));
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [
        MockModule(FontAwesomeModule),
        MockModule(MatProgressSpinnerModule),
        MockModule(MatToolbarModule)
      ],
      declarations: [
        AppComponent,
        MockComponent(FormComponent),
        MockComponent(CardComponent)
      ],
      providers: [
        MockProvider(DogService, dogService)
      ]
    }).compileComponents();
  });
});

Используя MockBuilder, мы можем сократить нашу настройку на несколько строк кода.

describe('AppComponent', () => {
  beforeEach(async () => {
    breeds = ['affenpinscher', 'african', 'airedale'];
    dogs = ['https://images.dog.ceo/breeds/affenpinscher/n02110627_10047.jpg'];
    dogService = createSpyFromClass(DogService);
    dogService.getBreeds.and.returnValue(of(breeds));
    dogService.getDogs.and.returnValue(of(dogs));
    await MockBuilder(AppComponent, AppModule)
      .mock(FontAwesomeModule)
      .mock(MatProgressSpinnerModule)
      .mock(MatToolbarModule)
      .mock(CardComponent)
      .mock(DialogComponent)
      .mock(FormComponent)
      .provide({ provide: DogService, useValue: dogService });
  });
});

Мы цепляем вызовы mock, чтобы заменить наши настоящие модули и компоненты фиктивными эквивалентами. Функция Provide также поддерживает цепочку и позволяет нам внедрить нашу фиктивную версию DogService.

MockRender

Ng-mocks предоставляет MockRender для тестирования Inputs и Outputs, ChildContent и визуализации пользовательских шаблонов. Кроме того, MockRender предоставляет удобные методы find и findAll, которые возвращают правильно типизированные ссылки на дочерние компоненты.

Функции MockComponent и MockProvider используются в тандеме с TestBed Angular. MockRender обычно используется вместо TestBed и наиболее полезен для тестирования директив. Как и в случае с макетами/заглушками компонентов, документация Angular предлагает создать поддельный компонент для директивного тестирования.

@Component({
  template: `
  <h2 highlight="yellow">Something Yellow</h2>
  <h2 highlight>The Default (Gray)</h2>
  <h2>No Highlight</h2>
  <input #box [highlight]="box.value" value="cyan"/>`
})
class TestComponent { }

Тесты, предлагаемые Angular docs, довольно запутанны.

beforeEach(() => {
  fixture = TestBed.configureTestingModule({
    declarations: [ HighlightDirective, TestComponent ]
  })
  .createComponent(TestComponent);

  fixture.detectChanges(); // initial binding

  // all elements with an attached HighlightDirective
  des = fixture.debugElement.queryAll(By.directive(HighlightDirective));

  // the h2 without the HighlightDirective
  bareH2 = fixture.debugElement.query(By.css('h2:not([highlight])'));
});

// color tests
it('should have three highlighted elements', () => {
  expect(des.length).toBe(3);
});

it('should color 1st <h2> background "yellow"', () => {
  const bgColor = des[0].nativeElement.style.backgroundColor;
  expect(bgColor).toBe('yellow');
});

it('should color 2nd <h2> background w/ default color', () => {
  const dir = des[1].injector.get(HighlightDirective) as HighlightDirective;
  const bgColor = des[1].nativeElement.style.backgroundColor;
  expect(bgColor).toBe(dir.defaultColor);
});

it('should bind <input> background to value color', () => {
  // easier to work with nativeElement
  const input = des[2].nativeElement as HTMLInputElement;
  expect(input.style.backgroundColor)
    .withContext('initial backgroundColor')
    .toBe('cyan');

  input.value = 'green';

  // Dispatch a DOM event so that Angular responds to the input value change.
  input.dispatchEvent(new Event('input'));
  fixture.detectChanges();

  expect(input.style.backgroundColor)
    .withContext('changed backgroundColor')
    .toBe('green');
});

it('bare <h2> should not have a customProperty', () => {
  expect(bareH2.properties['customProperty']).toBeUndefined();
});

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

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

К счастью, ng-mocks позволяет разработчикам использовать MockRender для рендеринга небольших одноразовых шаблонов в своих тестах. Вот фрагмент документации ng-mocks, демонстрирующий тестовые примеры для директивы highlight.

it('uses default background color', () => {
    const fixture = MockRender('<div target></div>');

    expect(fixture.nativeElement.innerHTML).not.toContain(
      'style="background-color: yellow;"',
    );
});

it('sets provided background color', () => {
    const fixture = MockRender('<div [color]="color" target></div>', {
      color: 'red',
    });

    fixture.point.triggerEventHandler('mouseenter', null);
    expect(fixture.nativeElement.innerHTML).toContain(
      'style="background-color: red;"',
    );
});

Реализации директивы highlight в документах Angular и ng-mocks немного отличаются. Несмотря на это, использование MockRender позволяет разработчикам создавать небольшие изолированные программные среды, невероятно полезные для тестирования директив.

Собирая все это воедино

Вот пример шаблона AppComponent из сопутствующего репозитория.

<mat-toolbar color="primary">
  <span class="title">
    <fa-icon [icon]="faAngular" size="xl"></fa-icon>
    {{ title }}
  </span>
  <span class="spacer"></span>
  <a mat-icon-button href="https://github.com/bobbyg603/ng-testing-tips-ng-mocks" target="_blank" rel="noopener noreferrer"
    aria-label="ngx-testing-tips on GitHub">
    <fa-icon [icon]="faGithub"></fa-icon>
  </a>
  <a mat-icon-button href="https://bobbyg603.medium.com" target="_blank" rel="noopener noreferrer"
    aria-label="@bobbyg603 on Medium">
    <fa-icon [icon]="faMedium"></fa-icon>
  </a>
  <a mat-icon-button href="https://twitter.com/bobbyg603" target="_blank" rel="noopener noreferrer"
    aria-label="@bobbyg603 on Twitter">
    <fa-icon [icon]="faTwitter"></fa-icon>
  </a>
</mat-toolbar>
<div class="main-content">
  <div class="form">
    <div class="breeds">
      <app-form [breeds]="(breeds$ | async)!" [breed]="breed" [count]="count"
        (formChange)="onFormChange($event)"></app-form>
    </div>
  </div>
  <div class="cards">
    <mat-spinner *ngIf="loading$ | async"></mat-spinner>
    <app-card *ngFor="let dog of dogs$ | async" [imgSrc]="dog"></app-card>
  </div>
</div>

Вот логика, которая поддерживает шаблон компонента.

import { Component } from '@angular/core';
import { faAngular, faGithub, faMedium, faTwitter } from '@fortawesome/free-brands-svg-icons';
import { BehaviorSubject, Observable, Subject, tap } from 'rxjs';
import { DogService } from './dog/dog.service';
import { DogsForm } from './form/form.component';
import { nextTurn } from './utils/next-turn';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  readonly title = 'ng-testing-tips';

  readonly faAngular = faAngular;
  readonly faGithub = faGithub;
  readonly faMedium = faMedium;
  readonly faTwitter = faTwitter;
  
  breeds$: Observable<string[]>;
  dogs$: Observable<string[]>;
  loading$: Observable<boolean>;

  breed: string;
  count: number;
  
  private loadingSubject: Subject<boolean>;
 
  constructor(private dogService: DogService) {
    this.breed = 'husky';
    this.count = 3;
    this.loadingSubject = new BehaviorSubject(true);
    this.breeds$ = this.getBreeds();
    this.dogs$ = this.getDogs();
    this.loading$ = this.loadingSubject.asObservable().pipe(nextTurn());
  }

  onFormChange(form: DogsForm) {
    const { breed, count } = form;
    this.breed = breed;
    this.count = count;
    this.dogs$ = this.getDogs();
  }

  private getBreeds() {
    return this.dogService.getBreeds();
  }
  
  private getDogs() {
    this.loadingSubject.next(true);
 
    return this.dogService.getDogs(this.breed, this.count)
      .pipe(
        tap(() => this.loadingSubject.next(false))
      );
  }
}

Что важно протестировать в компоненте выше? Хороший способ ответить на вопрос, что тестировать, — это «что должен делать компонент, чтобы функционировать должным образом». Еще один хороший вопрос: «Как мы можем задокументировать, что делает этот компонент, чтобы его мог понять следующий человек, который будет над ним работать».

Кажется хорошей идеей убедиться, что заголовок отображается. У нас также есть форма ввода, которую необходимо инициализировать с правильными значениями по умолчанию для breeds, breed и count. Когда форма ввода изменяется, мы должны обработать событие изменения и обновить наш count. Если форма изменится, нам нужно убедиться, что мы вызываем нашу службу dogsService с обновленными значениями breed и count. Перед вызовом функции getDogs мы должны выдать значения для loading$, показать индикатор загрузки и скрыть индикатор загрузки, когда сетевой вызов вернется. Результат вызова getDogs должен быть передан через наблюдаемый объект Dogs$.

Разбив нашу задачу на более мелкие части и используя ng-mocks, написание наших тестов стало проще, чем кажется.

import { MatProgressSpinner, MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatToolbarModule } from '@angular/material/toolbar';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { createSpyFromClass, Spy } from 'jasmine-auto-spies';
import { MockBuilder, MockedComponentFixture, MockRender, ngMocks } from 'ng-mocks';
import { firstValueFrom, of, skip } from 'rxjs';
import { AppComponent } from './app.component';
import { AppModule } from './app.module';
import { CardComponent } from './card/card.component';
import { DialogComponent } from './dialog/dialog.component';
import { DogService } from './dog/dog.service';
import { FormComponent } from './form/form.component';

let dogService: Spy<DogService>;
let app: AppComponent;
let breeds: string[];
let dogs: string[];

let rendered: MockedComponentFixture<AppComponent>;
let cardComponents: CardComponent[];
let formComponent: FormComponent;

describe('AppComponent', () => {
  beforeEach(async () => {
    breeds = ['affenpinscher', 'african', 'airedale'];
    dogs = ['https://images.dog.ceo/breeds/affenpinscher/n02110627_10047.jpg'];
    dogService = createSpyFromClass(DogService);
    dogService.getBreeds.and.returnValue(of(breeds));
    dogService.getDogs.and.returnValue(of(dogs));
    await MockBuilder(AppComponent, AppModule)
      .mock(FontAwesomeModule)
      .mock(MatProgressSpinnerModule)
      .mock(MatToolbarModule)
      .mock(CardComponent)
      .mock(DialogComponent)
      .mock(FormComponent)
      .provide({ provide: DogService, useValue: dogService });
    rendered = MockRender(AppComponent, null, { detectChanges: false });
    app = rendered.point.componentInstance;
  });

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

  it(`should have as title 'ng-testing-tips'`, () => {
    expect(app.title).toEqual('ng-testing-tips');
  });

  describe('breeds$', () => {
    it('should emit result of getBreeds from DogService', () => {
      return expectAsync(firstValueFrom(app.breeds$)).toBeResolvedTo(breeds);
    });
  });

  describe('dogs$', () => {
    it('should emit result of getDogs from DogService', () => {
      return expectAsync(firstValueFrom(app.dogs$)).toBeResolvedTo(dogs);
    });
  });

  describe('loading$', () => {
    it('should start with true', () => {
      return expectAsync(firstValueFrom(app.loading$)).toBeResolvedTo(true);
    });

    it('should emit false after call to getDogs', async () => {
      const resultPromise = firstValueFrom(app.loading$.pipe(skip(1)));

      rendered.detectChanges();
      const result = await resultPromise;

      expect(result).toBe(false);
    });
  });

  describe('onFormChange', () => {
    it('should call getDogs with breed and count', () => {
      const breed = 'affenpinscher';
      const count = 3;

      app.onFormChange({ breed, count });

      expect(dogService.getDogs).toHaveBeenCalledWith(breed, count);
    });
  });

  describe('template', () => {
    beforeEach(() => {
      rendered.detectChanges();
      cardComponents = ngMocks.findAll(CardComponent).map(c => c.componentInstance);
      formComponent = ngMocks.find<FormComponent>('app-form').componentInstance;
    });

    it('should render title', () => {
      expect(rendered.nativeElement.querySelector('span.title')?.textContent).toMatch(app.title);
    });

    it('should pass breed to form', () => {
      expect(formComponent.breed).toBe(app.breed);
    });

    it('should pass breeds to form', () => {
      expect(formComponent.breeds).toBe(breeds);
    });

    it('should pass count to form', () => {
      expect(formComponent.count).toBe(app.count);
    });

    it('should create card for each dog', () => {
      dogs.forEach(dog => {
        expect(cardComponents.find(c => c.imgSrc === dog)).toBeTruthy();
      });
    });

    it('should show spinner when loading', () => {
      rendered.componentInstance.loading$ = of(true);
      rendered.detectChanges();
      expect(ngMocks.find(MatProgressSpinner)).toBeTruthy();
    });

    it('should not show spinner when not loading', () => {
      rendered.componentInstance.loading$ = of(false);
      rendered.detectChanges();
      expect(ngMocks.findAll(MatProgressSpinner).length).toBe(0);
    });

    it('should call onFormChange when form component raises formChange event', () => {
      const event = { breed: 'affenpinscher', count: 3 };
      const spy = spyOn(rendered.componentInstance, 'onFormChange');

      formComponent.formChange.emit(event);

      expect(spy).toHaveBeenCalledWith(event);
    });
  });
});

Найдите минутку, чтобы прочитать набор тестов, вы заметите, что большинство тестов просты и легки в написании (особенно с CoPilot). В beforeEach мы передали {detectChanges: false} в MockRender, чтобы более точно имитировать работу TestBed и позволить нам выполнять утверждения в отношении класса компонента без обнаружения или рендеринга изменений.

Функция firstValueFrom RxJS помогает нам преобразовывать наблюдаемые объекты в промисы, а expectAsync позволяет нам создавать тесты с одним выражением. Мы также можем использовать firstValueFrom с пропуском, например, для тестирования последующих выбросов при loading$.

Наши тесты шаблонов используют ngMocks.detectChanges для визуализации тестируемого компонента. Мы используем find и findAll для получения ссылок на различные компоненты DebugElements. Используя карту, мы можем преобразовывать коллекции DebugElements в коллекции NativeElements, запрашивать DOM с помощью querySelector и выполнять различные ожидания. Мы можем делать простые утверждения для входных данных дочернего компонента, а MockRender автоматически создает EventEmitters для выходных данных компонента, поэтому мы можем проверить привязку между событием formChange дочернего компонента и обработчиком onFormChange родительского.

#Angular #TypeScript #Testing
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

В этом месте могла бы быть ваша реклама

Разместить рекламу