Extracting Maximum Value from Angular Component Tests

A common concern I encounter is uncertainty about what to test in an Angular component. This worry usually surfaces alongside complaints that writing and maintaining these tests consumes significant time while delivering minimal benefit. Eventually, teams question whether the testing effort pays off. I've experienced this dilemma, and typically two paths emerge. Either the codebase ends up with virtually no tests, or you wind up with an excessive number of additional tests. Both scenarios leave something to be desired.

In this piece, I'd like to discuss my approach to maximizing the value derived from each test. What defines a high-value test in my view? For me, it's a test that catches bugs in my code, naturally. Beyond that, it's a test whose creation cost doesn't impede the development workflow, whether immediately or down the line. In simpler terms, writing the test shouldn't feel burdensome. On the contrary, the test ought to be straightforward to comprehend and should empower me to introduce new functionality with assurance.

To achieve this goal, I aim to replicate the experience of a genuine user engaging with my application. This also implies that mocking is kept to a minimum, since minimizing mocks helps ensure the application truly functions as intended.

To assist in crafting these tests, I rely on the Angular Testing Library. With this library, the render method combined with the screen object suffices for basic component testing. When interactions are necessary, I incorporate userEvent sourced from @testing-library/user-event.

Let's examine the initial test for a straightforward component named EntitiesComponent. This component manages a list of entities and is responsible for presenting them within a table.

import { render, screen } from '@testing-library/angular';

