Angular Testing Mastery: The Best Practices to Skyrocket Your Skills! — Examples

Antoine Audusseau
Shippeo Tech Blog
Published in
8 min readFeb 27, 2024

This article is here to provide you with some tips for testing Angular components, services, or other classes. It follows a previous article where you can find tips and best practices: https://medium.com/shippeo-tech-blog/angular-testing-mastery-the-best-practices-to-skyrocket-your-skills-369ef71a4456.

Photo by Kelly Sikkema from Unsplash

Before beginning, I want to clarify that these examples are purely subjective; they are based only on my personal experience as an Angular frontend developer and on what we learn at Shippeo.

These tests are made with the Angular framework, combined with the Jest testing framework. Jest is not the default testing framework for Angular; it is a good choice as a substitute for Karma, which will be removed in future Angular versions. In addition, I use the Spectator and ng-mocks libraries, which simplify the Angular tests a lot by providing very useful tools. I highly recommend these libraries; Angular tests are quite verbose right now, and I hope they will evolve in the future.

Angular, Jest and Spectator logos

All the code you will see is available in this repository: https://github.com/a-audusseau/angular-tests-examples

Testing components

First of all, remember that the purpose of a component is (in most cases) to deliver HTML to the user. So you need to test the template and interact with it. Do not skip it by directly testing component methods or properties. All public methods and properties are supposed to be used in the template; if they don’t, they should be private and not available directly for the tests.

Ok, now let’s see our component 👀

@Compontyent({
selector: 'app-pizza',
standalone: true,
imports: [AsyncPipe, PizzaIngredientListComponent],
styles: `
.red { color: red; }
`,
template: `
<h2 class="title" [class.red]="!pizza.isAvailable">{{ pizza.name }}</h2>

<app-pizza-ingredient-list [pizza]="pizza" />

@if (isInCart$ | async) {
<p>In the cart</p>
} @else { @if (pizza.isAvailable) {
<button (click)="addToCart()" data-test="add-to-card">Add to card</button>
} @else {
<p>Currently not available</p>
} }
`,
})
export class PizzaComponent {
@Input({ required: true }) pizza!: Pizza;

public readonly isInCart$: Observable<boolean> = this.pizzaService.cart$.pipe(
map((cart) => !!cart.find(({ id }) => id === this.pizza.id)),
distinctUntilChanged()
);

constructor(private readonly pizzaService: PizzaService) {}

public addToCart(): void {
if (this.pizza.cost > 12) {
console.log('wow this is expensive');
}

this.pizzaService.addToCard(this.pizza);
}
}

This component takes a pizza as input, displays some information depending on the state, and can render a button to add the pizza to the cart.

Let's start by configuring our tests for this component. The first step is to mock all dependencies by checking the constructor and the template. It is not always required, but this way we avoid the complexity of our dependencies, and it can make the tests faster.

So for this component, we have a dependency on the PizzaService and on the PizzaIngredientsListComponent component. Here are the results with comments:

describe('PizzaComponent', () => {
let spectator: Spectator<PizzaComponent>;

const cart$ = new BehaviorSubject<Pizza[]>([]);
// createSpyObject let us create a PizzaService instance with all methods replaced by Jest spy. We can also overridres properties, so now we have the control of the cart$ Observable.
const pizzaService = createSpyObject(PizzaService, { cart$ });
const pizza: Pizza = {
id: 2,
name: 'Pepperoni Paradise',
cost: 12,
ingredients: ['Tomato', 'Mozzarella', 'Pepperoni'],
isAvailable: true,
};

const createComponent = createComponentFactory({
component: PizzaComponent,
// Mock services
providers: [{ provide: PizzaService, useValue: pizzaService }],
// Mock components
overrideComponents: [
[
PizzaComponent,
{
add: { imports: [MockComponent(PizzaIngredientListComponent)] },
remove: { imports: [PizzaIngredientListComponent] },
},
],
],
});

// the pizza Input is required, so we need to fill it when creating the component.
beforeEach(() => (spectator = createComponent({ props: { pizza } })));

it('should create the component', () => {
expect(spectator.component).toBeTruthy();
});
});

Okay, now let’s just look at the template and create tests for everything we see. The most important things are the dynamic ones, like if/else or for statements. For instance, it can be:

it('should display the pizza name', () => {...});
describe('when the pizza is not available', () => {
it('should add the class red to the title ', () => {...})
});
it('should display the pizza ingredient list', () => {...});
describe('when the pizza is in the cart', () => {
it('should display a message to indicate it', () => {...})
});
describe('when the pizza is not in the cart', () => {
describe('and it is available', () => {
it('should display the add to card button', () => {...})
describe('and the add to card button is clicked', () => {
it('should call the PizzaService addToCard method', () => {...})
});
});
describe('and it is unavailable', () => {
it('should display the unavailable message', () => {...});
});
});

Just by looking at the template, we have a nice list of tests to do 😍, so let’s write them now:

// You can add specific attributes to target the HTML elements, like the data-test attribute. It can help for your Angular tests and also for E2E tests if you have some.
const addToCardSelector = '[data-test="add-to-card"]';
const titleSelector = '.title';

// the pizza Input is required, so we need to fill it when creating the component.
beforeEach(() => (spectator = createComponent({ props: { pizza } })));

it('should display the pizza name', () => {
expect(spectator.query(titleSelector)).toBeTruthy();
expect(spectator.query(titleSelector)).toHaveExactText(pizza.name);
expect(spectator.query(titleSelector)).not.toHaveClass('red');
});

describe('when the pizza is not available', () => {
beforeEach(() =>
spectator.setInput({ pizza: { ...pizza, isAvailable: false } })
);

it('should add the class red to the title ', () => {
expect(spectator.query(titleSelector)).toHaveClass('red');
});
});

it('should display the pizza ingredient list', () => {
expect(spectator.query(PizzaIngredientListComponent)).toBeTruthy();
expect(spectator.query(PizzaIngredientListComponent)?.pizza).toEqual(pizza);
});

describe('when the pizza is in the cart', () => {
beforeEach(() => {
cart$.next([pizza]);
spectator.detectChanges();
});

it('should display a message to indicate it', () => {
expect(spectator.query(byText('In the cart'))).toBeTruthy();
expect(spectator.query(byText('Currently not available'))).toBeFalsy();
expect(spectator.query(addToCardSelector)).toBeFalsy();
});
});

describe('when the pizza is not in the cart', () => {
beforeEach(() => {
cart$.next([]);
spectator.detectChanges();
});

describe('and it is available', () => {
beforeEach(() =>
spectator.setInput({ pizza: { ...pizza, isAvailable: true } })
);

it('should display the add to card button', () => {
expect(spectator.query(addToCardSelector)).toBeTruthy();
expect(spectator.query(addToCardSelector)).toHaveExactText(
'Add to card'
);
expect(spectator.query(byText('In the cart'))).toBeFalsy();
expect(spectator.query(byText('Currently not available'))).toBeFalsy();
});

describe('and the add to card button is clicked', () => {
beforeEach(() => spectator.click(addToCardSelector));

it('should call the PizzaService addToCard method', () => {
expect(pizzaService.addToCard).toHaveBeenCalledWith(pizza);
});
});
});

describe('and it is unavailable', () => {
beforeEach(() =>
spectator.setInput({ pizza: { ...pizza, isAvailable: false } })
);

it('should display the unavailable message', () => {
expect(spectator.query(byText('Currently not available'))).toBeTruthy();
expect(spectator.query(addToCardSelector)).toBeFalsy();
expect(spectator.query(byText('In the cart'))).toBeFalsy();
});
});
});

Now if we take a look at the coverage, we can see that we already cover almost all the component code. But we also can see that one branch is still not covered; this is when the coverage report is useful.

So let's add the test to fix this:

describe('when the pizza is not in the cart', () => {
[...]
describe('and it is available', () => {
[...]
describe('and the add to card button is clicked with a pizza cost over 12', () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();

beforeEach(() => {
spectator.setInput({
pizza: { ...pizza, isAvailable: true, cost: 18 },
});
spectator.click(addToCardSelector);
});

it('should call the PizzaService addToCard method and log a message', () => {
expect(consoleSpy).toHaveBeenCalledWith('wow this is expensive');
expect(pizzaService.addToCard).toHaveBeenCalledWith(pizza);
});
});
});
});

Congrats 👏, now we have a pretty well-tested component!

Testing Services

