State of SPFx unit testing in 2023

On this blog I already covered unit testing with SPFx quite extensively. Times change, software evolves I learnt new things and it’s about time to write again about my most favorite topic – unit testing in SPFx.

Full sample with configuration is available on my github

Our sample is quite simple. We want to render list items with information if author is site collection administrator. We do that in two steps – first one is to get list items from a list and then get distinct author ids and call user information list to get user details.

At least Test Oriented Development

I will not lie. It is impossible to write unit tests for whatever. There are classes or functions that are implemented without any encapsulation nor abstraction in place. In such cases isolation of our code is impossible. When developing, we have to consider reusability and tests. TDD is a very difficult approach, so starting with it may cause more problems than solution, but just awareness of testability will do wonders. Let’s consider following as an example:

import { WebPartContext } from "@microsoft/sp-webpart-base";
import * as React from "react";
import { IItemWithAuthor } from "../model/IItemWithAuthor";
import { Checkbox, Spinner, Stack } from "office-ui-fabric-react";
import { SPFx, spfi, SPFI } from "@pnp/sp";
import { PnPListItemProvider } from "../dal/PnPListItemProvider";
import { ISPListItem } from "../model/ISPListItem";
import { IUserListItem } from "../model/IUserListItem";
import { ItemsWithAuthorDetailsManager } from "../manager/ItemsWithAuthorDetailsManager";

export interface IComplexComponentProps {
    context: WebPartContext;
}

export function ComplexComponent(props: IComplexComponentProps): JSX.Element {
    const [loading, setLoading] = React.useState(true);
    const [items, setItems] = React.useState<IItemWithAuthor[]>([]);

    const loadItems: ()=>Promise<IItemWithAuthor[]> = async () => {
        const sp: SPFI = spfi().using(SPFx(props.context));
        const itemsProvider = new PnPListItemProvider<ISPListItem>(sp, "Documents");
        const usersProvider = new PnPListItemProvider<IUserListItem>(sp, "User Information List");

        const manager = new ItemsWithAuthorDetailsManager(itemsProvider, usersProvider);
        return await manager.getItemsWithAuthorDetails();
        
    }
    React.useEffect(() => {
        loadItems().then((items) => {
            setItems(items);
            setLoading(false);
        }).catch((error) => {
            console.log(error);
            setLoading(false);
        });
    }, []);

    
    if (loading) {
        return <Spinner />;
    }
    return <Stack>
        {items.map((item) => {
            return <Stack horizontal key={item.Id}>
                <Stack.Item>{item.Title}</Stack.Item>
                <Stack.Item>{item.Author.Title}</Stack.Item>
                <Stack.Item>{item.Author.JobTitle}</Stack.Item>
                <Stack.Item>{<Checkbox label="Is admin" disabled checked={item.Author.IsSiteAdmin} />}</Stack.Item>
            </Stack>
        })}
    </Stack>
}

Here we have no separation at all. Almost everything is happening in the component and our dependencies are created in component lifecycle without any dependency injection. To unit test this, we would have to implement functional mocks of pnp js and sp context, which is huge investment.

We will circle back to this example and rewrite it for testability and usability. But for now…

Stuck in the middle with you…

Based on my experience, the easiest way to start unit testing is from the middle, so from manager classes. The main reason behind it, is we have to mock very little, and usually, only classes we developed, so there is no need for mocking 3rd party nor fetch api, as it should be already abstracted in data access layer. Let’s take a look at our manager class:

import { IListItemProvider } from "../dal/IListItemProvider";
import { IItemWithAuthor } from "../model/IItemWithAuthor";
import { ISPListItem } from "../model/ISPListItem";
import { IUserListItem } from "../model/IUserListItem";

export class ItemsWithAuthorDetailsManager {
    constructor(protected listItemProvider: IListItemProvider<ISPListItem>, protected userProvider: IListItemProvider<IUserListItem>) {

    }

    public async getItemsWithAuthorDetails(): Promise<IItemWithAuthor[]> {
        const items = await this.listItemProvider.getListItems();
        const uniqueUserIds = this.getUniqueUserIds(items);
        const users = await Promise.all(uniqueUserIds.map(id => this.userProvider.getById(id)));
        const itemsWithAuthorDetails = items.map(i => {
            const author = users.filter((u: IUserListItem) => u.Id === i.AuthorId)[0];
            return {
                ...i,
                Author: author
            }
        });
        return itemsWithAuthorDetails;
    }
    protected getUniqueUserIds(items: ISPListItem[]): number[] {
        const uniqueUserIds = items.map(i => i.AuthorId)
            .filter((value, index, self) => self.indexOf(value) === index);
        return uniqueUserIds;
    }
}