it('renders the entities', async () => {
    await render(EntitiesComponent);

    expect(screen.getByRole('heading', { name: /Entities Title/i })).toBeDefined();

    // Use the custom Jest matchers from @testing-library/jest-dom
    // to make your tests declarative and readable
    // e.g. replace `toBeDefined` with `toBeInTheDocument`
    expect(screen.getByRole('cell', { name: /Entity 1/i })).toBeInTheDocument();
    expect(screen.getByRole('cell', { name: /Entity 2/i })).toBeInTheDocument();
    expect(screen.getByRole('cell', { name: /Entity 3/i })).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

This illustrates the initial use of the screen object. Consider screen as the actual display a user sees, representing the DOM tree, equipped with numerous queries used to confirm the component renders accurately. The byRole query stands out as the most critical one; it enables element selection in a manner akin to how a user or a screen reader would perceive it. Consequently, this approach also promotes improved accessibility in your components.

💡 TIP: leverage screen.debug() to output the HTML to the console, or employ screen.logTestingPlaygroundURL() to generate an interactive playground. As an illustration, the sample application featured in this article can be accessed via this playground link. The playground assists in identifying the appropriate query.

That seems quite straightforward and clear, doesn't it? Naturally, this simplicity stems from the component's simplicity, so the test reflects that.

Now, let's enhance the component and observe the resulting test implications. Rather than a fixed entity list, the component now fetches entities via a service and relies on a separate table component (TableComponent) for rendering.

import { render, screen } from '@testing-library/angular';

it('renders the entities', async () => {
    await render(EntitiesComponent, {
        declarations: [TableComponent],
        providers: [
            {
                provide: EntitiesService,
                value: {
                    fetchAll: jest.fn().mockReturnValue([...])
                }
            }
        ]
    });

    expect(
        screen.getByRole('heading', { name: /Entities Title/i })
    ).toBeInTheDocument();

    expect(
        screen.getByRole('cell', { name: /Entity 1/i })
    ).toBeInTheDocument();
    expect(
        screen.getByRole('cell', { name: /Entity 2/i })
    ).toBeInTheDocument();
    expect(
        screen.getByRole('cell', { name: /Entity 3/i })
    ).toBeInTheDocument();
})
Enter fullscreen mode Exit fullscreen mode

Observe how the previously established test approach means minimal adjustments to the updated test. The only aspect requiring modification is the test configuration. The test avoids hardcoding the component's inner workings, which simplifies future refactoring without needing to revisit and update the tests.

If you're acquainted with the Angular TestBed, the configuration passed to render (the second parameter) should look familiar. This is because render acts as a thin wrapper over TestBed, preserving the same API while introducing some sensible defaults.

Within the test, the EntitiesService is stubbed to prevent real network calls. When writing component tests, the goal is to shield the test from external dependencies' impact. Our focus is on controlling the data. The stub supplies the entity collection specified in the test setup. An alternative is to utilize Mock Service Worker (MSW). MSW intercepts network requests and substitutes them with mock implementations. A notable advantage of MSW is that these mocks can also be reused during application development or within end-to-end testing.

With the core features in place, it's appropriate to begin interacting with the component. Let's introduce a search textbox to filter entities in the table and modify the test to validate this behavior.

import {
    render,
    screen,
    waitForElementToBeRemoved,
} from '@testing-library/angular';
import userEvent from '@testing-library/user-event';

it('renders the entities', async () => {
    await render(EntitiesComponent, {
        declarations: [TableComponent],
        providers: [
            {
                provide: EntitiesService,
                value: {
                    fetchAll: jest.fn().mockReturnValue([...])
                }
            }
        ]
    });

    expect(
        screen.getByRole('heading', { name: /Entities Title/i })
    ).toBeInTheDocument();

    expect(
        screen.getByRole('cell', { name: /Entity 1/i })
    ).toBeInTheDocument();
    expect(
        screen.getByRole('cell', { name: /Entity 2/i })
    ).toBeInTheDocument();
    expect(
        screen.getByRole('cell', { name: /Entity 3/i })
    ).toBeInTheDocument();

    userEvent.type(
        screen.getByRole('textbox', { name: /Search entities/i }),
        'Entity 2'
    );

    // depending on the implementation:
    // use waitForElementToBeRemoved to wait until an element is removed
    // otherwise, use the queryBy query
    await waitForElementToBeRemoved(
        () => screen.queryByRole('cell', { name: /Entity 1/i })
    );
    expect(
        screen.queryByRole('cell', { name: /Entity 1/i })
    ).not.toBeInTheDocument();

    expect(
        await screen.findByRole('cell', { name: /Entity 2/i })
    ).toBeInTheDocument();
})
Enter fullscreen mode Exit fullscreen mode

To emulate user interaction, utilize the methods provided by the userEvent object. These methods replicate the events triggered by an actual user. For instance, the type method dispatches a sequence including focus, keyDown, keyPress, input, and keyUp. For events not covered by userEvent, the fireEvent utility from @testing-library/angular can be employed. These events are genuine JavaScript events dispatched to the control.

The test now incorporates a new method, waitForElementToBeRemoved. This method is necessary only when an element is removed from the document asynchronously. If removal happens immediately, waiting becomes unnecessary; you can simply use a queryBy query to confirm the element's absence. The key distinction between queryBy and getBy queries lies in their behavior: getBy throws an error when the element is missing, whereas queryBy returns undefined.

This test also highlights the usage of findBy queries. While comparable to queryBy, these queries operate asynchronously. They allow us to wait for an element to appear within the document.

💡 TIP: To ensure test resilience against minor variations, I lean towards findBy queries rather than getBy queries.

Following these modifications, the test remains effortlessly readable, prompting us to proceed to the next enhancement.

Imagine that, for performance considerations, the component's internal search mechanism needed adjustment, introducing a delay before executing the search. In a worst-case delay scenario, the existing test might fail due to a timeout. Conversely, even with a minimal delay, the test execution time increases, albeit negligibly.

To address this, we must incorporate fake timers within the test to accelerate time progression. This approach is somewhat advanced, yet it's a valuable technique to master. Initially, I found it challenging, but familiarity bred appreciation for this concept. You might even begin to feel like a time manipulator, an enjoyable sensation.

The subsequent test utilizes Jest's fake timers; alternatively, the fakeAsync and tick utility functions from @angular/core/testing serve a similar purpose.

it('renders the table', async () => {
    jest.useFakeTimers();

    await render(EntitiesComponent, {
        declarations: [TableComponent],
        providers: [
            {
            provide: EntitiesService,
            useValue: {
                fetchAll: jest.fn().mockReturnValue(
                of([...]),
                ),
            },
            },
        ],
    });

    expect(
        await screen.findByRole('heading', { name: /Entities Title/i })
    ).toBeInTheDocument();

    expect(
        await screen.findByRole('cell', { name: /Entity 1/i })
    ).toBeInTheDocument();
    expect(
        await screen.findByRole('cell', { name: /Entity 2/i })
    ).toBeInTheDocument();
    expect(
        await screen.findByRole('cell', { name: /Entity 3/i })
    ).toBeInTheDocument();

    userEvent.type(
        await screen.findByRole('textbox', { name: /Search entities/i }),
        'Entity 2'
    );

    jest.advanceTimersByTime(DEBOUNCE_TIME);

    await waitForElementToBeRemoved(
        () => screen.queryByRole('cell', { name: /Entity 1/i })
    );
    expect(
        await screen.findByRole('cell', { name: /Entity 2/i })
    ).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

Our final component addition includes two buttons: one for creating a new entity and another for editing an existing one. The following test confirms that the modal service gets triggered upon user clicks on these buttons.

import {
    render,
    screen,
    waitForElementToBeRemoved,
    within,
    waitFor,
} from '@testing-library/angular';
import { provideMock } from '@testing-library/angular/jest-utils';
import userEvent from '@testing-library/user-event';

it('renders the table', async () => {
    jest.useFakeTimers();

    await render(EntitiesComponent, {
        declarations: [TableComponent],
        providers: [
            {
                provide: EntitiesService,
                useValue: {
                    fetchAll: jest.fn().mockReturnValue(of(entities)),
                },
            },
            provideMock(ModalService),
        ],
    });
    const modalMock = TestBed.inject(ModalService);

    expect(
        await screen.findByRole('heading', { name: /Entities Title/i })
    ).toBeInTheDocument();

    expect(
        await screen.findByRole('cell', { name: /Entity 1/i })
    ).toBeInTheDocument();
    expect(
        await screen.findByRole('cell', { name: /Entity 2/i })
    ).toBeInTheDocument();
    expect(
        await screen.findByRole('cell', { name: /Entity 3/i })
    ).toBeInTheDocument();

    userEvent.type(
        await screen.findByRole('textbox', { name: /Search entities/i }),
        'Entity 2'
    );

    jest.advanceTimersByTime(DEBOUNCE_TIME);

    await waitForElementToBeRemoved(
        () => screen.queryByRole('cell', { name: /Entity 1/i })
    );
    expect(
        await screen.findByRole('cell', { name: /Entity 2/i })
    ).toBeInTheDocument();

    userEvent.click(
        await screen.findByRole('button', { name: /New Entity/i })
    );
    expect(modalMock.open).toHaveBeenCalledWith('new entity');

    const row = await screen.findByRole('row', {
        name: /Entity 2/i,
    });
    userEvent.click(
        await within(row).findByRole('button', {
            name: /edit/i,
        }),
    );
    waitFor(() =>
        expect(modalMock.open).toHaveBeenCalledWith('edit entity', 'Entity 2')
    );
});
Enter fullscreen mode Exit fullscreen mode

This test introduces several novel elements; let's examine them in detail.

Simulating a click on the "new entity" button is relatively standard and something we should already be familiar with. The userEvent.click method is used to mimic the user's click action. Following this, we assert that the modal service was called with the correct parameters.

Scrutinizing the test setup reveals the usage of provideMock from @testing-library/angular/jest-utils to mock the ModalService. Each method within the provided service gets wrapped with a jest mock implementation by provideMock. This simplifies the process of verifying whether a function was invoked.

The "edit entity" button presents a more complex scenario, featuring two new methods: within and waitFor.

The within method comes into play because every table row contains an edit button. Using within, we can target the specific edit button to click, in this case, the one associated with "Entity 2".

The second method, waitFor, facilitates waiting until the assertion within its callback succeeds. In this instance, the component introduces a delay between the edit button click and the modal opening. waitFor allows us to pause until that action concludes.

Additional examples

Directives

Up to this point, the focus has been exclusively on component tests.
Fortunately, testing directives follows a very similar pattern.
The key distinction is that a template must be supplied to the render method, which also works if you prefer to render a component that way.

Everything else in the test stays the same.
The assertions rely on the screen object and its utility methods to verify the directive behaves as expected.

As an illustration, the test below renders the appSpoiler directive, which keeps the text content hidden until the element is hovered over.

test('it is possible to test directives', async () => {
    await render('<div appSpoiler data-testid="sut"></div>', {
        declarations: [SpoilerDirective],
    });

    const directive = screen.getByTestId('sut');

    expect(screen.queryByText('I am visible now...')).not.toBeInTheDocument();
    expect(screen.queryByText('SPOILER')).toBeInTheDocument();

    fireEvent.mouseOver(directive);
    expect(screen.queryByText('SPOILER')).not.toBeInTheDocument();
    expect(screen.queryByText('I am visible now...')).toBeInTheDocument();

    fireEvent.mouseLeave(directive);
    expect(screen.queryByText('SPOILER')).toBeInTheDocument();
    expect(screen.queryByText('I am visible now...')).not.toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

NgRx Store

Getting component tests right when they interact with the NgRx Store took us some time.
The breakthrough came with the introduction of MockStore.

Our initial tests avoided mocking the NgRx Store entirely, relying on the full infrastructure with reducers, selectors, and effects.
Although this approach exercised the complete flow, it also required the Store to be set up for each individual test.
This was manageable early on, but it quickly turned into an unwieldy situation.

To work around this, developers would create service facades around the Store.
However, restructuring your application code just to accommodate testing is hardly a sound practice.

With MockStore, we now get the advantages of both approaches.
The test remains focused on the component itself, and the intricacies of the NgRx Store are kept out of the picture.

The following test demonstrates how to incorporate MockStore into a component test.
It uses the same sample component as before, but swaps out the entities service and the modal service for the NgRx Store.

The store is created using the provideMockStore method, where you can override the results of the selectors the component relies on.
A mock can be assigned to the dispatch method to confirm actions are being sent.
When necessary, you can also refresh the selector's result.

import { render, screen } from '@testing-library/angular';
import { MockStore, provideMockStore } from '@ngrx/store/testing';

it('renders the table', async () => {
    await render(EntitiesComponent, {
        declarations: [TableComponent],
        providers: [
            provideMockStore({
                selectors: [
                    {
                        selector: fromEntities.selectEntities,
                        value: [...],
                    },
                ],
            }),
        ],
    });

    // create a mock for `dispatch`
    // this mock is used to verify that actions are dispatched
    const store = TestBed.inject(MockStore);
    store.dispatch = jest.fn();
    expect(store.dispatch).toHaveBeenCalledWith(fromEntities.newEntityClick());

    // provide new result data for the selector
    fromEntities.selectEntities.setResult([...]);
    store.refreshState();
});
Enter fullscreen mode Exit fullscreen mode

Wrapping up

Since these tests are written from the user's point of view, they tend to be far more readable and straightforward to grasp.

In my experience, adhering to this methodology makes tests more resilient to future modifications.
A test becomes brittle when it verifies the component's internal workings, such as how and when lifecycle methods are invoked.

Major test rewrites become less common, as that would imply a substantial change in the component's UI, which would also be noticeable to the end-user.
When that happens, it's often wise to start fresh with a new component and a new test rather than attempting to retrofit the existing ones.

The sole situation where you might need to adjust a test after a refactor is when the component is split into several smaller components. In that case, you'd need to include all the new components, modules, or services in the test's setup, but the rest of the test stays intact (assuming the refactor went well, otherwise can it truly be called a refactor?).

💡 TIP: If you're following Single Component Angular Modules, it becomes clearer to spot when changes will impact your tests.

You may have observed that I group multiple arrange/act/assert blocks within a single test.
This is a practice I adopted from Kent C. Dodds, and I'd recommend his post "Write fewer, longer tests" for more context.
Given that test initialization can be expensive in Angular, this habit also helps reduce the overall execution time of the test suite.

Once our team embraced this testing style, I found that writing new tests was noticeably quicker than before.
The approach simply made sense to us.
I'd even go as far as saying it brought a bit of enjoyment to the process.

I'd like to close with a thought from Sandi Metz: "Test the interface, not the implementation".

If you're looking for more on Angular testing, I suggest checking out these resources:


Follow me on Twitter at @tim_deschryver | Subscribe to the Newsletter | Originally published on timdeschryver.dev.