Skip to main content

Testing

True Life uses Vitest for UI testing with React Testing Library.

Running Tests

# Run all tests once
pnpm test

# Watch mode
pnpm test:watch

# With coverage
pnpm test:coverage

# Test code splitting
pnpm test:ui:chunks

Test Structure

Tests are located in ui/src/test/:

ui/src/test/
├── setup.ts # Test setup
├── mocks/ # Mock data
│ └── nui.ts # NUI event mocks
├── components/ # Component tests
│ └── Panel.test.tsx
└── code-splitting.manifest.test.ts

Writing Component Tests

Basic Component Test

// ui/src/test/components/StatusPanel.test.tsx
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { Provider } from "react-redux";
import { configureStore } from "@reduxjs/toolkit";
import StatusPanel from "@modules/needs/huds/status/Panel";
import reducer from "@modules/needs/huds/status/state/slice";

function createTestStore(initialState = {}) {
return configureStore({
reducer: { status: reducer },
preloadedState: { status: initialState },
});
}

describe("StatusPanel", () => {
it("renders health value", () => {
const store = createTestStore({ health: 75, hunger: 100, thirst: 100 });

render(
<Provider store={store}>
<StatusPanel />
</Provider>
);

expect(screen.getByText("75%")).toBeInTheDocument();
});

it("shows warning when health is low", () => {
const store = createTestStore({ health: 20, hunger: 100, thirst: 100 });

render(
<Provider store={store}>
<StatusPanel />
</Provider>
);

expect(screen.getByTestId("health-warning")).toBeInTheDocument();
});
});

Testing with NUI Events

import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { Provider } from "react-redux";
import { store } from "@ui/store";
import StatusPanel from "@modules/needs/huds/status/Panel";
import { emitNuiEvent } from "../mocks/nui";

describe("StatusPanel with NUI events", () => {
it("updates when receiving NUI event", async () => {
render(
<Provider store={store}>
<StatusPanel />
</Provider>
);

// Simulate NUI event from client
emitNuiEvent("needs:update", {
health: 50,
hunger: 80,
thirst: 60,
armor: 0,
});

await waitFor(() => {
expect(screen.getByText("50%")).toBeInTheDocument();
});
});
});

NUI Event Mock

// ui/src/test/mocks/nui.ts
import { vi } from "vitest";

type NuiEventHandler = (data: unknown) => void;

const handlers = new Map<string, Set<NuiEventHandler>>();

export function emitNuiEvent(event: string, data: unknown): void {
const eventHandlers = handlers.get(event);
if (eventHandlers) {
for (const handler of eventHandlers) {
handler(data);
}
}
}

// Mock the useNuiEvent hook to use our test handlers
vi.mock("@ui/hooks", async () => {
const actual = await vi.importActual<typeof import("@ui/hooks")>("@ui/hooks");
return {
...actual,
useNuiEvent: <T,>(event: string, handler: (data: T) => void) => {
if (!handlers.has(event)) {
handlers.set(event, new Set());
}
handlers.get(event)!.add(handler as NuiEventHandler);

return () => {
handlers.get(event)?.delete(handler as NuiEventHandler);
};
},
};
});

Testing Redux Slices

// ui/src/test/slices/statusSlice.test.ts
import { describe, it, expect } from "vitest";
import reducer, {
updateStatus,
setHealth,
initialState,
} from "@modules/needs/huds/status/state/slice";

describe("statusSlice", () => {
it("should return initial state", () => {
expect(reducer(undefined, { type: "unknown" })).toEqual(initialState);
});

it("should handle setHealth", () => {
const state = reducer(initialState, setHealth(75));
expect(state.health).toBe(75);
});

it("should handle updateStatus", () => {
const state = reducer(initialState, updateStatus({
health: 50,
hunger: 80,
thirst: 60,
armor: 25,
}));

expect(state).toEqual({
health: 50,
hunger: 80,
thirst: 60,
armor: 25,
});
});
});

Testing User Interactions

import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Provider } from "react-redux";
import { store } from "@ui/store";
import ATMPage from "@modules/atm/pages/atm-interface/Page";

describe("ATMPage", () => {
it("handles withdrawal", async () => {
const user = userEvent.setup();
const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: () => ({}) });
global.fetch = mockFetch;

render(
<Provider store={store}>
<ATMPage />
</Provider>
);

// Enter amount
await user.type(screen.getByLabelText("Amount"), "100");

// Click withdraw
await user.click(screen.getByRole("button", { name: /withdraw/i }));

// Verify API call
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("withdraw"),
expect.objectContaining({
method: "POST",
body: expect.stringContaining("100"),
})
);
});
});

Code Splitting Tests

Verify proper chunk generation:

// ui/src/test/code-splitting.manifest.test.ts
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { join } from "path";

describe("Code Splitting", () => {
it("generates separate chunks for features", () => {
const manifestPath = join(__dirname, "../../dist/.vite/manifest.json");
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));

// Verify feature chunks exist
const featureChunks = Object.keys(manifest).filter(
(key) => key.includes("feature")
);

expect(featureChunks.length).toBeGreaterThan(0);
});

it("does not include feature code in main bundle", () => {
const manifestPath = join(__dirname, "../../dist/.vite/manifest.json");
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));

const mainEntry = manifest["index.html"];
expect(mainEntry.file).not.toContain("needs");
expect(mainEntry.file).not.toContain("atm");
});
});

Test Configuration

// ui/vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig({
plugins: [react(), tsconfigPaths()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.test.{ts,tsx}"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
exclude: ["node_modules/", "src/test/"],
},
},
});

Test Setup

// ui/src/test/setup.ts
import "@testing-library/jest-dom";
import { vi } from "vitest";

// Mock window.matchMedia
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});

// Mock NUI fetch
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({}),
});

Best Practices

  1. Test behavior, not implementation - Focus on what users see
  2. Use data-testid sparingly - Prefer accessible queries
  3. Mock external dependencies - NUI, fetch, timers
  4. Test error states - Verify error handling
  5. Keep tests focused - One assertion per test when possible
  6. Use setup files - Share common mocks and setup