Note, this is the same class we are using in our bad example. What’s good here, is we can inject classes responsible for data access. With that, we can easily replace the concrete implementation at runtime. Let’s do that:

///<reference types="jest" />
import { ItemsWithAuthorDetailsManager } from "../../src/manager/ItemsWithAuthorDetailsManager";
describe("ItemsWithAuthorDetailsManager", ()=>{
	test("should get users", async ()=>{
		const listItemProvider = {
			getListItems: jest.fn(),
			getById: jest.fn()
		};
		const userProvider = {
			getListItems: jest.fn(),
			getById: jest.fn()
		};

		jest.spyOn(listItemProvider, "getListItems").mockResolvedValueOnce([
			{
				Id: 1,
				Title: "Item 1",
				AuthorId: 1
			},
			{
				Id: 2,
				Title: "Item 2",
				AuthorId: 2
			},
			{
				Id: 3,
				Title: "Item 3",
				AuthorId: 1
			}
		]);
		jest.spyOn(userProvider, "getById").mockResolvedValueOnce({
			Id: 1,
			Title: "User 1"
		});
		jest.spyOn(userProvider, "getById").mockResolvedValueOnce({
			Id: 2,
			Title: "User 2"
		});

		const manager = new ItemsWithAuthorDetailsManager(listItemProvider, userProvider);
		const items = await manager.getItemsWithAuthorDetails();

		expect(listItemProvider.getListItems).toBeCalled();
		expect(items[0].Author.Title).toEqual("User 1");
	});
});

Here we can use jest.mock to return hard coded values and verify our manager correctly compiles the response. Thanks to dependency on listItemProvider, it’s easy. Note, we can also easily compose (or decorate) providers with additional functionality (such as cache or proxy) and such decision will not affect our manager at all, hence our test will not need to change in any way to support different provider.

Closer to the edge

Having our manager class tested let’s focus on data access layer. There are multiple ways we can access list items in SPFx. For our sample I implemented two most popular and one more I consider optimal.

Let’s start with accessing list items using PnP JS. PnP JS is without a doubt, most popular helper library for SPFx. This amazing library provides us with types for most of SharePoint models but also with easy to use fluent api. Let’s see how we can benefit from it:

import { SPFI } from "@pnp/sp";
import { ISPListItem } from "../model/ISPListItem";
import { IListItemProvider } from "./IListItemProvider";
import "@pnp/sp/webs";
import "@pnp/sp/lists";
import "@pnp/sp/items";

export class PnPListItemProvider<T extends ISPListItem> implements IListItemProvider<T>{
    
    constructor(protected spfi: SPFI, protected listName: string) { }
    
    public async getListItems(): Promise<T[]> {
        return await this.spfi.web.lists.getByTitle(this.listName).items();
    }
    public async getById(id: number): Promise<T> {
        return await this.spfi.web.lists.getByTitle(this.listName).items.getById(id)();
    }
}

Thanks to PnP JS implementation is rather straight forward. From unit testing perspective there is one issue – we need to understand how the library works to provide a proper mocking mechanism:

///<reference types="jest" />
import { PnPListItemProvider } from "../../src/dal/PnPListItemProvider";
import { SPFI } from "@pnp/sp";
import { ISPListItem } from "../../src/model/ISPListItem";
import { mockISPListItem, mockISPListItems } from "./Mocks";

jest.mock("@pnp/sp/webs", () => ({}));
jest.mock("@pnp/sp/lists", () => ({}));
jest.mock("@pnp/sp/items", () => ({}));

describe("PnPListItemProvider", () => {
	let mockSpfi: SPFI;
	const listName = "Test List";
	const itemId = 1;

	it("Should call the correct methods when calling getListItems", async () => {
		// Mock SPFI
		mockSpfi = {
			web: {
				lists: {
					getByTitle: jest.fn().mockReturnValue({ items: () => mockISPListItems })
				},
			},
		} as unknown as SPFI;
		const provider = new PnPListItemProvider<ISPListItem>(mockSpfi, listName);

		const items = await provider.getListItems();

		expect(mockSpfi.web.lists.getByTitle).toBeCalledWith(listName);
		expect(items).toEqual(mockISPListItems);
	});

	it("Should call the correct methods when calling getById", async () => {
		// Mock SPFI
		mockSpfi = {
			web: {
				lists: {
					getByTitle: jest.fn().mockReturnValue({
						items: {
							getById: jest.fn().mockReturnValue(()=>Promise.resolve(mockISPListItem))
						}
					})
				},
			},
		} as unknown as SPFI;
		const provider = new PnPListItemProvider<ISPListItem>(mockSpfi, listName);

		const item = await provider.getById(itemId);

		expect(mockSpfi.web.lists.getByTitle).toBeCalledWith(listName);
		expect(item).toEqual(mockISPListItem);
	});
});

