| name | testing |
| description | Guides writing tests in carwash-saas. Use this when asked to add, write, or fix tests. Covers which test framework to use per package, Supabase mocking, TanStack Query test setup, React Testing Library patterns, and Expo/React Native testing. |
Testing Patterns
Framework selection
No test framework is configured yet. When adding tests, use the correct framework per package:
| Package / App | Framework | Runner |
|---|
packages/lib | Vitest | pnpm vitest run |
packages/types | Vitest | pnpm vitest run |
packages/ui | Vitest + React Testing Library | pnpm vitest run |
apps/dashboard | Vitest + React Testing Library | pnpm --filter dashboard vitest run |
apps/mobile | Jest + React Native Testing Library | pnpm --filter mobile jest |
Run a single file: pnpm vitest run path/to/file.test.ts
packages/lib — Pure function tests
@carwash/lib has zero side effects and zero I/O. Tests are straightforward input/output assertions. No mocking required.
Setup (first time)
"scripts": { "test": "vitest run" },
"devDependencies": { "vitest": "^2.0.0" }
import { defineConfig } from "vitest/config";
export default defineConfig({ test: { environment: "node" } });
Example: loyalty.test.ts
import { describe, it, expect } from "vitest";
import {
getLoyaltyStatus,
applyWash,
applyRedemption,
canRedeem,
DEFAULT_LOYALTY_CONFIG,
} from "./loyalty";
describe("getLoyaltyStatus", () => {
it("returns correct progress for partial count", () => {
const status = getLoyaltyStatus(5, DEFAULT_LOYALTY_CONFIG);
expect(status.washCount).toBe(5);
expect(status.canRedeem).toBe(false);
});
it("marks canRedeem true when wash_count reaches washes_required", () => {
const status = getLoyaltyStatus(8, DEFAULT_LOYALTY_CONFIG);
expect(status.canRedeem).toBe(true);
});
});
describe("applyRedemption", () => {
it("resets wash_count to 0 and increments total_redeemed", () => {
const result = applyRedemption({ washCount: 8, totalRedeemed: 2 });
expect(result.washCount).toBe(0);
expect(result.totalRedeemed).toBe(3);
});
it("throws if wash_count is below required", () => {
expect(() => applyRedemption({ washCount: 5, totalRedeemed: 0 })).toThrow();
});
});
packages/types — Zod schema tests
import { describe, it, expect } from "vitest";
import { CustomerUpsertSchema, LogWashSchema } from "./models";
describe("CustomerUpsertSchema", () => {
it("accepts a valid Indonesian phone number", () => {
const result = CustomerUpsertSchema.safeParse({
name: "Budi",
phone: "08123456789",
});
expect(result.success).toBe(true);
});
it("rejects a non-Indonesian phone format", () => {
const result = CustomerUpsertSchema.safeParse({
name: "Budi",
phone: "555-1234",
});
expect(result.success).toBe(false);
});
});
describe("LogWashSchema", () => {
it("rejects an invalid wash_type", () => {
const result = LogWashSchema.safeParse({
customer_id: crypto.randomUUID(),
staff_id: crypto.randomUUID(),
branch_id: crypto.randomUUID(),
wash_type: "ultra",
amount_paid: 50000,
is_free_redemption: false,
});
expect(result.success).toBe(false);
});
});
apps/dashboard — React Testing Library
Setup (first time)
"vitest": "^2.0.0",
"@vitejs/plugin-react": "^4.0.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.0.0",
"jsdom": "^25.0.0"
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: { environment: "jsdom", setupFiles: ["./vitest.setup.ts"] },
});
import "@testing-library/jest-dom";
Mocking Supabase
Never use a real Supabase connection in tests. Mock the client module:
import { vi } from "vitest";
export const createClient = vi.fn(() => ({
from: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
order: vi.fn().mockReturnThis(),
is: vi.fn().mockReturnThis(),
gte: vi.fn().mockResolvedValue({ data: [], error: null }),
rpc: vi.fn().mockResolvedValue({ data: null, error: null }),
}));
vi.mock("@/lib/supabase/client", () => import("./__mocks__/supabase-client"));
Wrapping TanStack Query
All client components that use useQuery / useMutation must be wrapped in a QueryClientProvider:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render } from '@testing-library/react';
function renderWithQuery(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>
);
}
Example: component test
import { screen, waitFor } from '@testing-library/react';
import { vi } from 'vitest';
import StaffPage from '@/app/(dashboard)/staff/page';
vi.mock('@/lib/supabase/client', () => ({
createClient: () => ({
from: () => ({
select: () => ({
in: () => ({
eq: () => ({
order: () => Promise.resolve({
data: [{ id: '1', name: 'Budi', role: 'cashier', qr_token: 'abc', is_active: true }],
error: null,
}),
}),
}),
}),
}),
}),
}));
it('renders staff name', async () => {
renderWithQuery(<StaffPage />);
await waitFor(() => expect(screen.getByText('Budi')).toBeInTheDocument());
});
apps/mobile — React Native Testing Library
Setup (first time)
pnpm --filter mobile add -D jest jest-expo @testing-library/react-native
"jest": { "preset": "jest-expo" }
Mocking Expo Router
import { jest } from "@jest/globals";
jest.mock("expo-router", () => ({
useRouter: () => ({ push: jest.fn(), replace: jest.fn() }),
useLocalSearchParams: () => ({}),
Link: ({ children }: { children: React.ReactNode }) => children,
}));
Mocking Supabase singleton
jest.mock("@/lib/supabase", () => ({
supabase: {
from: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
eq: jest.fn().mockResolvedValue({ data: [], error: null }),
auth: {
getUser: jest
.fn()
.mockResolvedValue({ data: { user: { id: "u1" } }, error: null }),
onAuthStateChange: jest.fn(() => ({
data: { subscription: { unsubscribe: jest.fn() } },
})),
},
},
}));
General checklist
Common anti-patterns to reject
| Anti-pattern | Correct approach |
|---|
| Real Supabase calls in tests | Mock createClient or supabase singleton |
| Testing implementation details (spy on internal functions) | Test observable output and rendered UI |
Shared mutable state between it blocks | Reset mocks in beforeEach; use vi.clearAllMocks() |
setTimeout(done, 1000) to wait for async | Use waitFor(() => expect(...)) |
| Using the wrong framework for a package | See the framework selection table above |
retry: true on QueryClient in tests | Set retry: false to avoid slow test failures |