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