đź‘‹ Hey there!

I'm Josh Morony, and this website is a collection of my free content centered around creating modern Angular applications using the latest Angular features, and concepts like reactive and declarative code.

Tutorial hero
Lesson icon

Testing Smart Components and Services in Angular

Originally published August 30, 2023 Time 9 mins

In the previous lesson, we looked at a simple approach for writing unit tests for dumb components in Angular - specifically, we were focusing on testing the behaviour of the inputs and outputs of those components.

If this component has this input, what should happen? Or in the case of outputs, what needs to happen for this output to be triggered with some specific value?

This article is adapted from the following video:

This is a good place to start, but as I mentioned it isn’t the whole picture. A dumb component does not exist in isolation, it needs to be integrated into the rest of the application to actually do anything, and the rest of the application is going to require different types of testing approaches.

This post is going to focus on completing the testing story within the Angular application. This will mean testing how smart components interact with those dumb components, and how they interact with services, and we will also look at testing the services themselves.

smart components interact with those dumb components

State Management Recap

We are going to do all of this in the context of the declarative state management approach I have been talking about recently with RxJS and Signals.

Full explanation video: I bet you can write an Angular UNIT TEST after this video

All state in the application is derived from sources which sit at the very top of the data flow in our application. These sources are RxJS observables. If we need to load some data in the application or some user action is triggered, one of these sources will emit.

We then subscribe to these sources, and use the values to determine how they should change the current state as a result which is stored in a signal.

Then the rest of the application uses these signals to get access to the state.

signals to get access to the state

How Do Tests Fit in?

Now let’s consider how this plays into our testing scenario.

At the moment, we have dumb components and we are testing their inputs and outputs.

Now we need a smart component to serve as the parent for that component - the smart component will supply the component with its inputs - often, but not always, these inputs will be derived from the signals in our state management services. We will write tests for this.

The smart component will also handle the outputs from the dumb component. For example, the dumb component might cause the “delete” output to emit. The smart component would need to take the data from that and trigger the appropriate source in the state management service with that data. We will also write tests for that.

Then we have the service itself. When one of the sources emits, it often needs to change the state signal in some way - we will also write a test for that.

With this approach, the application functions so consistently the same in most scenarios. Dumb components are given inputs by smart components, they trigger outputs, those outputs are handled by the smart component which usually means triggering some source in the service, and the service handles updating the state as a result of the source emitting.

This means a lot of the time, the tests can be basically the same, just with some bits and pieces changed around.

Let’s Take A Look

Let’s take a look at what this actually looks like.

Let’s just continue on from the example we had in the previous lesson. We tested that our dumb component renders a list item for each of the elements in the array it receives as an input.

it('should render a list item for each element', () => {
  const testData = [{}, {}, {}] as any;
  component.checklists = testData;

  fixture.detectChanges();

  const result = fixture.debugElement.queryAll(By.css('[data-testid="checklist-item"]'));

  expect(result.length).toEqual(testData.length);
});

We also tested that when the delete button is clicked for a particular item it will emit the id of that checklist on the “delete” output.

    it('should emit checklist id to be deleted', () => {
      const testData = [{ id: '1', title: 'test' }] as any;
      component.checklists = testData;

      const observerSpy = subscribeSpyTo(component.delete);

      fixture.detectChanges();

      const deleteButton = fixture.debugElement.query(
        By.css('[data-testid="delete-checklist"]')
      );
      deleteButton.nativeElement.click();

      expect(observerSpy.getLastValue()).toEqual(testData[0].id);
    });
  });

Smart Component: Inputs

Now we need to add our tests for its parent smart component - in this case, the home page.

What should that smart component supply as the input for the dumb component, and how should it handle the delete output?

For the input in this case, we want to check that it supplies the dumb component with whatever the current state for the checklists is from our service.

beforeEach(() => {
  list = fixture.debugElement.query(By.css('app-checklist-list'));
});

describe('input: checklists', () => {
  it('should use checklists selector as input', () => {
    expect(list.componentInstance.checklists).toEqual(mockChecklists);
  });
});

Since we are testing our smart component, not the service, we will create a mock version of our real service. This mock will provide some fake values for the checklists data, and we will check that it is this data that is supplied to our dumb component.

        {
          provide: ChecklistService,
          useValue: {
            checklists: jest.fn().mockReturnValue(mockChecklists),
            add$: {
              next: jest.fn(),
            },
            remove$: {
              next: jest.fn(),
            },
            edit$: {
              next: jest.fn(),
            },
          },
        },

Our checklists state is a signal, and signals have their values accessed through a function call. To simulate this we use Jest’s mock function and have it return the value we want. In this case, just some dummy data.

That means, when we execute this test, our component is going to be accessing this fake version of the signal not the actual signal from our real service.

In our test, we just check that this dummy data has been supplied to the dumb component. Then we know the smart component is doing its job.

Smart Component: Outputs

For the output, we want to check that it calls next on our remove$ source in the service with whatever data was emitted on the delete event.

describe('output: delete', () => {
  it('should next remove$ source with emitted value', () => {
    const testId = 5;
    list.triggerEventHandler('delete', testId);

    expect(checklistService.remove$.next).toHaveBeenCalledWith(testId);
  });
});

Again, we will use our mock to do this - we don’t care at this point what the service does, all we need to know is that the smart component is nexting the correct source with the correct data.

As well as providing the ability to return mock values, spies will allow you to check during the test if a method has indeed been called at some point, and what it was called with.

In our test, we trigger our delete output with some dummy data, and then we check our spy to see that it was correctly called with that dummy data.

Testing the Service

The final piece of the puzzle here is the service. We know the dumb component does what it is supposed to do, we know that the smart component is correctly handling communication between the dumb component and the service. Now we just need to check that the service is doing its job.

Again, this signals/rxjs state management approach makes things quite straightforward to test. In general, we will just test triggering one of the sources and then check that the resulting state change was correct.

To test our remove$ source we will add in some test data before each test.

describe('source: remove$', () => {
    beforeEach(() => {
        // add some test data
        service.add$.next({ title: 'abc' });
        service.add$.next({ title: 'def' });
        service.add$.next({ title: 'ghi' });
    });

I haven’t shown you the tests for the add$ source but this is already done and functional, you can find those in the source code if you are interested.

Now we want to test that when the remove$ source emits some id it removes the checklist that matches that id from the checklists state.

it('should remove the checklist with the supplied id', () => {
  const testChecklist = service.checklists()[0];
  service.remove$.next(testChecklist.id);
  expect(service.checklists().find((checklist) => checklist.id === testChecklist.id)).toBeFalsy();
});

To do this, we can just grab the first checklist from the checklists state to test, and trigger our remove$ source with its id. Then we check the checklists state to ensure that it no longer contains that specific checklist.

We also want to make sure it is ONLY that checklist that is removed, so I have also added this second test to ensure that just one checklist is being removed. Otherwise, just removing every single checklist would also be a solution for this feature.

it('should NOT remove checklists that do not match the id', () => {
  const testChecklist = service.checklists()[0];
  const prevLength = service.checklists().length;
  service.remove$.next(testChecklist.id);
  expect(service.checklists().length).toEqual(prevLength - 1);
});

And there we go, now we can be reasonably sure our component is actually behaving the way we want it to in the actual application.

Conclusion

Once you are somewhat experienced at testing, most of your workflow will go pretty smoothly, but it will be punctuated by frustrating issues that will make you feel like you suck at testing. This is normal, and just keep in mind that it’s these challenging scenarios that help you grow the most.

Learn to build modern Angular apps with my course