28 November 2022

Testing in Angular with Jest and Testing Library

Every developer with a bit of experience knows the importance of Unit Tests. But for those who may not know, here are some key advantages:

  • With a good test coverage, you can rely on your code to do exactly what you expect of it to do
  • Your code is easier to refactor due to tests backing it up
  • Tests serve as documentation of your code
  • Tests serve as early bug detection
  • Tests force better design

Recently I began applying TDD for my personal development. Because automated tests are the key aspect of TDD, I decided to broaden my horizon a bit and see if there are better solutions to the stuff that Angular offers us out of the box. After some research, I picked up a couple of libraries as my testing solution:

  • Jest - Testing Framework developed by Meta (Facebook)
  • jest-dom - it contains additional matchers for Jest to check the state of the DOM elements
  • Testing Library - collection of testing tools that encourage testing the behavior of the applications instead of its implementation details
  • ng-mocks - mocking library for Angular. It helps mock components, services, modules, directives, pipes…
  • user-event - additional companion library of the Testing Library which simulates user interaction with the application

In this article, I would like to share my experience with these libraries so far. Keep in mind that this is my personal take on these libraries, and your usage and implementation may vary.

Jest

Jest is a JavaScript Testing Framework from Meta (Facebook) which is oriented on facilitating the testing experience. Jest is the standard testing solution for React.

Advantages

  • Easy to set up
  • Can run tests in parallel. Tests are isolated (if you don’t couple them yourself)
  • Easy to create test coverage reports
  • Does not need a browser
  • Has a big user base
  • Significantly more popular than Karma (probably correlates to the fact that React is generally more Popular than Angular)
  • It is possible to create snapshots of the components and assure that the UI has not changed since the last execution. (Compares the current and previous snapshot of the component)
  • You do not need additional configuration for the CI

Disadvantages

  • Tests are less visual than Karma. You do not really see what is happening in your application when the tests are running
  • Jest runs on JSDOM which is not a real browser. So there is the possibility that the application behaves differently than inside a browser

Testing Library

Testing Library is a collection of tools, that facilitates the writing of the tests in a robust way. It encourages a way of testing where you do not query for implementation details, like CSS classes or HTML tags or attributes, but rather stuff that the user sees when he interacts with your application.

Advantages

  • Tested code is easier to refactor without breaking the tests
  • It is possible to interact with nodes inside the DOM by querying for stuff that the user sees, thus making tests more resilient when the implementation changes
  • It can be used together with a wide variety of Testing Frameworks and Applications

Project setup

Jest

An easy explanation on how jest can be added to your Angular project can be found here.

Testing Library

npm install --save-dev @testing-library/angular

user-event

npm install --save-dev @testing-library/user-event

jest-dom

npm install --save-dev @testing-library/jest-dom

In your own jest-setup.js (or any other name) add the following line:

import '@testing-library/jest-dom'

Additionally, I had to add “@testing-library/jest-dom” to the tsconfig.spec.json file:

...
"compilerOptions": {
  "outDir": "./out-tsc/spec",
  "module": "CommonJs",
  "types": [
    "jest", 
    "@testing-library/jest-dom" <--
  ]
}
...

ng-mocks

npm install ng-mocks --save-dev



Q-AND-A

I have created a small q-and-a angular application for testing purposes:

The application has two views:

  • Teacher browser: You can access available teachers, view their profile and ask them questions
  • Question browser: You can view asked questions

Source code is available on GitHub. If you want for the application to work with API calls, you have to start json-server from the root directory by executing “json-server –watch db.json”.

Let’s dig in

In the following section, I am going to describe a basic test class structure inside the q-and-a application. In most of the cases when I test, I extract the rendering of a component in a separate function. The reason is that most of the time I have to provide some dependencies for the component to work. Doing that, the code expands and the test gets difficult to read. The function renderWithMock below is one of those functions.

  async function renderWithMock(questionService: Mock<QuestionService>) {
    const { container, detectChanges } = await render(AppComponent, {
      imports: [
        RouterTestingModule.withRoutes([
          { path: 'questions', component: QuestionBrowserComponent }
        ]),
      ],
      declarations: [
        NavbarComponent
      ],
      componentProperties: {
        question: { question: "This is the question." } as Question
      },
      componentProviders: [
        { provide: QuestionService, useValue: questionService }
      ],
    });

    return { container, detectChanges }
  }