As You can see, mock of PnP context is quite complex. However, it is difficult only for the first time. Most of functionalities provided by PnP JS depend on the SPFi in similar way.
Note, our assertion verifies only what kind of parameters we send down to SPFi. We don’t want to test if we will get any items from our call – if You want to test for actual data retrieval, write integration tests.

DAL with context.spHttpClient

Second most popular way to consume SharePoint REST API is using spHttpClient from context. To consume the api You can either inject spContext object or just spHttpClient and web absolute url. I highly recommend against passing spContext but based on my experience, this is more popular approach. From unit testing perspective, it doesn’t make any difference. Let’s consider list item provider with web part context:

import { WebPartContext } from "@microsoft/sp-webpart-base";
import { ISPListItem } from "../model/ISPListItem";
import { IListItemProvider } from "./IListItemProvider";
import { SPHttpClient } from "@microsoft/sp-http";

export class SPContextListItemProvider<T extends ISPListItem> implements IListItemProvider<T>{
    constructor(protected context: WebPartContext, protected listName: string) { }

    public async getListItems(): Promise<T[]> {
        const result = await this.context.spHttpClient.get(`${this.context.pageContext.web.absoluteUrl}/_api/web/lists/getbytitle('${this.listName}')/items`, SPHttpClient.configurations.v1);
        const resultJson = await result.json();

        return resultJson.value;
    }
    public async getById(id: number): Promise<T> {
        const result = await this.context.spHttpClient.get(`${this.context.pageContext.web.absoluteUrl}/_api/web/lists/getbytitle('${this.listName}')/items(${id})`, SPHttpClient.configurations.v1);
        const resultJson = await result.json();

        return resultJson;
    }
}

Once again our code is not too complicated. The biggest difference between this approach and PnP JS is You have to know the API url. Let’s see how we can unit test this class:

///<reference types="jest" />
import { SPContextListItemProvider } from "../../src/dal/SPContextListItemProvider";
import { WebPartContext } from "@microsoft/sp-webpart-base";
import { SPHttpClient } from "@microsoft/sp-http";
import { ISPListItem } from "../../src/model/ISPListItem";
import { mockISPListItem, mockISPListItems } from "./Mocks";

jest.mock("@microsoft/sp-http", () => ({
	SPHttpClient: {
		configurations: {
			v1: "v1",
		},
	},
	SPHttpClientResponse: jest.fn(),
}));

describe("SPContextListItemProvider", () => {
	let mockContext: WebPartContext;
	const listName = "Test List";
	const itemId = 1;

	it("Should call the correct URL when calling getListItems", async () => {
		
		// Mock WebPartContext
		mockContext = {
			spHttpClient: {
				get: jest.fn().mockReturnValue(Promise.resolve({
					ok: true,
					json: jest.fn().mockReturnValue(Promise.resolve({ value: [mockISPListItems] }))
				})),
			},
			pageContext: {
				web: {
					absoluteUrl: "http://test.sharepoint.com",
				},
			},
		} as unknown as WebPartContext;
		const provider = new SPContextListItemProvider<ISPListItem>(mockContext, listName);
		const itemsUrl = `${mockContext.pageContext.web.absoluteUrl}/_api/web/lists/getbytitle('${listName}')/items`;

		await provider.getListItems();

		expect(mockContext.spHttpClient.get).toBeCalledWith(itemsUrl, SPHttpClient.configurations.v1);
	});

	it("Should call the correct URL when calling getById", async () => {
		// Mock WebPartContext
		mockContext = {
			spHttpClient: {
				get: jest.fn().mockReturnValue(Promise.resolve({
					ok: true,
					json: jest.fn().mockReturnValue(Promise.resolve({ value: mockISPListItem }))
				})),
			},
			pageContext: {
				web: {
					absoluteUrl: "http://test.sharepoint.com",
				},
			},
		} as unknown as WebPartContext;
		const provider = new SPContextListItemProvider<ISPListItem>(mockContext, listName);
		const itemUrl = `${mockContext.pageContext.web.absoluteUrl}/_api/web/lists/getbytitle('${listName}')/items(${itemId})`;

		await provider.getById(itemId);

		expect(mockContext.spHttpClient.get).toBeCalledWith(itemUrl, SPHttpClient.configurations.v1);
	});
});

