Skip to content

The perfect approach to test Angular applications

_

Table of contents

  1. Introduction

  2. Testing Theory vs Testing Reality

  3. How To Find Balance

  4. Dumb and Smart Components

    1. Dumb Components (Presentational components)
    2. Smart Components (Container components)
  5. Unit Testing

  6. Integration Testing

  7. E2E Testing

  8. That's all folks

Introduction

I have been working with Angular for more than 3 years now, and although I am relatively new to the software development world, I feel like contributing my little grain of sand about how to test Angular applications.

I like to see Angular applications at 3 different abstraction levels:

  1. Isolated components
  2. Connecting components
  3. Entire application

This approach allows us not only to separate concerns across components but also allows us to perform unit testing for some components, integration testing for others, and end-to-end (E2E) testing for the whole application.

Testing Theory vs Testing Reality

I'm one of those guys who thinks that a pull request with tests will always be better than a pull request without tests. From my point of view, I also believe that if you have the possibility of doing TDD, you probably should do it.

I really agree with this philosophy where tests are core in any software project, but sometimes it's not that easy.

You will face occasions where unit tests won’t bring you as much value in comparison with the time invested. Indeed, sometimes you just won’t have time to implement tests because of project requirements, tight deadlines, or a limited budget.

That's without mentioning the time that you’ll have to invest in the future to maintain those tests.

I have worked on projects with no tests and projects where we implemented more than 1400 unit tests with TDD, where everything is tested.

My conclusion is that you should find a balance with testing. It's bad to not have any tests in a project, but it can also be bad to have 5 tests just to check the correct styles of a button.

How To Find Balance

The reason for writing this post is to share my thoughts on what are the minimum things that you should test in any Angular application to achieve the right balance between value and time spent.

tdd.webp

Dumb and Smart Components

Thanks to the way of communicating components through input() and output() signal functions, smart and dumb component architecture fits so well when structuring our project.

Dumb Components (Presentational components)

The main ideas behind dumb components are:

  • These components are primarily concerned with performing a single task.
  • They are typically simple and reusable, focusing on displaying data and emitting events.
  • Dumb components usually don't have any dependencies on services or store state.
  • They receive data through input() bindings and emit events through output() bindings.
  • They “don’t know anything” about the rest of the application; they can work in isolation.

In the end, dumb components are like the bricks that we use to build a house. They are isolated pieces of software that can be placed in any project and still work correctly.

A good example of a dumb component could be a form.

import { Component, input, output } from "@angular/core";
import { FormControl, FormGroup } from "@angular/forms";

type FormValues = { email: string; password: string };

@Component({
  selector: "app-form",
  standalone: true,
  imports: [],
  templateUrl: "./form.component.html",
  styleUrl: "./form.component.scss",
})
export class FormComponent {
  defaultValues = input<FormValues>({ email: "", password: "" });
  submit = output<FormValues>();

  form = new FormGroup({
    email: new FormControl(this.defaultValues().email),
    password: new FormControl(this.defaultValues().password),
  });

  onSubmit() {
    const { email, password } = this.form.value;
    if (email && password) {
      this.submit.emit({ email, password });
    }
  }
}

Encapsulating the form in that way allows you to communicate with the rest of the application via input() and output(), which is like exposing a public API to the rest of the application. Hence, this form component is totally reusable in other parts of the project or even in other applications.

This is not only good for the versatility that it provides but also for testing purposes, especially for unit testing purposes.

Smart Components (Container components)

The main ideas behind smart components are:

  • These components are concerned with orchestrating dumb components to build full features; they usually represent an application page.
  • Smart components interact with services, fetch data from APIs, manage application state, and handle user interactions at a higher level.
  • They often manage data streams (observables) to receive updates and dispatch actions to modify the state.
  • They pass data down to dumb components via input() and handle events emitted by dumb components through output().

Following the comparison with the house, smart components are like the concrete that lets you build the walls of the house by connecting the bricks (our dumb components).

Ninety percent of the time, smart components will be pages that hold dumb components and manage how they interact with each other, fetching data, managing streams, managing state, etc. The other 10% of the time, smart components will represent complex features that need some more levels of depth, for example, a page that actually is a smart component holding a Trello board, which will most likely be divided into smaller pieces.

As you can imagine at this point, smart components seem to be the perfect piece of code to conduct integration tests on them. I’ll talk about that soon.

Unit testing

Once we've discussed how to architect our components, we can talk about what we should test in each type of component.

Let’s focus on dumb components first. One of their main features is that they communicate through input() and output() bindings. This mechanism is indeed intentional because it allows you to see your components as pure functions.

Pure functions are great to test because they're easy to test; you just need to expect some outputs depending on the provided inputs.

However, with components, it's a little more complicated because they are more complex than just functions. But we can think about them in the following way:

dumb-components.png