We can render an Angular component with the render function from the Testing Library. Additionally, we can pass arguments that are needed for the component to be rendered:

  • imports: here you can define shared modules that are needed to render the application
  • declarations: can be the child components, directives and pipes that the tested component needs
  • componentProperties: @Input() and @Output() properties of a component
  • componentProviders: is used to provide services that are injected inside the component

The render function returns some useful objects that could be needed for the test. Here are some elements I used:

  • detectChanges: updates the state of the view. Useful if you have made changes to properties which affect the view
  • fixture: Angular component fixture which is used to test a component. I mainly used it to access the component instance
  • container: The containing DOM node of the rendered Angular component

Creating mocks

function createMocks() {
  const questionService: Mock<QuestionService> = createMock(QuestionService);
  const teacherService: Mock<TeacherService> = createMock(TeacherService);

  return { questionService, teacherService };
}

createMocks function creates the mocks of the services that are injected inside the component.

There are different ways to mock services with Jest and Testing Library. The best and most consistent way it has worked for me is as shown in the createMocks function with the following imports:

import { createMock, Mock } from '@testing-library/angular/jest-utils';

In some test classes, I separated the part where the mocks are created and where the component is rendered. I needed to define mock return values before the component was rendered. This is the case for example when the data is fetched inside the Angular ngOnInit method.

TeacherProfileComponent

Displaying information about the teacher

I expect of this component that it displays some information about the teacher. The information is provided by the parent component as follows: @Input() teacher!: Teacher;. Here is the test for this use case:

it('should display teacher information', async () => {
    await renderComponent();

    expect(screen.getByText('Jeff')).toBeVisible();
    expect(screen.getByText('Subjects: Math, Computer Science')).toBeVisible();
});
  • Inside the renderComponent function, we render the TeacherProfileComponent and provide the Input() property with the following code:
componentProperties: {
    teacher: {
        id: 1, 
        name: 'Jeff',
        subjects: "Math, Computer Science"
    } as Teacher,
}
  • At the end, we expect for the information to be displayed. For querying we use the screen object which can be imported and is provided by the Testing Library, and we query by text that is visible for the user.

Submitting a Question

When we write down some text in the HTML textarea, the “Submit your Question” button gets enabled. When the button is pressed, the QuestionService.submitQuestion method gets called and submits the question to the API. On success or failure, we expect the corresponding message to be shown:

submitQuestion() {
    this.questionService
        .submitQuestion({ question: this.question } as Question)
        .subscribe({
            next: () => {
                this.submitSuccess = true;
                this.question = '';
            },
            error: () => this.submitError = true,
        });
}

Here is the test for submitting a question:

it('should be possible to submit a question', async () => {
    const { detectChanges, questionService } = await renderComponent(); //1
    questionService.submitQuestion.mockReturnValue(of({} as Question)); //2

    const questionTextArea: HTMLTextAreaElement = screen.getByPlaceholderText('Question'); //3
    const submitButton = screen.getByRole('button', { //4
        name: /Submit your question/i,
    });

    await userEvent.type(questionTextArea, 'What is the purpose of life?'); //5
    await userEvent.click(submitButton);

    detectChanges(); //6

    let infoMessage = screen.getByText('Question was submitted successfully!');
    expect(infoMessage).toBeVisible(); //7
    expect(infoMessage).toHaveClass('is-success');
    expect(questionTextArea.value).toEqual(''); //8
});
  1. First line, we render the component and retrieve the mocked QuestionService and the detectChanges function
  2. We define what should be returned when QuestionService.submitQuestion is called
  3. We retrieve the textarea HtmlElement from the DOM. As you can see, the elements get retrieved by querying for stuff that the user sees. In this case, with the placeholder text “Question”
  4. We retrieve the “Submit your Question” button. You can also supply Regular Expressions as parameters instead of strings. Here I supply the text of the button, making it case-insensitive with “i” letter
  5. We write some text in the textarea and click on the submit button with the help of the userEvent. Alternatively, there is also the fireEvent. Here are the differences:
    userEvent is more focused on user interactions with the DOM, which means it can fire several events simulating this way an interaction
    fireEvent on the other hand can fire single events which do not necessarily have anything to do with the user
  6. detectChanges is called. This function triggers a change detection cycle for the component. It is needed in our case for enabling the button so that it can be clicked
  7. At the end we expect that the successful info message is displayed and to contain the CSS class is-success which displays the text green. The toBeVisible matcher is from the jest-dom package. In a real world application, the toHaveClass(‘is-success’) would probably be used many times. I personally would extract it in a separate file so that it can be accessed from any test class
  8. We also expect that the HTML textarea is emptied when the question was submitted successfully. Note that without type definition upon retrieval, textarea is returned as HTMLElement. We have to set it to HTMLTextAreaElement because HTMLElement does not have property value