We are in a similar situation as previously. We have to mock spContext this time, but it’s not a problem as we can mock only parts of it (spHttpClient and web). Note, this time our assertion verifies if we are calling correct url. Once again, we don’t want to call actual api, for unit test it’s enough to just validate how our call is sent.

…and abstraction for all

Previously presented approaches are just fine. 9 out of 10 cases they will be good enough, however there is this one case You might need more abstraction around http client. You may find it useful if You want to provide telemetry on call level, proxy, auto-batching or rate limiter. In such cases I recommend implementing interface for IHttpClient with get and post methods and use this client as abstraction in our DAL classes. This is how we can implement it:

import { ISPListItem } from "../model/ISPListItem";
import { IHttpClient } from "./IHttpClient";
import { IListItemProvider } from "./IListItemProvider";

export class DIListItemProvider<T extends ISPListItem> implements IListItemProvider<T>{

    constructor(protected httpClient: IHttpClient, protected siteUrl: string, protected listName: string,) { }
    public async getById(id: number): Promise<T> {
        const result = await this.httpClient.get<T>(`${this.siteUrl}/_api/web/lists/getbytitle('${this.listName}')/items(${id})`);
        return result;
    }

    public async getListItems(): Promise<T[]> {
        const result = await this.httpClient.get<{ value: T[] }>(`${this.siteUrl}/_api/web/lists/getbytitle('${this.listName}')/items`);
        return result.value;
    }
}

Implementation of our class is almost identical to the one using spContext object with only difference being dependency on custom IHttpClient. This can simplify our unit test as we have to mock only our IHttpClient, in particular we do not have to mock @microsoft/sp-http. Let’s take a look at our test:

///<reference types="jest" />
import { DIListItemProvider } from "../../src/dal/DIListItemProvider";
import { IHttpClient } from "../../src/dal/IHttpClient";
import { ISPListItem } from "../../src/model/ISPListItem";
import { mockISPListItem, mockISPListItems } from "./Mocks";
describe("DIListItemProvider", () => {
	let mockHttpClient: IHttpClient;
	const siteUrl = "http://test.sharepoint.com";
	const listName = "Test List";
	const itemId = 1;
	
	beforeEach(() => {
		// Mock HttpClient
		mockHttpClient = {
			get: jest.fn(),
			post: jest.fn()
		};
	});

	it("Should call the correct URL when calling getById", async () => {
		const provider = new DIListItemProvider<ISPListItem>(mockHttpClient, siteUrl, listName);
		const itemUrl = `${siteUrl}/_api/web/lists/getbytitle('${listName}')/items(${itemId})`;

		jest.spyOn(mockHttpClient, "get").mockResolvedValue(mockISPListItem);
		const item = await provider.getById(itemId);

		expect(mockHttpClient.get).toBeCalledWith(itemUrl);
		expect(item.Title).toEqual(mockISPListItem.Title);
	});

	it("Should call the correct URL when calling getListItems", async () => {
		const provider = new DIListItemProvider<ISPListItem>(mockHttpClient, siteUrl, listName);
		const itemsUrl = `${siteUrl}/_api/web/lists/getbytitle('${listName}')/items`;

		jest.spyOn(mockHttpClient, "get").mockResolvedValue({ value: mockISPListItems });
		const items = await provider.getListItems();

		expect(mockHttpClient.get).toBeCalledWith(itemsUrl);
		expect(items).toEqual(mockISPListItems);
	});
});

This test differs only in few nuances from previous ones. There is no jest.mock method call at the top, because we don’t have to intercept loading SPFx libraries as our class is totally SPFx independent. Once again, we are only verifying if we pass correct url to http client.

Finally, at the front

With such layering, let’s try to refactor the component from the beginning of this post. As now we can depend only on our manager the component simplifies quite a bit:

import * as React from "react";
import { ItemsWithAuthorDetailsManager } from "../manager/ItemsWithAuthorDetailsManager";
import { IItemWithAuthor } from "../model/IItemWithAuthor";
import { Checkbox, Spinner, Stack } from "office-ui-fabric-react";

export interface IItemsWithAdminInfoProps {
    manager: ItemsWithAuthorDetailsManager;
}

