Testing with Deep Partial Types
When building apps with large complex datasets, a common problem while testing is that a component may interact with many parts of the application state.
When this happens, it's inconvenient to have to stub out the full application state in every test.
There are a variety of approaches to this problem, each with its own set of tradeoffs.
Using deep partial data with mock functions is one of my preferred ways to make testing easier.
Before looking at deep partial data mocking, it's helpful to compare it to a common simpler approach, which is to stub out incomplete data.
As an example scenario, an app might have the following state:
interface AppState {
isLoggedIn: boolean;
logo: Image;
content: string;
copyright: string;
}
interface Image {
alt: string;
src: string;
}
When testing the footer of the page, a test might be set up as:
const { getByText } = render(
<Footer />,
wrapper: appWrapper({
copyright: 'Lorem ipsum'
} as AppState),
);
expect(getByText('Lorem ipsum')).toBeInTheDocument();
But while testing the <Header> we might run into issues due to the logo data being unavailable:
const { getByRole } = render(
<Header />,
wrapper: appWrapper({
isLoggedIn: false,
} as AppState),
);
expect(getByRole('link', { name: 'Log In'})).toBeInTheDocument();
TypeError: Cannot read properties of undefined (reading 'src')
This can lead to needing to specify data that's entirely irrelevant to the test.
wrapper: appWrapper({
isLoggedIn: false,
+ logo: {
+ alt: '',
+ src: '',
+ }
} as AppState),
Deep partial data mocking avoids this issue by ensuring the minimal required data is always present when testing.
In order to use deep partial data, you create a mock function that accepts a deep partial version of the type and returns the full type.
import type { PartialDeep } from "type-fest";
function mockAppState(appState?: PartialDeep<AppState>): AppState {
return {
// Default values for all primitive properties
isLoggedIn: false,
content: "",
copyright: "",
// Override data with any properties that were specified in the input state
...appState,
// Use a `mock` utility function for any object properties
logo: mockImage(appState?.logo),
};
}
function mockImage(image?: PartialDeep<Image>): Image {
return {
alt: "",
src: "",
...image,
};
}
We can return to our header example with a mock function:
const { getByRole } = render(
<Header />,
wrapper: appWrapper(mockAppState({
isLoggedIn: false,
})),
);
This time, we know that some minimal logo data is available during our test, and we don't have to worry about the logo being rendered while we test the login link.
Arrays
If the type you're testing includes an array type, a deep partial version of the array will allow every item in the array to be partial, which means you will need to ensure that every item is mocked.
type MoreAppState = {
images: Image[];
};
mocking each item can be done by calling map on the array and passing the mock function for the subtype:
function mockMoreAppState(moreAppState: PartialDeep<MoreAppState>): MoreAppState {
return {
images: moreAppState?.images.map(mockImage) ?? [];
}
}
Optional Properties
Values that can be undefined must either default to undefined, or use very careful handling in order to ensure that an undefined value can be produced by the mock* function.
If we imagine an AppState with an optional user property for user data to represent a logged-in user:
interface AppState {
user?: User;
}
interface User {
first: string;
last: string;
}
a mock function that specifies a value for the user property will make it impossible to generate a mock object where the user property is undefined:
function mockAppState(appState?: PartialDeep<AppState>): AppState {
return {
// DON'T DO THIS!
user: mockUser(appState?.user),
};
}
function mockUser(user?: PartialDeep<User>): User {
return {
first: "Tester",
last: "Testerson",
...user,
};
}
mockAppState({
user: {
first: "John",
},
});
// Works fine
// { user: { first: 'John', last: 'Testerson' } }
mockAppState({ user: undefined });
// Uh oh! Valid data is being overwritten!
// { user: { first: 'Tester', last: 'Testerson' } }
Instead, it's better to check if the property was provided before mocking the sub-object:
function mockAppState(appState?: PartialDeep<AppState>): AppState {
return {
user: appState?.user ? mockUser(appState?.user) : undefined,
};
}
mockAppState({
user: {
first: "John",
},
});
// { user: { first: 'John', last: 'Testerson' } }
mockAppState({ user: undefined });
// { user: undefined }
If you would like the mock function to mock out data when the property was not provided, you can check for the existence of the property as part of mocking.
This may be surprising and unusual behavior so proceed with caution:
function mockAppState(appState? PartialDeep<AppState>): AppState {
return {
user: !('user' in appState) || appState?.user ? mockUser(appState?.user) : undefined,
}
}
Union Types
Mocking out a deep partial of a union type requires being able to discern which of the subtypes is being mocked out.
As an example, the following website type can have a collection of pages which may be a Page, Post or Quote.
interface Website {
pages: AnyPage[];
}
type AnyPage = Page | Post | Quote;
interface Page {
type: "page";
content: string;
}
interface Post {
type: "post";
content: string;
author: string;
}
interface Quote {
type: "quote";
quote: string;
byline: string;
}
When mocking data it's possible to provide data that can ambiguously match more than one of the types in the type union.
// This could be a Page, Post, or Quote
mockAnyPage({});
// This could be a Page or Post
mockAnyPage({
content: "lorem ipsum",
});
In order to disambiguate the partial data, I recommend using type predicates to determine which of the partial types is appropriate to construct:
function mockAnyPage(anyPage?: PartialDeep<AnyPage>): AnyPage {
if (isPartialQuote(anyPage)) {
return mockQuote(anyPage);
}
if (isPartialPost(anyPage)) {
return mockPost(anyPage);
}
return mockPage(anyPage);
}
The predicate functions should check for properties or types that indicate that the data is a specific type.
function isPartialQuote(
anyPage?: PartialDeep<AnyPage>
): anyPage is PartialDeep<Quote> {
return anyPage?.type === "quote" || "quote" in anyPage || "byline" in anyPage;
}
function isPartialPost(
anyPage?: PartialDeep<AnyPage>
): anyPage is PartialDeep<Post> {
return anyPage?.type === "post" || "author" in anyPage;
}
Note that it's important to order the checks from the most specific type to the least specific type. Objects that are ambiguous will be mocked out using more generic types.
In this example, mockAnyPage({}) will produce a Page type because there is no indicator that a more specific type ought to be used.
Drawbacks
While deep partial data mocking can be a useful strategy for some unit and integration tests, it's important to also know when not to use it.
Deep partial data mocking is verbose. It requires creating utility functions for each of the types used in the app state. This may be more effort than it's worth for testing when components have a narrow scope, or well-defined dependencies.
The DeepPartial<T> type—or PartialDeep<T> in the case of type-fest—may cause weird behaviors with native types, where an easy-to-create object may be treated as partial. type-fest excludes a custom set of BuiltIn types, including Date and RegExp, but you may find yourself needing to exclude more types, such as those provided by Temporal.
The mock functions give no assurances that the data being generated is actually valid or representative of a real user scenario. Mock functions are not the same as factories. For some categories of tests you will be better served by creating factory functions that generate more complex structures with randomized data.
Lastly, since deep partial data mocking automatically fills in data, it's possible to forget to specify values that are required in a test if the values happen to match the default value.