Displaying error message on submit failure


it('should be possible to see error message on submit fail', async () => {
   const { questionService, detectChanges } = await renderComponent(); //1
   questionService.submitQuestion.mockReturnValue( //2
           throwError(() => {
              new HttpErrorResponse({
                 error: 'Your request sucks!',
                 status: 400,
                 statusText: 'BAD REQUEST',
              });
           })
   );

   // Same as in the previous test: //3
   // Here we retrieve the textarea and the submit button,
   // write to the textrea, click on submit and execute detect changes

   const errorMessage = screen.getByText('Oops, something went wrong. Please try again later.');
   expect(errorMessage).toBeVisible(); //4
   expect(errorMessage).toHaveClass('is-danger');
});
  1. We render the component and assign some objects that we are going to need for the test
  2. The QuestionService.submitQuestion method is mocked. When it gets called, we return an error observable. This triggers the code where the error is handled
  3. We write down the question and submit it (comments)
  4. At the end, we expect for the error message to be visible in a red font

Disabling Button when TextArea does not contain any text

it('should not be possible to submit an empty question', async () => {
   const { questionService } = await renderComponent(); //1
   const submitButton = screen.getByRole('button', {
      name: /Submit your question/i,
   });

   await userEvent.click(submitButton);

   expect(submitButton).toBeDisabled() //2
   expect(questionService.submitQuestion).toHaveBeenCalledTimes(0); //3
   expect(questionService.submitQuestion).not.toHaveBeenCalled(); //4
});
  1. The first two parts of the test should be familiar at this point
  2. At the end, we expect for the button to be disabled
  3. We expect that the QuestionService.submitQuestion method does not get called
  4. As an alternative, you can replace the toHaveBeenCalledTimes(0) with not.toHaveBeenCalled()

We want to test the following code:

export class NavbarComponent {
   @Output() loginClicked = new EventEmitter();

   onLoginButtonClicked() {
      this.loginClicked.emit();
   }
}

When the user clicks on the Login button inside the NavbarComponent template, we want to notify the parent. The login process itself would be handled by a different component.

Here is the test for this behavior:

it('should notify parent when login clicked', async () => {
   let emitted = false;
   const { fixture, detectChanges } = await render(NavbarComponent)
   fixture.componentInstance.loginClicked.subscribe(() => (emitted = true)); //1

   screen.getByText('Login').click(); //2
   detectChanges();

   expect(emitted).toBe(true); //3
});
  • We obtain the component instance from the fixture object. The fixture object is provided by the render function. Through the componentInstance we subscribe to the loginClicked event emitter. When it gets emitted, we set the emitted variable to true
  • Next, we execute the necessary steps to simulate the wanted behaviour
  • At the end, we expect the emitted variable to be true

AppComponent

Here is the content of AppComponent template file:

<app-navbar></app-navbar>
<app-sign-in></app-sign-in>
<router-outlet></router-outlet>

The router-outlet handles the display of the different components. The navigation gets triggered from the NavbarComponent. Additionally, we have the app-sign-in which is the selector for the SignInComponent. This component contains a form without any functionality.

Next thing that we want to test is that when we click on the Questions button, we see the QuestionBrowserComponent and the QuestionListItemComponent twice.