export function ItemsWithAdminInfo(props: IItemsWithAdminInfoProps): JSX.Element {
    const [loading, setLoading] = React.useState(true);
    const [items, setItems] = React.useState<IItemWithAuthor[]>([]);

    React.useEffect(() => {
        props.manager.getItemsWithAuthorDetails().then((items) => {
            setItems(items);
            setLoading(false);
        }).catch((error) => {
            console.log(error);
            setLoading(false);
        });
    }, []);

    if (loading) {
        return <Spinner data-testId="spinner" />;
    }
    return <Stack>
        {items.map((item) => {
            return <Stack horizontal key={item.Id}>
                <Stack.Item>{item.Title}</Stack.Item>
                <Stack.Item>{item.Author.Title}</Stack.Item>
                <Stack.Item>{item.Author.JobTitle}</Stack.Item>
                <Stack.Item>{<Checkbox label="Is admin" disabled checked={item.Author.IsSiteAdmin} />}</Stack.Item>
            </Stack>
        })}
    </Stack>
}

For more advanced developers, it would be a great exercise to implement same component using custom getListItems() hook which could depend on http client provided by some binding context.

Getting back to our sample – our component is now just a client for the manager. This means, we can mock our manager and test the component in isolation. Let’s take a look:

///<reference types="jest" />
import * as React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import '@testing-library/jest-dom';
import { ItemsWithAuthorDetailsManager } from '../../src/manager/ItemsWithAuthorDetailsManager';
import { IItemWithAuthor } from '../../src/model/IItemWithAuthor';
import { ItemsWithAdminInfo } from "../../src/components/ItemsWithAdminInfo";

describe("ItemsWithAdminInfo", () => {
  let mockManager: ItemsWithAuthorDetailsManager;
  let mockItems: IItemWithAuthor[];

  beforeEach(() => {
    // Mock data
    mockItems = [{
      Id: 1,
      Title: "Test Item",
      Author: {
        Title: "Author 1",
        JobTitle: "Job Title 1",
        IsSiteAdmin: true
      }
    } as IItemWithAuthor, {
      Id: 2,
      Title: "Test Item 2",
      Author: {
        Title: "Author 2",
        JobTitle: "Job Title 2",
        IsSiteAdmin: false
      }
    } as IItemWithAuthor];

    // Mock manager
    mockManager = {
      getItemsWithAuthorDetails: jest.fn().mockResolvedValue(mockItems)
    } as unknown as ItemsWithAuthorDetailsManager;
  });

  it("should display spinner when loading", async () => {
    mockManager.getItemsWithAuthorDetails = jest.fn().mockReturnValue(new Promise(() => {}));
    render(<ItemsWithAdminInfo manager={mockManager} />);
    
    expect(screen.getByTestId('spinner')).toBeInTheDocument();
  });

  it("should display items when loaded", async () => {
    render(<ItemsWithAdminInfo manager={mockManager} />);
    
    await waitFor(() => {
      expect(screen.getByText(mockItems[0].Title)).toBeInTheDocument();
      expect(screen.getByText(mockItems[1].Title)).toBeInTheDocument();
      expect(screen.getByText(mockItems[0].Author.Title)).toBeInTheDocument();
      expect(screen.getByText(mockItems[1].Author.Title)).toBeInTheDocument();
      // Add more assertions as needed
    });
  });

  it("should handle error when getting items fails", async () => {
    mockManager.getItemsWithAuthorDetails = jest.fn().mockRejectedValue(new Error("Test Error"));
    console.log = jest.fn();
    render(<ItemsWithAdminInfo manager={mockManager} />);

    await waitFor(() => {
      expect(console.log).toHaveBeenCalledWith(new Error("Test Error"));
    });
  });
});

In this test we are using testing-library/react to be able to render react components in mocked DOM. In our package.json file I set testEnv to jsdom and finally added libraries to extends assertions provided by jest (@testing-library/jest-dom/extend-expect introduces inDocument assertion).

Summary

It is almost impossible to write unit tests for components implemented without proper layering and dependency design. On the other hand, writing unit tests and TDD in particular, will force You to write more modular, loosely connected code which is easier to maintain and refactor.

Thanks for reading, have a great day.

Published by Marcin Wojciechowski

Coding enthusiast, TDD evangelist, OOP champion and SOLID proponent. Likes to code as well as talking about the code. For fun, besides software development, plays guitar, basketball and video games. Enjoys cooking and learning.

Leave a comment

Design a site like this with WordPress.com
Get started