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
| Component | Testable? | How |
|---|---|---|
| Calc field functions | Yes | Import the function, pass a mock entity, assert the result |
| Action logic functions | Yes | Import the function, set up entity/entityOld globals, assert mutations |
| Precondition functions | Yes | Import the function, set up entity global, assert the boolean return |
| Field definitions | Yes | Import the fields array, assert structure (names, types, options) |
View logic (getInvisibleFields, etc.) | Limited | Functions depend on ComindView global which is not available in tests |
| Layouts (TSX) | No | Layouts 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 inafterEach. This prevents state leaking between tests and mirrors how the platform providesentity,entityOld, andcurrentUserat 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.
Related
- Production deployment - pre-deploy checklist and multi-environment configuration
- Fields and field options - field definitions and calc field types referenced in field definition tests