Сразу скажу, что я не любитель 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 так «балуются».
Автор: jsfun