Skip to main content

Keep Your Code Testable

Separation Between Component and Services

Components can contain a lot of observables and business logic.
To keep components lightweight, we prefer to move all logic into a component service.

The component service exposes observables that the component can expose 1-to-1 to the template.
All interactions in the template are also passed through the component to the service.

⚠️ Component services should not be provided in root, since they are only used in one place.
Component services must not be reused outside the relevant component.
Component services should be provided in the component itself.

When writing unit tests, make sure to properly mock them:

Example Component Using a Component Service

export class component implements OnInit, OnDestroy {
#titleService = inject(TitleService);
#overviewService = inject(OverviewService);
#methodService = inject(MethodService);
#adminService = inject(AsAdminService);

loadingData$ = this.#overviewService.loadingData$;
overviewData$ = this.#overviewService.overviewData$;
asAdmin$ = this.#adminService.asAdmin$;
methodFetchError$ = this.#methodService.methodFetchError$;

ngOnInit() {
this.#titleService.setKey('overview');
}

toggleAsAdmin = () => this.#asAdminService.toggleAsAdmin();

constructor() {
this.#overviewService.initializeDataFetching();
}

ngOnDestroy() {
this.#overviewService.destroy();
}
}

Templates Should Not Communicate with Themselves

If a template communicates directly with itself, it's no longer possible to intercept with a unit test — in this case, you'll need an integration or end-to-end test.

❌ Bad Example

<div>
<button (click)="myComponent.hide()" />
<myComponent #myComponent />
</div>

✅ Good Example

<div>
<button (click)="hideMyComponent()" />
<myComponent #myComponent />
</div>

Use a data-test Attribute for Every Selector

This prevents tests from breaking due to CSS class or tag name changes.
It also indicates during template refactoring that tests are associated with a specific HTML element.

The data-test attribute also makes it easier to recover broken tests and can be used by Cypress if needed.

❌ Bad Example

const buttonElement = fixture.debugElement.query(By.css('h1'));

✅ Good Example

const buttonElement = fixture.debugElement.query(By.css('[data-test="title"]'));

Don't Test Existing Interactions

It can be tempting to trigger click events on components, but this ends up testing Angular core features.
Instead, have click events call functions directly, which can also be called by unit tests.

This avoids timing issues often encountered in integration and end-to-end tests.

❌ Bad Example

const buttonElement: HTMLButtonElement = fixture.nativeElement.querySelector('button');
buttonElement.click();
fixture.detectChanges();

expect(component.count).toBe(1);

✅ Good Example

const element: HTMLElement = fixture.debugElement.query(
By.css(`[data-test="my-component"]`)
).nativeElement;

element.nativeElement.dispatchEvent(new Event('clicked'));

expect(component.count).toBe(1);

Use Faker Factories

In unit tests, you often need a lot of data.
Avoid creating objects that loosely resemble the types they’re meant to imitate.

❌ Bad Practice

Disadvantages:

  • Old tests break when new required fields are added
  • Refactoring variables is not picked up by your IDE
  • You often define more than needed for a specific test

✅ Good Practice

Advantages:

  • New fields are automatically included
  • IDE handles renamed variables
  • Unspecified fields are filled with faker values
  • No need to force type definitions
  • Type mismatches throw factory errors instead of failing tests mysteriously

Example Using a Faker Factory

const list = FakerFactory.create({ title: 'title' });

const methods = [
FakerMethodFactory.create({ code: 'abc' }),
FakerMethodFactory.create({ code: 'def' }),
];

Factory Implementation

import { faker } from '@faker-js/faker';
import { Group } from '../group/index';

export class FakerGroupFactory {
static create(data: Partial<Group>): Group {
return {
id: faker.string.uuid(),
name: faker.word.words({ count: { min: 1, max: 4 } }),
scope: '',
description: faker.lorem.lines(1),
manual: faker.helpers.arrayElement([true, false]),
...data,
} as Group;
}
}