Testing & CI

Unit, integration, and end-to-end tests for HotCRM customizations — plus CI patterns and a sample GitHub Actions workflow.

Testing & CI

How to test your HotCRM customisations confidently before shipping.

What to test

LayerTest typeTool
Object metadataSchema validation@objectstack/spec schemas + vitest
Hook logicUnit testvitest
FlowsIntegration test@objectstack/runtime/testing
AI skillsSnapshot + integration@objectstack/runtime/ai/testing
Validation rulesUnit testvitest
Sharing rulesIntegration test (multi-user)@objectstack/runtime/testing
UI pages / dashboardsSchema validation + visual@objectstack/spec + Playwright
End-to-end user flowsE2EPlaywright

1. Schema validation tests

Every metadata file should validate against its spec schema. Add a single test that walks all files:

// test/metadata-references.test.ts
import { describe, it, expect } from 'vitest';
import { ObjectSchema } from '@objectstack/spec/data';
import { PageSchema, ViewSchema, DashboardSchema } from '@objectstack/spec/ui';
import { StateMachineSchema } from '@objectstack/spec/automation';
import opportunityObj from '../src/objects/opportunity.object';
import opportunityPage from '../src/pages/opportunity_detail.page';

describe('metadata schemas', () => {
  it('opportunity object', () => {
    expect(() => ObjectSchema.create(opportunityObj)).not.toThrow();
  });

  it('opportunity page', () => {
    expect(() => PageSchema.parse(opportunityPage)).not.toThrow();
  });
});

These run in milliseconds and catch most metadata bugs at CI time.

2. Hook unit tests

The example below tests a hypothetical opportunity_territory hook you might add — swap in your own. (For real, runnable examples against the shipped hooks, see test/hooks-runtime.test.ts.)

// test/opportunity_territory.hook.test.ts
import { it, expect, vi } from 'vitest';
import territoryHook from '../src/objects/opportunity_territory.hook';

it('assigns territory from account region', async () => {
  const broker = {
    findOne: vi.fn().mockResolvedValue({ id: 'ter_amer', region: 'amer' }),
  };
  const record = { account: 'acc_123' };
  // mock the account lookup
  broker.findOne
    .mockResolvedValueOnce({ id: 'acc_123', region: 'amer' })
    .mockResolvedValueOnce({ id: 'ter_amer', region: 'amer' });

  const result = await territoryHook.handler({ record, broker, context: {} });

  expect(result.territory).toBe('ter_amer');
});

Tip: hook handlers should be pure functions of (record, context, broker) — easy to unit test.

3. Flow / automation tests

Use the runtime's test harness to exercise multi-step flows:

// test/contract_renewal.flow.test.ts
import { createTestRuntime } from '@objectstack/runtime/testing';
import { it, expect } from 'vitest';

it('creates a renewal task when contract enters renewal window', async () => {
  const rt = await createTestRuntime();
  await rt.seed.contract({
    id: 'ctr_abc',
    end_date: '2026-05-01',
    renewal_notice_days: 60,
    owner: 'usr_alex',
    status: 'activated',
  });

  await rt.advanceTime('2026-03-02T08:00:00Z');  // crosses renewal window
  await rt.runScheduledJobs();

  const tasks = await rt.query.task({ filters: [['related_to', '=', 'ctr_abc']] });
  expect(tasks).toHaveLength(1);
  expect(tasks[0].subject).toContain('Renewal');
});

The createTestRuntime spins up an in-memory tenant with your packages loaded — fast, isolated, deterministic.

4. AI skill tests

Skills are deterministic when you mock the LLM. The example tests a hypothetical renewal-pitch skill (the shipped skills live in src/skills/):

// test/renewal-pitch.skill.test.ts
import { runSkill } from '@objectstack/runtime/ai/testing';
import renewalPitch from '../src/skills/renewal-pitch.skill';

it('produces a pitch with citations', async () => {
  const result = await runSkill(renewalPitch, {
    input: { contract_id: 'ctr_123' },
    as_user: 'usr_alex',
    mock_llm: 'echo',           // deterministic mode
  });
  expect(result.subject).toMatch(/renewal/i);
  expect(result.citations.length).toBeGreaterThan(0);
});

it('snapshot of prompt for renewal pitch', async () => {
  const { rendered_prompt } = await runSkill(renewalPitch, {
    input: { contract_id: 'ctr_123' },
    as_user: 'usr_alex',
    return_prompt_only: true,
  });
  expect(rendered_prompt).toMatchSnapshot();
});