After the component, let’s see the other main Angular class, services! In our demo app, we have one service used to get the list of pizzas and manage the cart.

@Injectable({ providedIn: 'root' })
export class PizzaService {
private readonly pizzas$$ = new BehaviorSubject<Pizza[]>(PIZZA_LIST);
public readonly pizzas$ = this.pizzas$$.asObservable();
private readonly cart$$ = new BehaviorSubject<Pizza[]>([]);
public readonly cart$ = this.cart$$.asObservable();

public addToCard(pizza: Pizza): void {
const index = this.pizzas.findIndex(({ id }) => id === pizza.id);

if (index === -1 || !pizza.isAvailable) {
return;
}

this.cart$$.next([...this.cart, pizza]);
}

public removeFromCard(pizza: Pizza): void {
this.cart$$.next([...this.cart.filter(({ id }) => id !== pizza.id)]);
}

private get cart(): Pizza[] {
return this.cart$$.getValue();
}

private get pizzas(): Pizza[] {
return this.pizzas$$.getValue();
}
}

At Shippeo, we decided to test services method by method (or properties) to improve readability. Let’s see the result with the PizzaService:

describe('PizzaService', () => {
let spectator: SpectatorService<PizzaService>;

const pizza: Pizza = {
id: 2,
name: 'Pepperoni Paradise',
cost: 12,
ingredients: ['Tomato', 'Mozzarella', 'Pepperoni'],
isAvailable: true,
};

const createService = createServiceFactory(PizzaService);

beforeEach(() => (spectator = createService()));

describe('The addToCard method', () => {
describe('when the pizza is not in the pizza list', () => {
it('should not add it to the cart', async () => {
spectator.service.addToCard({ ...pizza, id: -1 });
expect(await firstValueFrom(spectator.service.cart$)).toHaveLength(0);
});
});

describe('when the pizza is not in available', () => {
it('should not add it to the cart', async () => {
spectator.service.addToCard({ ...pizza, isAvailable: false });
expect(await firstValueFrom(spectator.service.cart$)).toHaveLength(0);
});
});

describe('otherwise', () => {
it('should add the pizza to the cart', async () => {
spectator.service.addToCard(pizza);
expect(await firstValueFrom(spectator.service.cart$)).toContain(pizza);
});
});
});

describe('The removeFromCard method', () => {
beforeEach(() => spectator.service.addToCard(pizza));

it('should remove the pizza from the card', async () => {
expect(await firstValueFrom(spectator.service.cart$)).toContain(pizza);
spectator.service.removeFromCard(pizza);
expect(await firstValueFrom(spectator.service.cart$)).not.toContain(
pizza
);
});
});
});

We can see that we use a description for both the methods addToCart and removeFromCart, and a describe for every if statement possibility. The coverage report is great in services for seeing which branch is not covered, to not forget any case. If you have some private method with a branch uncovered, it can also be done by adding a new describe and targeting the public method that uses the private method.

We could have added specific tests for the service’s Observables, but they are quite simple and implicitly tested by other tests. We use the await/sync with the firstValueFrom function from RxJS to get the Observable value. It is a good way to test Observables, as we are sure that it emits a value and we follow the Jest recommendation by avoiding the done callback.

Our service doesn’t have any dependencies, but like for the component, we could use Spectator to set providers with mocks.

Other classes

Testing other classic Angular classes should be quite simple. For guards and resolvers, they are just specific services. For pipes and directives, you can take a look at the Spectator documentation which contains great examples.

Routed components can be painful, but once again Spectator can help by providing a nice helper to mock the ActivatedRoute object.

All the examples are available in this repository: https://github.com/a-audusseau/angular-tests-examples

Conclusion

In this article, we talked about best practices we try to follow at Shippeo, but we know that these practices can evolve over time, especially with tools that change quickly as Angular and the JS ecosystem do. As an example, in my opinion the Angular testing toolbox has a complex and verbose syntax, and I will not be surprised if it evolves for a more pleasant syntax like we have with Spectator. Moreover, we still use Observables a lot as you can see in the above examples, but since Angular 17, Signals have been introduced and it change the way we think reactivity a lot. I’m looking forward to see all these great things coming!

Thanks for reading! I hope all these tips will make you love testing your code 💜

--

--