TestBed
Some stats...
Test method | Duration |
---|---|
test (as-is) | 3s (stable) |
test (as-is) | 45s (flaky) |
(without optimization) | 7.5s (stable) |
E2E
(UI)
Integration
Unit
Isolation
Integration
Fast
Slow
Test pyramid
Components
E2E
(UI)
(Semi) Integration
Unit
Critical user flow testing
Render checks + Edge case testing
Isolated testing
?
Production-like
Test env
+
E2E
App
App
App
...
Critical flows
- Limited tests that prove our site main flows work
- Test simulates a user on our site
- Flows could be determined by business
E2E
(UI)
==
All user flows work
==
Deploy with confidence
==
Automatic deployment?
So, what do we need to achieve this?
Production-like test env
Proper mock solution
- Strong coupling with GSM content schema
- Easier to configure E2E flows
- Easier to maintain
Component testing
Integration
Unit
TagSearch
Filters
...
...
FiltersPanel
...
...
Smart/Container
Dumb/Presenter
Dumb/Presenter
[data]
(event)
Search
Unit testing components
FiltersPanel
<feature-filters-option
*ngFor="
let childFilter of filter.children
| slice
: 0
: (showAllOptions[filter.id]
? filter.children.length
: maxShown);
"
>
...
<div
...
[hidden]="
filter.children.length <= maxShown ||
showAllOptions[filter.id]
"
>
<rfs2-link
...
(rfs2Click)="showAll(filter.id)">
showAll(id: string) {
this.showAllOptions[id] = true;
}
it('should show all filter children', () => {
component.showAll('123');
expect(component.showAllOptions['123']).toBeTruthy();
});
TestBed setup
describe('FiltersPanelComponent', () => {
let fixture: ComponentFixture<FiltersPanelComponent>;
let component: FiltersPanelComponent;
let pageObject: FiltersFeaturePageObject;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [GlobalSitesTestingModule],
declarations: [FiltersPanelComponent, FiltersOptionComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
}).compileComponents();
fixture = TestBed.createComponent(FiltersPanelComponent);
component = fixture.componentInstance;
pageObject = new FiltersPanelPageObject(fixture.nativeElement);
});
it('should render', () => {
expect(component).not.toBeNull();
});
});
FiltersPanel
beforeEach(() => {
component.filters = FILTER_WITH_6_CHILDREN;
fixture.detectChanges();
});
it('should show a limited amount of children', () => {
expect(pageObject.childOptions.length).toBe(3);
});
it('should show all children on show more', () => {
pageObject.clickShowMore(0);
fixture.detectChanges();
expect(pageObject.childOptions.length).toBe(6);
});
FiltersPanel
it('should activate a filter option', () => {
const spy = jest.fn();
component.activate.subscribe(spy);
component.toggleFilter(DEACTIVE_FILTER);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(DEACTIVE_FILTER);
});
it('should activate a filter option', () => {
const spy = jest.fn();
component.activate.subscribe(spy);
pageObject.filtersOptions[1].activate();
fixture.detectChanges();
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(DEACTIVE_FILTER);
});
activate(): void {
this.element.dispatchEvent(
new CustomEvent('rfs2Change', {
detail: true,
})
);
}
Filters
<div
*sensesIfBreakpoint="'lg'; to 'xl'"
<!-- Filters -->
...
<ng-container *sensesIfBreakpoint="'xs'; to 'md'">
<feature-filters-modal>
test.each([Breakpoint.xs, Breakpoint.sm, Breakpoint.md])(
'should render on breakpoint %p',
(breakpoint: Breakpoint) => {
windowObserver.breakpoint$ = of(
breakpoint
);
fixture.detectChanges();
expect(pageObject.getModalButton()).not.toBeNull();
}
);
test.each([Breakpoint.lg, Breakpoint.xl])(
'should not render on breakpoint %p',
(breakpoint: Breakpoint) => {
windowObserver.breakpoint$ = of(
breakpoint
);
fixture.detectChanges();
expect(pageObject.getModalButton()).toBeNull();
}
);
Desktop
Mobile
Integration testing components
Article render checks
it('should render', () => {
fixture.detectChanges();
expect(pageObject.relatedMedia.isRendered()).toBeTruthy();
expect(pageObject.relatedMedia.mediaCards.isRendered()).toBeTruthy();
expect(pageObject.relatedMedia.loadMore.isRendered()).toBeTruthy();
// ...
});
Load more related media
Setup integration test
let legacySearchService: Mock<LegacySearchService, jest.SpyInstance>;
beforeEach(() => {
legacySearchService = TestBed.get(LegacySearchService);
jest.spyOn(legacySearchService, 'search');
jest
.spyOn(TestBed.get(HttpClient) as HttpClient, 'get')
.mockReturnValueOnce(of(SEARCH_RESPONSE_3))
.mockReturnValueOnce(of(SEARCH_RESPONSE_6))
.mockReturnValueOnce(of(SEARCH_RESPONSE_7));
});
Test load more results
it('should load more results', () => {
fixture.detectChanges();
expect(legacySearchService.search).toHaveBeenCalledTimes(1);
let searchParams: LegacySearchParams =
legacySearchService.search.mock.calls[0][0];
expect(searchParams.numberOfResults).toBe(3);
expect(pageObject.countMediaCards()).toBe(3);
pageObject.clickLoadMoreButton();
fixture.detectChanges();
expect(legacySearchService.search).toHaveBeenCalledTimes(2);
searchParams = legacySearchService.search.mock.calls[1][0];
expect(searchParams.numberOfResults).toBe(6);
expect(pageObject.countMediaCards()).toBe(6);
});
Test load more button
it('should show load more button only if more results exist', () => {
fixture.detectChanges();
expect(pageObject.relatedMedia.loadMore.isRendered()).toBeTruthy();
pageObject.clickLoadMoreButton(); // 3 -> 6 results
fixture.detectChanges();
pageObject.clickLoadMoreButton(); // 6 -> 7 results
fixture.detectChanges();
expect(pageObject.relatedMedia.loadMore.isRendered()).toBeFalsy();
});
hasLoadMore(): boolean {
return this.data.cards.length < this.totalCount;
}
Complex examples
Translations code
private sortingOptions = [SortDirection.DESC, SortDirection.RELEVANCY];
ngOnInit() {
this.translateService.setTranslations(translations);
this.sortingLinks = this.sortingOptions.map(sort => ({
name: this.translateService.instant(sort),
}));
}
Translations test
it('should translate sorting values', () => {
const DESC_TRANSLATED = 'DESC TRANSLATED';
const RELEVANCY_TRANSLATED = 'RELEVANCY TRANSLATED';
jest
.spyOn(sensesTranslateService, 'instant')
.mockReturnValueOnce(DESC_TRANSLATED)
.mockReturnValueOnce(RELEVANCY_TRANSLATED);
fixture.detectChanges();
expect(sensesTranslateService.instant).toHaveBeenCalledTimes(2);
expect(sensesTranslateService.instant).toHaveBeenNthCalledWith(
1,
SortDirection.DESC
);
expect(sensesTranslateService.instant).toHaveBeenNthCalledWith(
2,
SortDirection.RELEVANCY
);
expect(component.sortingLinks).toEqual([
{ name: DESC_TRANSLATED },
{ name: RELEVANCY_TRANSLATED },
]);
pageObject.toggleSortDropdownMenu();
fixture.detectChanges();
expect(pageObject.sortDropdownMenu.getLinkValues()).toEqual([
DESC_TRANSLATED,
RELEVANCY_TRANSLATED,
]);
});
Async code
// Async animation
animations: [
trigger('openModalAnimation', [
transition(':enter', [
style({ transform: 'translateX(100%)' }),
animate('300ms ease-out', style({ transform: 'translateX(0)' })),
]),
transition(':leave', [
animate('150ms ease-in', style({ transform: 'translateX(100%)' })),
]),
]),
],
<div (sensesClickOutside)="close.emit()">
...
</div>
setTimeout(() =>
this.document.addEventListener(
'click',
this.clickHandler,
this.useCapturing
)
);
Async test
beforeEach(fakeAsync(() => {
TestBed.get(WindowObserver).breakpoint$ = of(
Breakpoint.md
);
fixture.detectChanges();
pageObject.openModal();
fixture.detectChanges();
tick(CLICK_OUTSIDE_DIRECTIVE_TIMEOUT_DURATION); // 0
}));
it('should close filters modal', fakeAsync(() => {
document.dispatchEvent(new MouseEvent('click'));
fixture.detectChanges();
tick(FILTERS_MODAL_COMPONENT_ANIMATION_OUT_DURATION);
expect(pageObject.modal.isRendered()).toBeFalsy();
}));
Future nice-to-haves
- NgBullet (TestBed optimization)
- Spectator (TestBed wrapper)
- Jest snapshot testing
- Storybook visual regression testing
Questions
TestBed
By rachnerd
TestBed
- 163