> For the complete documentation index, see [llms.txt](https://docs.hexabot.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.hexabot.ai/developer-guide/develop-custom-actions/testing-custom-actions.md).

# Testing Custom Actions

This guide uses Jest examples for reusable `hexabot-action-*` packages, but the same patterns apply to project-local actions compiled under `dist/extensions/actions/**/*.action.js`.

### What to Test

Test each action at three levels:

* Schema contract: valid and invalid `input`, `settings`, and `output` payloads.
* Execution behavior: service calls, HTTP requests, SDK calls, memory updates, bindings, and normalized return values.
* Failure behavior: missing credentials, validation failures, upstream errors, network errors, retry-sensitive side effects, and safe logging.

Use focused unit tests. Do not bootstrap the full Hexabot API unless the test is explicitly verifying NestJS module wiring.

### Install Jest Dependencies

For a standalone TypeScript action package, install Jest with a TypeScript transform. This example uses `@swc/jest`, which matches the Hexabot monorepo test style.

```sh
npm install -D jest @swc/core @swc/jest @types/jest typescript
```

With pnpm:

```sh
pnpm add -D jest @swc/core @swc/jest @types/jest typescript
```

Add scripts to `package.json`:

```json
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:cov": "jest --coverage"
  }
}
```

### Jest Configuration

Create `jest.config.cjs`:

```js
module.exports = {
  testEnvironment: 'node',
  testRegex: '.*\\.spec\\.ts$',
  moduleFileExtensions: ['ts', 'js', 'json'],
  transform: {
    '^.+\\.(t|j)s$': [
      '@swc/jest',
      {
        jsc: {
          parser: {
            syntax: 'typescript',
            decorators: true
          },
          target: 'es2021',
          transform: {
            legacyDecorator: true,
            decoratorMetadata: true
          },
          keepClassNames: true
        },
        module: {
          type: 'commonjs'
        }
      }
    ]
  },
  clearMocks: true,
  restoreMocks: true
};
```

If the package uses TypeScript path aliases, add `moduleNameMapper` entries or avoid aliases in testable action packages.

### Test File Layout

Keep tests close to the action source:

```
hexabot-action-acme-ticket/
|-- src/
|   |-- acme-create-ticket.action.ts
|   `-- acme-create-ticket.action.spec.ts
|-- jest.config.cjs
`-- package.json
```

Or keep all tests under `test/`:

```
test/
`-- acme-create-ticket.action.spec.ts
```

Use the layout your package already uses, then make `testRegex` or `testMatch` match it.

### Instantiate an Action

`createAction()` returns an injectable action class. For a unit test, instantiate it directly with a mocked `ActionService`.

```ts
import { ActionService } from '@hexabot-ai/api';

import { AcmeCreateTicketAction } from './acme-create-ticket.action';

describe('AcmeCreateTicketAction', () => {
  let action: InstanceType<typeof AcmeCreateTicketAction>;
  let registerAction: jest.Mock;

  beforeEach(() => {
    registerAction = jest.fn();
    const actionService = {
      register: registerAction,
    } as unknown as ActionService;

    action = new AcmeCreateTicketAction(actionService);
  });

  it('registers with ActionService when Nest initializes the provider', async () => {
    await expect(action.onModuleInit()).resolves.toBeUndefined();
    expect(registerAction).toHaveBeenCalledWith(action);
  });
});
```

When your action extends `BaseAction` and injects additional services, pass mocks for those constructor arguments:

```ts
const mailerService = {
  sendMail: jest.fn(),
};

const action = new SendCustomerEmailAction(
  actionService,
  mailerService as any,
);
```

### Test the Schema Contract

Use `parseInput`, `parseSettings`, and `parseOutput` to test the contract that workflow authors rely on.

```ts
it('accepts a valid input payload', () => {
  expect(
    action.parseInput({
      subject: 'Cannot sign in',
      customer_email: 'ada@example.com',
      priority: 'high',
    }),
  ).toEqual({
    subject: 'Cannot sign in',
    customer_email: 'ada@example.com',
    priority: 'high',
  });
});

it('rejects invalid email input', () => {
  expect(() =>
    action.parseInput({
      subject: 'Cannot sign in',
      customer_email: 'not-an-email',
      priority: 'normal',
    }),
  ).toThrow();
});

it('parses action settings and preserves base settings', () => {
  expect(
    action.parseSettings({
      base_url: 'https://api.acme.example',
      credential_id: 'credential-1',
      timeout_ms: 5000,
    }),
  ).toMatchObject({
    base_url: 'https://api.acme.example',
    credential_id: 'credential-1',
    timeout_ms: 5000,
  });
});

it('parses success and failure outputs', () => {
  expect(
    action.parseOutput({
      success: true,
      ticket_id: 'TCK-123',
      status: 201,
    }),
  ).toEqual({
    success: true,
    ticket_id: 'TCK-123',
    status: 201,
  });

  expect(
    action.parseOutput({
      success: false,
      status: 401,
      error: 'Unauthorized',
    }),
  ).toEqual({
    success: false,
    status: 401,
    error: 'Unauthorized',
  });
});
```

Schema tests should cover:

* required fields;
* invalid formats such as email, URL, UUID, and enum values;
* cross-field validation rules;
* default settings;
* the success output shape;
* the failure output shape;
* rejection of unsupported extra fields when schemas are strict.

Do not redefine `timeout_ms` or `retries` in an action settings schema. Hexabot parses those base settings for every action.

### Test Execution with Mocked Context Services

Actions receive runtime dependencies through `context.services`, `context.event`, `context.memoryStore`, and `bindings`. Unit tests can pass the minimum context shape the action needs.

```ts
const credentials = {
  findOneValue: jest.fn().mockResolvedValue('secret-token'),
};

const logger = {
  warn: jest.fn(),
  error: jest.fn(),
};

const context = {
  services: {
    credentials,
    logger,
  },
} as any;

const result = await action.execute({
  input: {
    subject: 'Cannot sign in',
    customer_email: 'ada@example.com',
    priority: 'normal',
  },
  settings: {
    base_url: 'https://api.acme.example',
    credential_id: 'credential-1',
  },
  context,
  bindings: {},
});

expect(credentials.findOneValue).toHaveBeenCalledWith('credential-1');
expect(result).toMatchObject({
  success: true,
});
```

Call `execute()` when you want to isolate action logic. Call `run()` when you want the base runtime to validate input, validate output, apply timeout/retry settings, and reject unsupported bindings.

```ts
await expect(
  action.run(
    {
      subject: '',
      customer_email: 'ada@example.com',
    },
    context,
    {
      base_url: 'https://api.acme.example',
      credential_id: 'credential-1',
    },
  ),
).rejects.toThrow();
```

### Mock HTTP Calls

Prefer mocking the HTTP client instead of calling external APIs. If the action uses `fetch`, spy on `global.fetch`.

```ts
let fetchMock: jest.SpiedFunction<typeof fetch>;

beforeEach(() => {
  fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({
    ok: true,
    status: 201,
    json: jest.fn().mockResolvedValue({ id: 'TCK-123' }),
  } as any);
});

afterEach(() => {
  jest.restoreAllMocks();
});

it('creates an Acme ticket with the resolved credential', async () => {
  const result = await action.execute({
    input: {
      subject: 'Cannot sign in',
      customer_email: 'ada@example.com',
      priority: 'high',
    },
    settings: {
      base_url: 'https://api.acme.example',
      credential_id: 'credential-1',
      timeout_ms: 5000,
    },
    context,
    bindings: {},
  });

  expect(fetchMock).toHaveBeenCalledWith(
    'https://api.acme.example/tickets',
    expect.objectContaining({
      method: 'POST',
      headers: expect.objectContaining({
        authorization: 'Bearer secret-token',
        'content-type': 'application/json',
      }),
    }),
  );
  expect(result).toEqual({
    success: true,
    ticket_id: 'TCK-123',
    status: 201,
  });
});
```

If the action uses `axios`, mock the module:

```ts
import axios from 'axios';

jest.mock('axios', () => ({
  __esModule: true,
  default: {
    request: jest.fn(),
  },
}));

const requestMock = axios.request as jest.Mock;
requestMock.mockResolvedValueOnce({
  status: 200,
  statusText: 'OK',
  headers: { 'content-type': 'application/json' },
  data: { id: 'TCK-123' },
});
```

### Test Failure Paths

Failure tests are as important as success tests because workflows often branch on failure outputs.

```ts
it('throws when the configured credential has no value', async () => {
  credentials.findOneValue.mockResolvedValueOnce('');

  await expect(
    action.execute({
      input: {
        subject: 'Cannot sign in',
        customer_email: 'ada@example.com',
        priority: 'normal',
      },
      settings: {
        base_url: 'https://api.acme.example',
        credential_id: 'missing-credential',
      },
      context,
      bindings: {},
    }),
  ).rejects.toThrow('Missing Acme API credential value');
});

it('returns a structured failure output for non-2xx responses', async () => {
  fetchMock.mockResolvedValueOnce({
    ok: false,
    status: 401,
    text: jest.fn().mockResolvedValue('Unauthorized'),
  } as any);

  const result = await action.execute({
    input: {
      subject: 'Cannot sign in',
      customer_email: 'ada@example.com',
      priority: 'normal',
    },
    settings: {
      base_url: 'https://api.acme.example',
      credential_id: 'credential-1',
    },
    context,
    bindings: {},
  });

  expect(result).toEqual({
    success: false,
    status: 401,
    error: 'Unauthorized',
  });
});
```

Also test malformed upstream payloads, network errors, missing required context, and any idempotency behavior for write actions.

### Test Memory Actions

For memory actions, mock `context.memoryStore`.

```ts
it('updates workflow memory and returns the updated value', async () => {
  const update = jest.fn().mockResolvedValue({
    profile: { name: 'Ada', plan: 'enterprise' },
  });
  const context = {
    memoryStore: { update },
  } as any;

  const result = await action.execute({
    input: {
      memory: {
        profile: { name: 'Ada' },
      },
    },
    settings: {},
    context,
    bindings: {},
  });

  expect(update).toHaveBeenCalledWith({
    profile: { name: 'Ada' },
  });
  expect(result).toEqual({
    memory: {
      profile: { name: 'Ada', plan: 'enterprise' },
    },
  });
});
```

Validate memory slugs and make sure the output is JSON-serializable.

### Test Conversational Actions

Conversational actions usually need `context.event`. Mock only the methods the action calls.

```ts
const context = {
  event: {
    getInitiator: jest.fn(() => ({
      id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
    })),
  },
  services: {
    subscriber: {
      handOverByPolicy: jest.fn(),
    },
  },
} as any;
```

Test the missing-event path when the action cannot run outside a conversational workflow.

```ts
await expect(
  action.execute({
    input: { mode: 'auto' },
    settings: {},
    context: { services: context.services } as any,
    bindings: {},
  }),
).rejects.toThrow('Missing event');
```

Restrict conversational-only actions with `workflowTypes` in the action metadata, then test that workflow-specific assumptions are enforced by the action logic.

### Test Bindings

When an action consumes runtime bindings, test both the direct execution path and the base runtime validation path.

```ts
it('rejects unsupported binding kinds through action.run', async () => {
  await expect(
    action.run(validInput, context, validSettings, {
      unsupported_binding: {
        settings: {},
      },
    } as any),
  ).rejects.toThrow(/does not support binding kind/);
});
```

For custom binding kinds, instantiate the binding provider with a mocked `RuntimeBindingsService` and assert registration:

```ts
import { RuntimeBindingsService } from '@hexabot-ai/api';

import { AcmeAccountBindingKind } from './acme-account.binding';

it('registers the acme_account binding kind', () => {
  const runtimeBindingsService = {
    register: jest.fn(),
  } as unknown as RuntimeBindingsService;
  const binding = new AcmeAccountBindingKind(runtimeBindingsService);

  binding.onModuleInit();

  expect(runtimeBindingsService.register).toHaveBeenCalledWith(
    expect.objectContaining({
      kind: 'acme_account',
      multiple: false,
    }),
  );
});
```

### Test Safe Logging

Log assertions should prove that useful metadata is logged without leaking secrets.

```ts
expect(logger.warn).toHaveBeenCalledWith(
  'acme_create_ticket failed',
  expect.objectContaining({
    status: 401,
    credential_id: 'credential-1',
  }),
);

const logPayload = JSON.stringify(logger.warn.mock.calls);
expect(logPayload).not.toContain('secret-token');
```

Avoid snapshots for logs or upstream payloads that may contain credentials or PII.

### Run Tests Before Publishing

Run the package checks before publishing or installing the action in a shared project:

```sh
npm run typecheck
npm test
npm run build
npm pack --dry-run
```

With pnpm:

```sh
pnpm typecheck
pnpm test
pnpm build
pnpm pack --dry-run
```

For actions inside the Hexabot monorepo, run the API package tests:

```sh
pnpm --filter @hexabot-ai/api run test
pnpm --filter @hexabot-ai/api run typecheck
```

Use a clean Hexabot project for final installation verification, but keep that as a package integration check. Unit tests should stay fast and deterministic.

### Checklist

* Valid input parses successfully.
* Invalid input fails with useful schema errors.
* Settings parse with action settings and base `timeout_ms`/`retries`.
* Success and failure outputs match `outputSchema`.
* External clients are mocked.
* Credentials are resolved through `context.services.credentials`.
* Missing or empty credential values are tested.
* Non-2xx, network, and malformed upstream responses are tested.
* Logs do not include raw secrets or full sensitive payloads.
* Memory and conversational context assumptions are tested when relevant.
* Binding support is tested when the action consumes bindings.
* `action.run()` is covered when timeout, retry, schema, or binding behavior is part of the contract.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.hexabot.ai/developer-guide/develop-custom-actions/testing-custom-actions.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
