Version: 1.0 · Last Updated: 2026-02-08
Testing strategy, tooling, and guidelines for the ObjectStack Mobile client.
- Testing Philosophy
- Test Pyramid
- Tooling
- Unit Testing
- Integration Testing
- E2E Testing
- Test Organization
- Mocking Strategy
- Coverage Targets
- Running Tests
- CI Integration
- Test behavior, not implementation — Test what the user sees and does, not internal state
- Confidence over coverage — Prioritize tests that catch real bugs over arbitrary coverage targets
- Fast feedback — Unit tests should run in seconds, integration tests in minutes
- Deterministic — Tests must be reproducible and not depend on network or timing
| Priority | What | How |
|---|---|---|
| Critical | Data hooks (query, mutation, sync) | Integration tests with mocked SDK |
| Critical | Query builder (filter AST) | Unit tests |
| Critical | Offline storage & sync queue | Unit tests |
| High | View renderers (list, form, detail) | Component tests |
| High | Auth flow (sign-in, guard, sign-out) | Integration tests |
| Medium | UI components | Snapshot tests |
| Medium | Error handling | Unit tests |
| Low | Navigation structure | E2E tests |
╱╲
╱ ╲
╱ E2E╲ ~10% — Critical user journeys
╱──────╲
╱ ╲
╱Integration╲ ~30% — Hook + renderer + SDK integration
╱──────────────╲
╱ ╲
╱ Unit Tests ╲ ~60% — Pure functions, utilities, builders
╱────────────────────╲
| Tool | Version | Purpose |
|---|---|---|
| Jest | ^29.7.0 |
Test runner |
| jest-expo | ^54.0.2 |
Expo preset for Jest |
| @testing-library/react-native | ^12.9.0 |
Component testing utilities |
| @testing-library/jest-native | ^5.4.3 |
Custom Jest matchers for RN |
| TypeScript | ~5.9.2 |
Type checking in tests |
// jest.config.js
module.exports = {
preset: "jest-expo",
transformIgnorePatterns: [
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg)"
],
setupFilesAfterSetup: ["@testing-library/jest-native/extend-expect"],
moduleNameMapper: {
"^~/(.*)$": "<rootDir>/$1"
}
};| Tool | Purpose | Phase |
|---|---|---|
MSW (@objectstack/plugin-msw) |
API mocking for integration tests | 5A |
| Maestro or Detox | E2E testing on real devices/simulators | 5A |
| Module | Path | Test Focus |
|---|---|---|
| Query Builder | lib/query-builder.ts |
Filter creation, serialization, operator validation |
| Offline Storage | lib/offline-storage.ts |
CRUD operations, schema migration |
| Sync Queue | lib/sync-queue.ts |
Enqueue, status transitions, cleanup |
| Error Handling | lib/error-handling.ts |
Error parsing, message mapping |
| Metadata Cache | lib/metadata-cache.ts |
Cache get/set, TTL, ETag |
| Utils | lib/utils.ts |
Utility functions |
// __tests__/lib/query-builder.test.ts
import {
createSimpleFilter,
createCompoundFilter,
serializeFilter,
serializeFilterTree,
operatorsForFieldType,
isSimpleFilter,
isCompoundFilter,
} from "~/lib/query-builder";
describe("Query Builder", () => {
describe("createSimpleFilter", () => {
it("creates a filter with default values", () => {
const filter = createSimpleFilter();
expect(filter.field).toBe("");
expect(filter.operator).toBe("eq");
expect(filter.value).toBe("");
expect(filter.id).toBeTruthy();
});
it("creates a filter with custom values", () => {
const filter = createSimpleFilter("status", "neq");
expect(filter.field).toBe("status");
expect(filter.operator).toBe("neq");
});
});
describe("serializeFilter", () => {
it("serializes a simple filter", () => {
const filter = createSimpleFilter("status", "eq");
filter.value = "active";
expect(serializeFilter(filter)).toEqual(["status", "eq", "active"]);
});
it("serializes a null-check operator without value", () => {
const filter = createSimpleFilter("email", "is_null");
expect(serializeFilter(filter)).toEqual(["email", "is_null"]);
});
it("serializes a between operator with two values", () => {
const filter = createSimpleFilter("amount", "between");
filter.value = 100;
filter.value2 = 500;
expect(serializeFilter(filter)).toEqual(["amount", "between", 100, 500]);
});
it("returns null for empty field", () => {
const filter = createSimpleFilter("", "eq");
expect(serializeFilter(filter)).toBeNull();
});
});
describe("operatorsForFieldType", () => {
it("returns text operators for text fields", () => {
const ops = operatorsForFieldType("text");
expect(ops).toContain("contains");
expect(ops).toContain("starts_with");
});
it("returns numeric operators for number fields", () => {
const ops = operatorsForFieldType("number");
expect(ops).toContain("gt");
expect(ops).toContain("between");
});
it("returns boolean operators for boolean fields", () => {
const ops = operatorsForFieldType("boolean");
expect(ops).toContain("eq");
expect(ops).not.toContain("contains");
});
});
});// __tests__/lib/error-handling.test.ts
import { parseError, getUserErrorMessage } from "~/lib/error-handling";
describe("Error Handling", () => {
it("parses network errors", () => {
const error = new Error("Network request failed");
const parsed = parseError(error);
expect(parsed.code).toBe("NETWORK_ERROR");
});
it("parses structured API errors", () => {
const error = new Error(JSON.stringify({ code: "NOT_FOUND" }));
const parsed = parseError(error);
expect(parsed.code).toBe("NOT_FOUND");
});
it("returns INTERNAL_ERROR for unknown errors", () => {
const parsed = parseError("something");
expect(parsed.code).toBe("INTERNAL_ERROR");
});
it("returns user-friendly messages", () => {
const message = getUserErrorMessage(new Error("Network request failed"));
expect(message).toContain("internet connection");
});
});Test hooks with mocked SDK client using React Testing Library's renderHook:
// __tests__/hooks/useAppDiscovery.test.ts
import { renderHook, waitFor } from "@testing-library/react-native";
import { useAppDiscovery } from "~/hooks/useAppDiscovery";
// Mock SDK client
jest.mock("@objectstack/client-react", () => ({
useClient: () => ({
packages: {
list: jest.fn().mockResolvedValue({
packages: [
{ id: "app1", name: "crm", label: "CRM", enabled: true },
{ id: "app2", name: "hrm", label: "HRM", enabled: true },
],
}),
},
}),
}));
describe("useAppDiscovery", () => {
it("fetches and transforms apps", async () => {
const { result } = renderHook(() => useAppDiscovery());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.apps).toHaveLength(2);
expect(result.current.apps[0].name).toBe("crm");
});
});Test renderers with mocked data:
// __tests__/components/renderers/ListViewRenderer.test.tsx
import { render, screen } from "@testing-library/react-native";
import { ListViewRenderer } from "~/components/renderers/ListViewRenderer";
describe("ListViewRenderer", () => {
const mockRecords = [
{ id: "1", name: "Record 1", status: "active" },
{ id: "2", name: "Record 2", status: "inactive" },
];
const mockFields = [
{ name: "name", type: "text", label: "Name" },
{ name: "status", type: "select", label: "Status" },
];
it("renders records", () => {
render(
<ListViewRenderer
records={mockRecords}
fields={mockFields}
view={{ columns: ["name", "status"] }}
/>
);
expect(screen.getByText("Record 1")).toBeTruthy();
expect(screen.getByText("Record 2")).toBeTruthy();
});
it("shows empty state when no records", () => {
render(
<ListViewRenderer records={[]} fields={mockFields} view={{}} />
);
expect(screen.getByText(/no records/i)).toBeTruthy();
});
});Using Maestro for E2E tests:
# e2e/sign-in.yaml
appId: com.objectstack.mobile
---
- launchApp
- assertVisible: "Sign In"
- tapOn: "Email"
- inputText: "test@example.com"
- tapOn: "Password"
- inputText: "password123"
- tapOn: "Sign In"
- assertVisible: "Home"| Flow | Steps |
|---|---|
| Sign in | Launch → Enter credentials → Verify home screen |
| Browse apps | Sign in → Tap Apps tab → Select app → View objects |
| View records | Navigate to object → Verify list → Search → Filter |
| Create record | Tap Create → Fill form → Save → Verify in list |
| Edit record | Tap record → Edit → Modify fields → Save → Verify |
| Delete record | Swipe record → Delete → Confirm → Verify removed |
| Offline mode | Disable network → Create record → Re-enable → Verify sync |
├── __tests__/
│ ├── lib/
│ │ ├── query-builder.test.ts
│ │ ├── offline-storage.test.ts
│ │ ├── sync-queue.test.ts
│ │ ├── error-handling.test.ts
│ │ └── metadata-cache.test.ts
│ ├── hooks/
│ │ ├── useAppDiscovery.test.ts
│ │ ├── useBatchOperations.test.ts
│ │ ├── useNetworkStatus.test.ts
│ │ └── useViewStorage.test.ts
│ ├── components/
│ │ ├── renderers/
│ │ │ ├── ListViewRenderer.test.tsx
│ │ │ ├── FormViewRenderer.test.tsx
│ │ │ ├── DetailViewRenderer.test.tsx
│ │ │ └── ViewRenderer.test.tsx
│ │ ├── common/
│ │ │ ├── EmptyState.test.tsx
│ │ │ ├── ErrorBoundary.test.tsx
│ │ │ └── SearchBar.test.tsx
│ │ └── ui/
│ │ ├── Button.test.tsx
│ │ ├── Input.test.tsx
│ │ └── Card.test.tsx
│ └── e2e/ # Phase 5A
│ ├── sign-in.yaml
│ ├── crud-flow.yaml
│ └── offline-sync.yaml
| Convention | Example |
|---|---|
| Test files | *.test.ts / *.test.tsx |
| Test suites | describe("ModuleName", ...) |
| Test cases | it("does something specific", ...) |
| Mock files | __mocks__/module-name.ts |
// __mocks__/@objectstack/client-react.ts
export const useClient = jest.fn(() => ({
meta: { getObject: jest.fn(), getTypes: jest.fn() },
data: { query: jest.fn(), find: jest.fn(), get: jest.fn() },
packages: { list: jest.fn() },
storage: { upload: jest.fn() },
analytics: { query: jest.fn(), meta: jest.fn() },
}));
export const useQuery = jest.fn();
export const useMutation = jest.fn();
export const useObject = jest.fn();
export const useView = jest.fn();
export const useFields = jest.fn();// jest.setup.ts
jest.mock("expo-sqlite", () => ({
openDatabaseSync: jest.fn(() => ({
execSync: jest.fn(),
runSync: jest.fn(),
getFirstSync: jest.fn(),
getAllSync: jest.fn(),
withTransactionSync: jest.fn((cb) => cb()),
})),
}));
jest.mock("expo-secure-store", () => ({
getItemAsync: jest.fn(),
setItemAsync: jest.fn(),
deleteItemAsync: jest.fn(),
}));
jest.mock("react-native-mmkv", () => ({
createMMKV: jest.fn(() => ({
getString: jest.fn(),
set: jest.fn(),
remove: jest.fn(),
clearAll: jest.fn(),
})),
}));| Area | Target | Priority |
|---|---|---|
lib/ (utilities) |
≥90% | Critical |
hooks/ (custom hooks) |
≥80% | High |
components/renderers/ |
≥70% | High |
components/common/ |
≥80% | Medium |
components/ui/ |
≥60% | Medium |
stores/ |
≥90% | High |
// jest.config.js (planned)
{
"coverageThreshold": {
"global": {
"branches": 70,
"functions": 80,
"lines": 80,
"statements": 80
}
}
}# Run all tests
pnpm test
# Run with coverage
pnpm test -- --coverage
# Run specific test file
pnpm test -- --testPathPattern=query-builder
# Run in watch mode
pnpm test -- --watch
# Update snapshots
pnpm test -- --updateSnapshot// package.json
{
"scripts": {
"test": "jest --passWithNoTests"
}
}Note:
--passWithNoTestsis used because the test suite is being built incrementally. This flag will be removed once initial test files are in place.
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "pnpm"
- run: pnpm install
- run: pnpm run lint
- run: pnpm test -- --coverage --ci
- uses: codecov/codecov-action@v4# Recommended pre-commit hook
npx tsc --noEmit && npx eslint . --ext .ts,.tsx && pnpm testThis document defines the testing strategy for ObjectStack Mobile. See CONTRIBUTING.md for development workflow and DEPLOYMENT.md for CI/CD pipeline details.