
Сразу скажу, что я не любитель Angular1, angular-way и иже с ними, потому как ребята из Angular таких делов наворотили, что иногда диву даешься. Тем не менее, их новое детище выглядит многообещающе. Да, Америку не открыли, но создали нечто, способное конкурировать с популярными современными фреймворками (React + Redux, Aurelia, и т.д.).
Есть и плюсы, и минусы, о которых уже написаны статьи и даже книги, но суть поста в другом.
RC5 вышел всего неделю назад и «порадовал» разработчиков многими изменениями, которые, возможно, и помогают в работе и упрощают жизнь, но заставят серьёзно попотеть над переписыванием уже написанного кода.
Удивлению моему не было предела, когда я узнал, что, выпустив новую версию в rc5, ребята забыли обновить раздел с Тестированием, в котором полезной информации и так «кот наплакал».
Поскольку найти интересующую меня информацию пока не удалось, пришлось разобраться. Надеюсь, информация поможет тем, кто страдает прямо сейчас над тем, что переходит с rc4 на rc5 и его, с такой любовью написанные, тесты — лежат. Здесь не будет ни конфигураций, ни огромных кусков кода и информация рассчитана на тех, кто уже знает азы Angular2.
Прикинем базовую структуру приложения:
— app
— app.component.ts
— app.module.ts
— main.ts
— components
— table.component.ts
— services
— post.service.ts
— models
— post.model.ts
— test
— post.service.mock.ts
— table.component.spec.ts
— post.model.spec.ts
— post.service.spec.ts
Здесь и дальше я буду использовать примеры на TypeScript, потому что код, написанный на нем, как по мне, выглядит слегка живее и интереснее. В примере будет описано приложение, которое создает таблицу и отрисовывает её. Просто и понятно, чтобы нагляднее обьяснить, как теперь писать тесты.
app.component — это первый компонент, который будет загружен, после инициализации приложения.
// Angular import { Component } from '@angular/core'; // Services import {PostService} from './app/services/post.service'; import {Post} from './app/models/post.model'; @Component({ selector: 'app', template: ` <div *ngIf="isDataLoaded"> <table-component [post]="post"></table-component> </div> ` }) export class AppComponent { public isDataLoaded: boolean = false; public post: Post; constructor(public postService: PostService) {} ngOnInit(): void { this.postService.getPost().subscribe((post: any) => { this.post = new Post(post); this.isDataLoaded = true; }); } }
app.module — нововведение в rc5, хранит в себе все зависимости модуля. В нашем случае, провайдит PostService и TableComponent.
import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { HttpModule } from '@angular/http'; // Components import { AppComponent } from './app/app.component'; import {TableComponent} from './app/components/table/table.component'; // Services import {PostService} from './app/services/post.service'; @NgModule({ declarations: [ AppComponent TableComponent ], imports: [ BrowserModule, HttpModule ], providers: [ PostService ], bootstrap: [AppComponent] }) export class AppModule {}
main — точка входа в приложение, которую использует Webpack, SystemJS, и т.д.
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; import { AppModule } from './app/app.module'; platformBrowserDynamic().bootstrapModule(AppModule);
table.component — компонента, которую хотим отрисовать.
// Angular import {Component, Input} from '@angular/core'; @Component({ selector: 'table-component', template: `<table> <thead> <tr> <th>Post Title</th> <th>Post Author</th> </tr> </thead> <tbody> <tr> <td>{{ post.title}}</td> <td>{{ post.author}}</td> </tr> </tbody> </table>` }) export class TableComponent { @Input() public post: any; }
post.service — Injectable сервис, который делает АПИ запросы и вытягивает пост
import {Injectable} from '@angular/core'; import {Observable} from 'rxjs/Rx'; import {Post} from './app/models/post.model'; import { Http } from '@angular/http'; @Injectable() export class PostService { constructor(http: Http) {} public getPost(): any { // Используем абстрактный АПИ - будь то Facebook или Google return this.http.get(AbstractAPI.url) .map((res: any) => res.json()) } }
post.model — класс поста, в который мы обернем голый JSON.
export class Post { public title: number; public author: string; constructor(post: any) { this.title = post.title; this.author = post.author; } }
Наше приложение готово и работает, но как же это все тестировать?
Я, в целом, фанат TDD, по-этому сначала пишу тесты, а потом — код, и для меня очень важно делать это, как можно проще и быстрее.
Я для тестов использую Karma + Jasmine и примеры будут строиться на основе этих инструментов.
Изменения, коснувшееся всех типов тестов( моделей, сервисов, компонент) — убрали {it, describe} из angular/core/testing. Теперь они deprecated и тянуться из фреймворка( в моем случае из Karma).
Также изменилась и загрузка стандартных модулей для тестов:
Было:
import {setBaseTestProviders} from '@angular/core/testing'; import { TEST_BROWSER_DYNAMIC_PLATFORM_PROVIDERS, TEST_BROWSER_DYNAMIC_APPLICATION_PROVIDERS } from '@angular/platform-browser-dynamic/testing'; setBaseTestProviders( TEST_BROWSER_DYNAMIC_PLATFORM_PROVIDERS, TEST_BROWSER_DYNAMIC_APPLICATION_PROVIDERS );
Стало:
import {TestBed} from '@angular/core/testing'; import {BrowserDynamicTestingModule, platformBrowserDynamicTesting} from '@angular/platform-browser-dynamic/testing'; TestBed.initTestEnvironment( BrowserDynamicTestingModule, platformBrowserDynamicTesting() );
Теперь, на любой чих, надо создавать тестовые @NgModule:
Пример с формами:
Было:
import {disableDeprecatedForms, provideForms} from @angular/forms; bootstrap(App, [ disableDeprecatedForms(), provideForms() ]);
Стало:
import {DeprecatedFormsModule, FormsModule, ReactiveFormsModule} from @angular/common; @NgModule({ declarations: [MyComponent], imports: [BrowserModule, DeprecatedFormsModule], boostrap: [MyComponent], }) export class MyAppModule{}
Было еще несколько изменений, но детальнее прочитать можно в будущем посте от Angular.
Начнем с простых тестов:
post.model.spec — тут все просто, тянем реальную модель и тестируем свойства.
import {Post} from './../app/models/post.model'; let testPost = {title: 'TestPost', author: 'Admin'} describe('Post', () => { it('checks Post properties', () => { var post = new Post(testPost); expect(post instanceof Post).toBe(true); expect(post.title).toBe("testPost"); expect(post.author).toBe("Admin"); }); });
Продолжим с сервисами, где все немного сложнее, но в целом концепция не поменялась.
post.service.spec — напишем тесты и для сервиса, который дёргает API:
import { inject, fakeAsync, TestBed, tick } from '@angular/core/testing'; import {MockBackend} from '@angular/http/testing'; import { Http, ConnectionBackend, BaseRequestOptions, Response, ResponseOptions } from '@angular/http'; import {PostService} from './../app/services/post.service'; describe('PostService', () => { beforeEach(() => { // Сделаем все нужные тестовые сервисы TestBed.configureTestingModule({ providers: [ PostService, BaseRequestOptions, MockBackend, { provide: Http, useFactory: (backend: ConnectionBackend, defaultOptions: BaseRequestOptions) => { return new Http(backend, defaultOptions); }, deps: [MockBackend, BaseRequestOptions]} ], imports: [ HttpModule ] }); }); describe('getPost methods', () => { it('is existing and returning post', // Заинстанциируем все необходимые сервисы inject([PostService, MockBackend], fakeAsync((ps: postService, be: MockBackend) => { var res; // Эмулируем соединения с сервером backend.connections.subscribe(c => { expect(c.request.url).toBe(AbstractAPI.url); let response = new ResponseOptions({body: '{"title": "TestPost", "author": "Admin"}'}); c.mockRespond(new Response(response)); }); ps.getPost().subscribe((_post: any) => { res = _post; }); // Функция подождет, пока выполнится запрос tick(); expect(res.title).toBe('TestPost'); expect(res.author).toBe('Admin'); })) ); }); });
Осталось, собственно, самое сложное — написать тесты для самого компонента. Именно этого типа тестов и коснулись наибольшие изменения.
Перед тем, как обьяснить в деталях, что изменилось — хотел бы создать MockPostService, на который буду ссылаться.
post.service.mock — здесь мы будем перезаписывать реальные методы сервиса, чтобы он не делал запросы, а просто возвращал тестовые данные.
import {PostService} from './../app/services/post.service'; import {Observable} from 'rxjs'; export class MockPostService extends PostService { constructor() { // Унаследуемся от реального сервиса super(); } // Перезапишет реальный метод сервиса на копию, чтобы не делать ненужных запросов getPost() { // Поскольку Http использует Observable, нам необходимо сделать тестовый Observable обьект. return Observable.of({title: 'TestPost', author: 'Admin'}); } }
Ранее тест для компонента выглядел так:
import { inject, addProviders } from '@angular/core/testing'; import {TableComponent} from './../app/components/table/table.component'; // Стандартный билдер компонентов от Ангулар. Позволяет создавать тестовые данные компонентов и перезаписывать свойства компонентов import {TestComponentBuilder} from '@angular/core/testing'; @Component({ selector : 'test-cmp', template : '<table-component [post]="postMock"></table-component>' }) class TestCmpWrapper { public postMock = new Post({'title': 'TestPost', 'author': 'Admin'}); } describe("TableComponent", () => { it('render table', inject([TestComponentBuilder], (tcb) => { return tcb.overrideProviders(TableComponent) .createAsync(TableComponent) // В fixture храниться все информация об отрисованном компоненте. Если в компоненте отрисованы другие компоненты, они будут доступны fixture.debugElement.children. .then((fixture) => { let componentInstance = fixture.componentInstance; let nativeElement = jQuery(fixture.nativeElement); componentInstance.post = new Post({title: 'TestPost', author: 'Admin'}); fixture.detectChanges(); let firstTable = nativeElement.find('table'); expect(firstTable.find('tr td:nth-child(1)').text()).toBe('TestPost'); expect(firstTable.find('tr td:nth-child(2)').text()).toBe('Admin'); }); })); });
Стало:
import {Component} from '@angular/core'; // TestComponentBuilder заменили на TestBed, и расширили несколькими методами. import {TestBed, async} from '@angular/core/testing'; import {Post} from './../app/models/post.model'; import {TableComponent} from './../app/components/table/table.component'; // Services import {PostService} from './../app/services/post.service'; import {MockPostService} from './post.service.mock' // Создаем тестовый компонент и передаем созданные тестовые данные. @Component({ selector : 'test-cmp', template : '<table-component [post]="postMock"></table-component>' }) class TestCmpWrapper { public postMock = new Post({'title': 'TestPost', 'author': 'Admin'}); } describe("TableComponent", () => { // Нововведение - Необходимо создать тестовый модуль, чтобы в нем создать все зависимости. beforeEach(() => { TestBed.configureTestingModule({ declarations: [ TestCmpWrapper, TableComponent ], providers: [ {provide: PostService, useClass: MockPostService ] }); }); describe('check rendering', () => { it('if component is rendered', async(() => { // Убрали методы createAsync() на compoleComponents() + createComponent(). Первый - компилит все компоненты, которые присутствуют TestCmpWrapper, второй - создает тестовый компонент. Остальное - не тронули. TestBed.compileComponents().then(() => { let fixture = TestBed.createComponent(TestCmpWrapper); let componentInstance = fixture.componentInstance; let nativeElement = jQuery(fixture.nativeElement); componentInstance.post = new Post({title: 'TestPost', author: 'Admin'}); fixture.detectChanges(); let firstTable = nativeElement.find('table'); expect(firstTable.find('tr td:nth-child(1)').text()).toBe('TestPost'); expect(firstTable.find('tr td:nth-child(2)').text()).toBe('Admin'); }); })); }); });
Внимательно читайте комментарии в самом коде — там есть небольшие разьяснения.
Комментарии — приветствуются и даже необходимы!
Да прибудет с нами Сила, потому что уже не знаю, чего ожидать от этих ребят, если они в RC так «балуются».