Basically, when some input is provided, we should check what effects it has on the component. Usually, providing an input will have an impact on the template. On the other hand, when a user interacts with the component, we should expect an event to be emitted up to the parent component.

Checking that the correct HTML is rendered and the emitted value is the expected one when interacting with the component is mostly all you need to test to ensure that every important feature is working smoothly.

So usually, my test file looks something like this.

import { ComponentFixture, TestBed } from "@angular/core/testing";

import { FormComponent } from "./form.component";

describe("FormComponent", () => {
  let component: FormComponent;
  let fixture: ComponentFixture<FormComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [FormComponent],
    }).compileComponents();

    fixture = TestBed.createComponent(FormComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it("should create", () => {
    expect(component).toBeTruthy();
  });

  describe("input: defaultValues", () => {
    it("should have an input prop called defaultValues defined", () => {});

    it("should render provided values in each form field", () => {});
  });

  describe("output: submit", () => {
    it("should have an output prop called submit defined", () => {});

    it("should emit form content when clicking submit button", () => {});

    it("should not emit form content if it is invalid", () => {});
  });
});

Having in mind the structure of the tests, you can implement them the way you like, using a traditional approach where you implement the feature and then test it, or use TDD to implement one scenario at a time.

As you can see, I’m not testing any styles. Usually, the systems I work with don’t focus too much on styles. But if that is your case, what I sometimes do is create an extra describe block called “template”, where I write all the related tests for the template, styles, and similar things.

Integration Testing

The good part of this approach is that by testing only our dumb components, we cover most of the core functionality of our application, as all the heavy lifting (user interaction) is encapsulated there.

Testing smart components is much simpler because we just have to ensure that everything is connected properly.

smart-component.png

The flow always will be:

  • Service fetches some data that is provided by the smart component to the dumb component.
  • Dumb component emits an event that is handled by the smart component to trigger some action in the service.

Imagine a smart component that represents a page to buy books like this one.

import { Component, inject } from "@angular/core";
import { RouterOutlet } from "@angular/router";
import { BookService } from "./services/book.service";
import { take } from "rxjs";

type Book = {};

@Component({
  selector: "app-books",
  standalone: true,
  imports: [RouterOutlet, BookList, BookPurchaseForm],
  templateUrl: "./app-books.component.html",
  styleUrl: "./app-books.component.scss",
})
export class BooksComponent {
  private bookService = inject(BookService);

  books$ = this.bookService.getBooks();

  selectedBook = signal<undefined | Book>(undefined);

  onBookSelected(book: Book) {
    this.selectedBook.set(book);
  }

  onBookPurchase(book: Book) {
    this.bookService.purchaseBook(book).pipe(take(1)).subscribe();
  }
}

With the above example, we can expect our tests to look like these.

import { ComponentFixture, TestBed } from "@angular/core/testing";

import { BooksComponent } from "./app-books.component";

describe("BooksComponent", () => {
  let component: BooksComponent;
  let fixture: ComponentFixture<BooksComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [BooksComponent],
    }).compileComponents();

    fixture = TestBed.createComponent(BooksComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it("should create", () => {
    expect(component).toBeTruthy();
  });

  describe("app-book-list", () => {
    it("should create", () => {});

    describe("input: books", () => {
      it("should use books$ as input");
    });

    describe("output: bookSelected", () => {
      it("should update selectedBook with the new book");
    });
  });

  describe("app-book-purchase-form", () => {
    it("should create", () => {});

    describe("input: selectedBook", () => {
      it("should use selectedBook as input", () => {});
    });

    describe("output: purchase", () => {
      it("should trigger purchase method in BookService", () => {});
    });
  });
});

As described above, in smart components, we just have to check that everything is well connected, as the core functionality is already tested in dumb components.

The idea behind the scenes of doing integration testing like this is to test every piece of code that is connected to the smart component.

It’s not described in the code above, as it’s a simple example, but sometimes you will need to create an extra describe block for each service. A common situation in this case is when you update some signal or emit in a BehaviourSubject from a handler method called by a dumb component output, and then you want to check if a method service is triggered as a result of this update or emission.

In this case, it’s a good idea to have a test inside the component describe block that checks if this source has updated its value, and also another test in the service describe block checking if the method is triggered when the source emits.

E2E Testing

Finally, to ensure that everything works in real-life scenarios, I try to perform end-to-end (E2E) tests with Cypress or Playwright to check that core features work when all the pieces are set together.

I don't have much to say here, as E2E testing is a little out of the scope of this post, but I just wanted to mention it because, from my point of view, it’s important to conduct some “real-world” test cases to ensure that the user will experience what we have created.

That’s all, folks!

As I have discussed, it’s easy to over-test or under-test your application. With this approach, you are not testing everything that can be tested in an Angular application, but I think that having these scenarios covered provides you with a solid foundation on basic things that should always work.

Hope you find it useful! See you in my next post!