Snapshot the rendered prompt — if you tune the template, you see exactly what changed.

For end-to-end integration tests with a real LLM, use a dedicated test tenant with a small free-tier model to keep cost down.

5. Sharing rule tests

import { createTestRuntime } from '@objectstack/runtime/testing';

it('AMER reps can see AMER accounts via sharing rule', async () => {
  const rt = await createTestRuntime();
  await rt.seed.user({ id: 'usr_amer_rep', role: 'ae_amer', profile: 'sales_user' });
  await rt.seed.account({ id: 'acc_us', region: 'amer', owner: 'usr_someone_else' });

  const accounts = await rt.as('usr_amer_rep').query.account({});
  expect(accounts.find(a => a.id === 'acc_us')).toBeDefined();
});

it('AMER reps cannot see EMEA accounts', async () => {
  const rt = await createTestRuntime();
  await rt.seed.user({ id: 'usr_amer_rep', role: 'ae_amer', profile: 'sales_user' });
  await rt.seed.account({ id: 'acc_de', region: 'emea', owner: 'usr_someone_else' });

  const accounts = await rt.as('usr_amer_rep').query.account({});
  expect(accounts.find(a => a.id === 'acc_de')).toBeUndefined();
});

6. End-to-end with Playwright

// e2e/lead-conversion.spec.ts — an example spec to add alongside the shipped
// e2e/smoke.spec.ts and e2e/opportunity-lifecycle.spec.ts
import { test, expect } from '@playwright/test';

test('rep can convert a qualified lead to an opportunity', async ({ page }) => {
  await page.goto('/');
  await page.fill('[name=email]', 'rep@test.com');
  await page.fill('[name=password]', process.env.TEST_PASSWORD!);
  await page.click('button[type=submit]');

  await page.click('text=Leads');
  await page.click('text=New Lead');
  await page.fill('[name=name]', 'Jane Doe');
  await page.fill('[name=company]', 'Acme Corp');
  await page.fill('[name=email]', 'jane@acme.test');
  await page.click('text=Save');

  await page.click('text=Mark Qualified');
  await page.click('text=Convert');
  await expect(page).toHaveURL(/\/opportunities\//);
  await expect(page.locator('h1')).toContainText('Acme Corp');
});

Run with pnpm test:e2e.

CI: GitHub Actions example

# .github/workflows/ci.yaml
name: ci

on: [push, pull_request]

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: pnpm/action-setup@v3
      - uses: actions/setup-node@v6
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm typecheck       # strict TypeScript
      - run: pnpm lint
      - run: pnpm test            # unit + integration
      - run: pnpm validate        # metadata schema validation

  e2e:
    runs-on: ubuntu-latest
    needs: validate
    steps:
      - uses: actions/checkout@v7
      - uses: pnpm/action-setup@v3
      - run: pnpm install --frozen-lockfile
      - run: pnpm playwright install --with-deps
      - run: pnpm dev &           # boot the runtime
      - run: pnpm wait-on http://localhost:4001
      - run: pnpm test:e2e

  deploy-sandbox:
    runs-on: ubuntu-latest
    needs: e2e
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - run: pnpm deploy --tenant=sandbox-uat --token=${{ secrets.HOTCRM_DEPLOY_TOKEN }}

Test data

Don't hand-write seed data — use the runtime's seed builders:

await rt.seed.account({ region: 'amer' });           // sensible defaults
await rt.seed.opportunity({ stage: 'negotiation' }); // links to a fresh account

For larger tests, use factories:

const accounts = await rt.seed.accountFactory(50, { tier: 'platinum' });

Coverage targets

  • Hooks — 90%+ branch coverage.
  • Flows — every path through every decision.
  • Validation rules — both pass and fail cases.
  • Sharing rules — at least one positive and one negative case per rule.
  • AI skills — snapshot tests for prompts; integration tests sampling outputs weekly.

Tips

  • Schema validation in CI catches 80% of metadata bugs.
  • Snapshot AI prompts — tiny prompt changes shift outputs noticeably.
  • Test sharing both ways — what users CAN see + what they CANNOT.
  • ✅ Use deterministic LLM mocks in CI; live LLM tests run nightly on a separate workflow.
  • ✅ Keep E2E tests few but critical — cover login, lead conversion, deal close, case resolution.
  • ✅ Run the full suite before every promotion to UAT — never deploy a red build.

On this page