it('should show all questions when user navigates to question browser component', async () => {
   const { questionService, container } = await renderComponent(); //1
   questionService.loadQuestions.mockReturnValue(
           of([{} as Question, {} as Question])
   );

   const allQuestionsLink = screen.getByText('Questions'); //2

   await userEvent.click(allQuestionsLink);

   expect(container.querySelectorAll('app-question-browser').length).toBe(1); //3
   expect(container.querySelectorAll('app-question-list-item').length).toBe(2);
});
  1. First, we render the component and mock the service
  2. We retrieve the “Questions” hyperlink tag from the NavbarComponent and click on it. In this case, it serves as a button
  3. Lastly, we expect to see a QuestionBrowserComponent and two QuestionListItemComponents because we have two questions that should be displayed

The renderComponent function looks like follows:

async function renderComponent() {
   //Mocked services are created here

   const { container } = await render(AppComponent, {
      imports: [ //1
         RouterTestingModule.withRoutes([{ path: 'questions', component: QuestionBrowserComponent }]),
      ],
      declarations: [ //2
         //Additional child components declared here to render the app.component
         QuestionBrowserComponent,
         MockComponent(SignInComponent),
      ],
      componentProviders: [
         //mocked services are provided here
      ],
   });
   return { container, questionService };
}

Comments indicate code that has already been covered.

  1. Because routing is used inside the AppComponent we have to import the RouterTestingModule. This Module is provided by Angular. Similar to the real RoutingModule we can define some routes that are needed for the test
  2. In the declarations we can decide if to use real or mocked child components. MockComponent in this case is provided by ng-mocks

Login Dialog

This dialog does not contain any functionality except that all the buttons close the dialog. I wanted to showcase an issue that I encountered with toBeVisible matcher.

The issue is that when the dialog is hidden/closed, toBeVisible still sees the component as displayed. At the end, I ended up checking for the CSS class when I wanted to assert that the component was not visible. Because a real world application probably would contain several dialogs, I would advise to abstract this assertion:

export function expectToBeHidden<T>(element: ComponentFixture<T>) {
   expect(element.nativeElement.firstChild).not.toHaveClass('is-active');
}

It is generally advised to abstract your implementation details when writing your code. There are several advantages to do so:

  • You only have a single place when you want to change your implementation
  • If you want to change your API or any other code you re relying on, you can do so without fear of breaking your Application or your tests
  • If your implementation is complex, abstraction also improves readability of your code

In this concrete example, the behavior seems to be a bug. So if the bug is fixed, only one place has to be modified.

And the test for SignInComponent looks like this:

it('should close dialog when clicked on x', async () => {
   const { fixture, detectChanges } = await render(SignInComponent, { //1
      componentProperties: {
         showDialog: true
      }
   });

   screen.getByTestId('close-dialog-button').click(); //2
   detectChanges()

   expectToBeHidden(fixture);
});
  1. We render the component with the showDialog property set to true. The property controls if the dialog is shown or not
  2. We retrieve the “x” button in the top right corner. When no other solutions found to obtain the elements in the DOM, Testing Library offers the data-testid attribute, which can be given to an HTML tag. The HTML element can be found from the test with getByTestId(‘some-id’). Testing Library advises to use this method as a last resort. More information about the priority on which method to use to query DOM elements can be found here

Testing services and pipes

Here is a simple pipe that transforms pennies to dollars:

export class PennyToDollarPipe implements PipeTransform {
   transform(value: number) {
      return value / 100;
   }
}

The test for this would look like this:

describe('PennyToDollarPipe', () => {
   it('should transform pennies to dollars', () => {
      const pipe = new PennyToDollarPipe();

      const result = pipe.transform(1000);

      expect(result).toBe(10);
   });
});

As you can see, you can treat pipes as simple classes. Same principle applies also for services. If the tested service or pipe is dependent on other services, these can mocked and passed as parameter on instantiation. Without the Angular context and the rendering of a component, the test duration is reduced significantly.

Conclusion

Angular is a very good framework. Out of the box, it comes with all the necessary libraries and tools needed for developing a web application. Although these libraries work good enough, in my opinion, it doesn’t hurt to have a look at alternative tools and packages that may make your life easier.


Ramzan Tumgojev

Senior Full Stack Developer at OpenValue