Skip to main content

Testing apps

Automated tests catch formula bugs, action logic errors, and field definition mistakes before they reach production. This guide covers how to set up and run Jest tests for Comind.work app logic.

Running tests

Run all tests from the project root:

npm test

Run tests for a single app:

npm test -- --testPathPatterns=app-org-task

Tests use Jest with TypeScript support via the project's shared configuration. No extra setup is needed beyond the standard project dependencies.

What you can test

ComponentTestable?How
Calc field functionsYesImport the function, pass a mock entity, assert the result
Action logic functionsYesImport the function, set up entity/entityOld globals, assert mutations
Precondition functionsYesImport the function, set up entity global, assert the boolean return
Field definitionsYesImport the fields array, assert structure (names, types, options)
View logic (getInvisibleFields, etc.)LimitedFunctions depend on ComindView global which is not available in tests
Layouts (TSX)NoLayouts compile to XML strings at build time, not testable in isolation

Focus your testing effort on calc fields and action logic - these contain the business rules that matter most.

Test file location

Place test files next to the code they test, using the .test.ts extension:

app-order/
├── fields/
│ └── calc-fields/
│ ├── calc-total.ts
│ └── calc-total.test.ts <-- test file
└── actions/
└── logic/
├── index.ts
└── index.test.ts <-- test file

This co-location makes it easy to find tests and keeps imports short.

Example: testing a calc field

Calc field functions live in fields/calc-fields/ and are exported as named functions. They read from the entity global and return a computed value. To test one, declare entity as a global, assign it a mock object, and call the function.

Here is a calc field that computes a line total from quantity and unit price:

// fields/calc-fields/calc-total.ts
export function c_total() {
if (entity.c_quantity == null || entity.c_unit_price == null) {
return 0;
}
return entity.c_quantity * entity.c_unit_price;
}

The field definition in fields/index.ts references this function by name:

{
calcType: 'calcfield',
caption: 'Total',
dependsOn: ['c_quantity', 'c_unit_price'],
name: 'c_total',
options: { formatting_pre: '$', decimal_places: 2 },
type: 'number',
},

And the test:

// fields/calc-fields/calc-total.test.ts
import { c_total } from './calc-total';

declare let entity: any;

beforeEach(() => {
(globalThis as any).entity = {};
});

afterEach(() => {
delete (globalThis as any).entity;
});

describe('c_total', () => {
it('calculates total as quantity times unit price', () => {
entity.c_quantity = 5;
entity.c_unit_price = 12.5;
expect(c_total()).toBe(62.5);
});

it('returns 0 when quantity is null', () => {
entity.c_quantity = null;
entity.c_unit_price = 10;
expect(c_total()).toBe(0);
});

it('returns 0 when unit price is null', () => {
entity.c_quantity = 3;
entity.c_unit_price = null;
expect(c_total()).toBe(0);
});

it('returns 0 when both fields are missing', () => {
expect(c_total()).toBe(0);
});

it('handles zero quantity', () => {
entity.c_quantity = 0;
entity.c_unit_price = 25;
expect(c_total()).toBe(0);
});

it('handles fractional quantities', () => {
entity.c_quantity = 2.5;
entity.c_unit_price = 10;
expect(c_total()).toBe(25);
});
});

The beforeEach / afterEach pattern ensures each test starts with a clean entity global. This mirrors how the platform injects entity at runtime.

Example: testing action logic

