Domphy

Testing

Setup

Install test dependencies:

pnpm add -D vitest @vitest/ui jsdom @testing-library/jest-dom

Configure Vitest:

// vitest.config.ts
import { defineConfig } from "vitest/config"

export default defineConfig({
  test: {
    environment: "jsdom",
    globals: true,
  },
})

Create a test QueryClient

Always use a fresh QueryClient per test to avoid state leaking between tests:

import { QueryClient } from "@domphy/query"
import { afterEach, beforeEach } from "vitest"

let queryClient: QueryClient

beforeEach(() => {
  queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,       // disable retries in tests — fail fast
        gcTime: Infinity,   // keep cache alive for the whole test
      },
    },
  })
  queryClient.mount()
})

afterEach(() => {
  queryClient.unmount()
  queryClient.clear()     // wipe all cached data
})

Test a query with mocked fetch

import { createQuery } from "@domphy/query/domphy"
import { describe, it, expect, vi } from "vitest"

describe("createQuery", () => {
  it("fetches and stores data", async () => {
    const mockFetchUser = vi.fn().mockResolvedValue({ id: 1, name: "Alice" })

    const user = createQuery(queryClient, {
      queryKey: () => ["user", 1],
      queryFn: mockFetchUser,
    })

    // Initially pending
    expect(user.isPending()).toBe(true)

    // Wait for data
    await vi.waitFor(() => expect(user.isSuccess()).toBe(true))

    expect(user.data()).toEqual({ id: 1, name: "Alice" })
    expect(mockFetchUser).toHaveBeenCalledTimes(1)
  })
})

Test error handling

it("handles fetch errors", async () => {
  const error = new Error("Network error")
  const mockFetch = vi.fn().mockRejectedValue(error)

  const query = createQuery(queryClient, {
    queryKey: () => ["fail"],
    queryFn: mockFetch,
    retry: false,
  })

  await vi.waitFor(() => expect(query.isError()).toBe(true))

  expect(query.error()).toBe(error)
})

Test mutations

import { createMutation } from "@domphy/query/domphy"

it("mutation calls mutationFn and updates state", async () => {
  const mockCreate = vi.fn().mockResolvedValue({ id: 42, name: "New post" })

  const createPost = createMutation(queryClient, {
    mutationFn: mockCreate,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["posts"] })
    },
  })

  expect(createPost.isPending()).toBe(false)

  createPost.mutate({ name: "New post" })

  await vi.waitFor(() => expect(createPost.isSuccess()).toBe(true))

  expect(mockCreate).toHaveBeenCalledWith({ name: "New post" })
  expect(createPost.data()).toEqual({ id: 42, name: "New post" })
})

Pre-populate the cache for component tests

Skip the async loading phase by seeding the cache before render:

it("renders user data from cache", () => {
  // Pre-populate — no network request needed
  queryClient.setQueryData(["user", 1], { id: 1, name: "Alice" })

  const user = createQuery(queryClient, {
    queryKey: () => ["user", 1],
    queryFn: () => fetch("/api/users/1"),
    staleTime: Infinity,   // won't refetch — use cached value
  })

  expect(user.isSuccess()).toBe(true)
  expect(user.data()).toEqual({ id: 1, name: "Alice" })
})

Test cache invalidation

it("invalidates and refetches after mutation", async () => {
  const mockFetch = vi.fn()
    .mockResolvedValueOnce([{ id: 1, text: "Todo 1" }])   // first fetch
    .mockResolvedValueOnce([{ id: 1, text: "Todo 1" }, { id: 2, text: "Todo 2" }])   // after create

  const todos = createQuery(queryClient, {
    queryKey: () => ["todos"],
    queryFn: mockFetch,
  })

  await vi.waitFor(() => expect(todos.isSuccess()).toBe(true))
  expect(todos.data()).toHaveLength(1)

  const createTodo = createMutation(queryClient, {
    mutationFn: vi.fn().mockResolvedValue({ id: 2, text: "Todo 2" }),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["todos"] }),
  })

  createTodo.mutate({ text: "Todo 2" })

  await vi.waitFor(() => expect(todos.data()).toHaveLength(2))
  expect(mockFetch).toHaveBeenCalledTimes(2)
})

Testing with fake timers

For polling and staleTime tests, use Vitest's fake timers:

import { vi, beforeEach, afterEach } from "vitest"

beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())

it("refetches every 5 seconds", async () => {
  const mockFetch = vi.fn().mockResolvedValue("data")

  const query = createQuery(queryClient, {
    queryKey: () => ["polling"],
    queryFn: mockFetch,
    refetchInterval: 5_000,
  })

  await vi.waitFor(() => expect(query.isSuccess()).toBe(true))
  expect(mockFetch).toHaveBeenCalledTimes(1)

  // Advance 5 seconds
  await vi.advanceTimersByTimeAsync(5_000)
  expect(mockFetch).toHaveBeenCalledTimes(2)

  // Another 5 seconds
  await vi.advanceTimersByTimeAsync(5_000)
  expect(mockFetch).toHaveBeenCalledTimes(3)
})

Testing with MSW (Mock Service Worker)

For integration-level tests that go through actual fetch:

import { setupServer } from "msw/node"
import { http, HttpResponse } from "msw"

const server = setupServer(
  http.get("/api/users/:id", ({ params }) => {
    return HttpResponse.json({ id: params.id, name: "Alice" })
  }),
)

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

it("fetches user from API", async () => {
  const user = createQuery(queryClient, {
    queryKey: () => ["user", "1"],
    queryFn: () => fetch("/api/users/1").then(r => r.json()),
  })

  await vi.waitFor(() => expect(user.isSuccess()).toBe(true))
  expect(user.data()).toEqual({ id: "1", name: "Alice" })
})