How to test Server Functions #2701
Replies: 9 comments
|
@cameronb23 did you ever get anywhere with this? Curious to hear how others are testing these functions as well. |
|
I'll second this, and add that it would be great if tests were included in the examples. |
|
To work around the lack of a testing approach for server functions until there's an official solution, I mocked // test/create-server-fn.ts
import { createServerFn } from "@tanstack/react-start";
import { vi } from "vitest";
type CreateServerFn = typeof createServerFn<"GET">;
type ServerFnBuilder = ReturnType<CreateServerFn>;
const mockServerFunctionBuider: ServerFnBuilder = vi.hoisted(() => {
return {
middleware: vi.fn(() => mockServerFunctionBuider),
inputValidator: vi.fn(() => mockServerFunctionBuider),
handler: vi.fn((func) => func),
} as unknown as ServerFnBuilder;
});
const mockCreateServerFn: CreateServerFn = vi.hoisted(() => {
return vi.fn(() => mockServerFunctionBuider);
});
vi.mock("@tanstack/react-start", async (importOriginal) => {
return {
...(await importOriginal()),
createServerFn: mockCreateServerFn,
};
}); |
|
This has been really the only disappointment in what otherwise has been a fantastic DX. A unit test could just test the server handler in isolation if you exported just that function separate from the With some help from GPT-5.3-codex, I've been experimenting with this utility, but it has a few limitations that I've found so far:
import { requestHandler } from "@tanstack/react-start/server";
import { runWithStartContext } from "@tanstack/start-storage-context";
// @ts-expect-error This is a virtual module generated by the compiler
import { getServerFnById } from "#tanstack-start-server-fn-resolver";
interface RunServerFnOptions {
method?: "GET" | "POST";
data?: unknown;
headers?: HeadersInit;
context?: Record<string, unknown>;
request?: Request;
}
interface ServerFnExecutionResult {
result?: unknown;
error?: unknown;
context?: unknown;
}
export async function runServerFn<T = unknown>(
serverFn: {
method?: "GET" | "POST";
serverFnMeta?: {
id: string;
};
},
options: RunServerFnOptions = {},
): Promise<{ result: T; response: Response; context: unknown }> {
const serverFnId = serverFn.serverFnMeta?.id;
if (!serverFnId) {
throw new Error(
"Expected a transformed server function reference with serverFnMeta.id",
);
}
const compiledServerFn = await getServerFnById(serverFnId);
const method = options.method ?? serverFn.method ?? "GET";
const request =
options.request ??
new Request("http://localhost/__vitest_server_fn__", {
method,
headers: options.headers,
});
let executionResult: ServerFnExecutionResult | undefined;
const handler = requestHandler(async () => {
await runWithStartContext(
{
getRouter: async () => {
throw new Error(
"Router access is not available in this test harness",
);
},
request,
startOptions: {},
contextAfterGlobalMiddlewares: options.context ?? {},
executedRequestMiddlewares: new Set(),
},
async () => {
const result = await compiledServerFn({
method,
data: options.data,
headers: options.headers,
context: options.context,
});
if (!isServerFnExecutionResult(result)) {
throw new Error("Server function returned an unexpected payload");
}
executionResult = result;
},
);
return new Response(null, { status: 204 });
});
const response = await handler(request, {});
if (!executionResult) {
throw new Error("Server function did not execute");
}
if (executionResult.error) {
throw executionResult.error;
}
return {
result: executionResult.result as T,
context: executionResult.context,
response,
};
}
function isServerFnExecutionResult(
value: unknown,
): value is ServerFnExecutionResult {
if (typeof value !== "object" || value === null) {
return false;
}
return "result" in value || "error" in value || "context" in value;
}Then you can execute tests like this. test("executes server function", async () => {
const { response } = await runServerFn(serverFn, {
data: { someData: true },
context: { globalContext: 'something' }
headers: new Headers({ session: 'asdf' })
});
expect(response).toEqual(
expect.objectContaining({
status: 204,
}),
);
}) |
|
I'll pile on here, this is a noticeable gap. I've taken a different direction, of just moving all the logic into standalone pure functions that the server function calls and returns, which allows me to unit test the handling albeit with a bit of indirection. It works, but it is unnecessary. |
|
What a nightmare is maintaining our testing utilities when updating Tanstack Start... I wish there was an easier way to test server functions, but I guess we'll have to split the functionality into their own functions and use server functions as just wrappers. |
|
This is indeed a major issue. Would really love to see "new" approaches/technologies/frameworks these days more think the testing story through from the start. Feels like an afterthought - as if no-one actually needed well tested applications? 🤔 |
|
At the time I comment this there is no official way to test the server function. import { createFileRoute } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { useState } from "react";
import z from "zod";
let count = 0;
export const countSchema = z.number().default(1);
export const incrementCounterFn = createServerFn({ method: "POST" })
.validator(countSchema)
.handler((data) => {
count += data.data;
return count;
});
export const Route = createFileRoute("/counter")({
component: Counter,
});
function Counter() {
const [displayedCount, setDisplayedCount] = useState(0);
const [isIncrementing, setIsIncrementing] = useState(false);
const [error, setError] = useState(false);
async function handleIncrement() {
setIsIncrementing(true);
setError(false);
try {
setDisplayedCount(await incrementCounterFn());
} catch {
setError(true);
} finally {
setIsIncrementing(false);
}
}
return (
<main className="flex min-h-screen items-center justify-center">
<section>
<p>Server counter</p>
<h1>{displayedCount}</h1>
<button
type="button"
onClick={handleIncrement}
disabled={isIncrementing}
>
{isIncrementing ? "Incrementing..." : "Increment"}
</button>
{error && <p>Unable to increment the counter.</p>}
</section>
</main>
);
}I split it to several file // src/routes/counter.tsx
import { createFileRoute } from "@tanstack/react-router";
import { Counter } from "../components/counter.tsx"
export const Route = createFileRoute("/counter")({
component: Counter,
});// src/components/counter.tsx
import { useState } from "react";
import { incrementCounterFn } from "../serverFn/counter"
export function Counter() {
const [displayedCount, setDisplayedCount] = useState(0);
const [isIncrementing, setIsIncrementing] = useState(false);
const [error, setError] = useState(false);
async function handleIncrement() {
setIsIncrementing(true);
setError(false);
try {
setDisplayedCount(await incrementCounterFn());
} catch {
setError(true);
} finally {
setIsIncrementing(false);
}
}
return (
<main className="flex min-h-screen items-center justify-center">
<section>
<p>Server counter</p>
<h1>{displayedCount}</h1>
<button
type="button"
onClick={handleIncrement}
disabled={isIncrementing}
>
{isIncrementing ? "Incrementing..." : "Increment"}
</button>
{error && <p>Unable to increment the counter.</p>}
</section>
</main>
);
}// src/schema/counter.ts
import z from "zod";
export const countSchema = z.number().default(1);
export type countType = z.infer<typeof countSchema>;// src/serverFn/counter.ts
import { incrementCounter } from "../service/counter.ts"
import { countSchema } from "../schema/counter.ts"
export const incrementCounterFn = createServerFn({ method: "POST" })
.validator(countSchema)
.handler((data) => {
return incrementCounter(data.data);
});// src/service/counter.ts
import { countType } from "../schema/counter.ts"
let count = 0;
export const incrementCounter = (data: countType) => {
count += data;
return count;
};then I just need to test the component and service like this // @vitest-environment jsdom
// to test the component
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Counter } from "src/components/counter";
import type { incrementCounterFn } from "../serverFn/counter";
const { incrementCounterMock } = vi.hoisted(() => ({
incrementCounterMock: vi.fn<typeof incrementCounterFn>(),
}));
vi.mock("../server/serverFn/counter", () => ({
incrementCounterFn: incrementCounterMock,
}));
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("Counter", () => {
it("starts at zero", () => {
render(<Counter />);
expect(screen.getByRole("heading").textContent).toBe("0");
expect(screen.getByRole("button", { name: "Increment" })).toHaveProperty(
"disabled",
false,
);
});
it("increments the displayed count", async () => {
incrementCounterMock.mockResolvedValueOnce(1);
render(<Counter />);
fireEvent.click(screen.getByRole("button", { name: "Increment" }));
await waitFor(() => expect(incrementCounterMock).toHaveBeenCalledWith());
expect((await screen.findByRole("heading")).textContent).toBe("1");
});
it("disables the button while incrementing", async () => {
let resolveIncrement: (value: number) => void = () => {};
incrementCounterMock.mockReturnValueOnce(
new Promise<number>((resolve) => {
resolveIncrement = resolve;
}),
);
render(<Counter />);
fireEvent.click(screen.getByRole("button", { name: "Increment" }));
expect(
screen.getByRole("button", { name: "Incrementing..." }),
).toHaveProperty("disabled", true);
resolveIncrement(1);
await waitFor(() =>
expect(screen.getByRole("button", { name: "Increment" })).toHaveProperty(
"disabled",
false,
),
);
});
it("shows an error when the increment fails", async () => {
incrementCounterMock.mockRejectedValueOnce(new Error("Request failed"));
render(<Counter />);
fireEvent.click(screen.getByRole("button", { name: "Increment" }));
expect(
await screen.findByText("Unable to increment the counter."),
).toBeTruthy();
expect(screen.getByRole("button", { name: "Increment" })).toHaveProperty(
"disabled",
false,
);
});
});// @vitest-environment node
// to test the service
import { describe, expect, it } from "vitest";
import { incrementCounter } from "../service/counter";
describe("counter service", () => {
it("increments and returns the running count", () => {
expect(incrementCounter(2)).toBe(2);
expect(incrementCounter(3)).toBe(5);
});
});Although it's require more set up but it less prone to library change. Because it doesn't mock the tanstack start library. While also using typesafety to make sure client and server are in sync. |
Uh oh!
There was an error while loading. Please reload this page.
I believe that server functions, just like any other functions or unit of work, should be able to be well tested. Currently, this doesn't work out of the box as expected (via Vitest) due to some inherent checks that are made and errors thrown when creating the server function:
While this is likely necessary for Start, there should be some official method or guidance on how to test these functions. I could imagine there being a change made to
createServerFnto ignore these checks if the environment is test or something, or this may be as simple as adding a section to the documentation. For now, the best that I have come up with is mocking the call globally - this is an example for Vitest, but I assume it would be similar for Jest and others.All reactions