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
- Test behavior, not implementation - Focus on what users see
- Use data-testid sparingly - Prefer accessible queries
- Mock external dependencies - NUI, fetch, timers
- Test error states - Verify error handling
- Keep tests focused - One assertion per test when possible
- Use setup files - Share common mocks and setup