Action logic functions run server-side and mutate the entity global directly. They also have access to entityOld (the record's previous state) and currentUser. To test them, set up these globals before calling the function.

Here is an action logic module with an approve function:

// actions/logic/index.ts
import { entity } from '#typings';
import type { ActionLogic } from '@comind/api';

export default {
approve,
reject,
} satisfies ActionLogic<typeof entity>;

export function approve() {
entity.c_approved_by = currentUser.id;
entity.c_approved_date = new Date().toISOString();
}

export function reject() {
entity.c_rejection_reason =
entity.c_rejection_reason || 'No reason provided';
}

And the test:

// actions/logic/index.test.ts
import { approve, reject } from './index';

declare let entity: any;
declare let entityOld: any;
declare let currentUser: any;

beforeEach(() => {
(globalThis as any).entity = {};
(globalThis as any).entityOld = {};
(globalThis as any).currentUser = { id: 'user-42' };
});

afterEach(() => {
delete (globalThis as any).entity;
delete (globalThis as any).entityOld;
delete (globalThis as any).currentUser;
});

describe('approve', () => {
it('sets approved_by to the current user', () => {
approve();
expect(entity.c_approved_by).toBe('user-42');
});

it('sets approved_date to an ISO string', () => {
approve();
expect(entity.c_approved_date).toMatch(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/
);
});
});

describe('reject', () => {
it('sets default rejection reason when none provided', () => {
reject();
expect(entity.c_rejection_reason).toBe('No reason provided');
});

it('preserves existing rejection reason', () => {
entity.c_rejection_reason = 'Incomplete documentation';
reject();
expect(entity.c_rejection_reason).toBe('Incomplete documentation');
});
});

Note that entity and entityOld are globals - you set them up before calling the function, then assert the mutations afterward. This is the same execution model the platform uses at runtime.

Example: testing preconditions

Precondition functions return a boolean that determines whether an action is available. Test them by setting entity (and optionally currentUser) to different states and checking the return value.

Here is a precondition module:

// actions/logic/preconditions.ts
import { entity } from '#typings';
import type { PreconditionLogic } from '@comind/api';

export default {
approve: canApprove,
submit: canSubmit,
} satisfies PreconditionLogic<typeof entity>;

export function canApprove() {
return entity.state === 'pending' && currentUser.isInGroup('Reviewers');
}

export function canSubmit() {
return entity.state === 'draft';
}

And the test:

// actions/logic/preconditions.test.ts
import { canApprove, canSubmit } from './preconditions';

declare let entity: any;
declare let currentUser: any;

beforeEach(() => {
(globalThis as any).entity = {};
(globalThis as any).currentUser = {
isInGroup: (group: string) => group === 'Reviewers',
};
});

afterEach(() => {
delete (globalThis as any).entity;
delete (globalThis as any).currentUser;
});

describe('canApprove', () => {
it('returns true when state is pending and user is a reviewer', () => {
entity.state = 'pending';
expect(canApprove()).toBe(true);
});

it('returns false when state is not pending', () => {
entity.state = 'draft';
expect(canApprove()).toBe(false);
});

it('returns false when user is not in Reviewers group', () => {
entity.state = 'pending';
(globalThis as any).currentUser = {
isInGroup: () => false,
};
expect(canApprove()).toBe(false);
});
});

describe('canSubmit', () => {
it('returns true when state is draft', () => {
entity.state = 'draft';
expect(canSubmit()).toBe(true);
});

it('returns false when state is pending', () => {
entity.state = 'pending';
expect(canSubmit()).toBe(false);
});
});

Example: testing field definitions

Field definition tests verify that your AppField[] array follows naming conventions and includes required fields. These tests act as a safety net against typos and missing configuration.

// fields/index.test.ts
import { fields } from './index';

describe('field definitions', () => {
it('all custom fields use the c_ prefix', () => {
const systemFields = [
'title', 'description', 'state', 'creation_date',
'update_date', 'keeper_id',
];
for (const field of fields) {
if (!systemFields.includes(field.name)) {
expect(field.name).toMatch(/^c_/);
}
}
});

it('includes required fields', () => {
const fieldNames = fields.map((f) => f.name);
expect(fieldNames).toContain('c_quantity');
expect(fieldNames).toContain('c_unit_price');
expect(fieldNames).toContain('c_status');
});

it('lookup fields have properly formatted options', () => {
const lookups = fields.filter((f) => f.type === 'lookup');
for (const lookup of lookups) {
expect(lookup.options?.lookupOptions).toBeDefined();
for (const opt of lookup.options!.lookupOptions!) {
expect(opt).toHaveProperty('name');
expect(opt).toHaveProperty('caption');
expect(opt.name).toMatch(/^[a-z0-9_-]+$/);
}
}
});

it('calc fields declare their dependencies', () => {
const calcFields = fields.filter((f) => f.calcType === 'calcfield');
for (const field of calcFields) {
expect(field.dependsOn).toBeDefined();
expect(field.dependsOn!.length).toBeGreaterThan(0);
}
});
});

Tips

  • Keep tests fast. They should run in seconds, not minutes. Avoid network calls or file system access.
  • Test business logic, not framework behavior. Do not test that the platform calls your calc field - test that the calc field returns the right value.
  • Calc field tests are the most valuable. They catch formula bugs before deployment. A wrong multiplication or a missing null check is easy to miss in code review but trivial to catch with a test.
  • Action logic tests catch bugs in state changes. If an approval action should set three fields, a test ensures all three are set correctly.
  • Use descriptive test names. Write "calculates total as quantity times unit price" instead of "works correctly". When a test fails, the name should tell you what broke.
  • Mock globals in beforeEach, clean up in afterEach. This prevents state leaking between tests and mirrors how the platform provides entity, entityOld, and currentUser at runtime.
  • Export functions you want to test. The default export (satisfies ActionLogic) is consumed by the platform, but you can also export individual functions by name for direct testing. See the action logic and precondition patterns.