| name | add-knowledge-connector-unit-tests |
| description | Guide for adding unit tests to a Knowledge Connector in a Cognigy Extension. Covers project setup (dependencies, scripts, build exclusion), test scaffolding using the Node.js standard test library (node:test + node:assert), the standard KnowledgeApi mock pattern, vendor-specific API mocking strategies, required test scenarios, and verification steps. |
Adding Unit Tests for a Knowledge Connector
This skill describes how to introduce unit tests for a Knowledge Connector extension using the Node.js standard testing library (node:test). Tests validate that connectors correctly create, upsert, and delete Knowledge Sources and Chunks via the KnowledgeApi.
Overview
Test Framework: Node.js built-in test runner (node:test)
- Assertions:
node:assert (strictEqual, partialDeepStrictEqual, ok)
- Mocking:
mock from node:test (mock.fn(), mock.method())
- TypeScript execution:
tsx loader (--import=tsx)
- No third-party test frameworks (no Jest, Vitest, or Mocha)
Reference Implementation:
Related Skills:
Prerequisites
Before writing tests for a Knowledge Connector, ensure you have:
- An existing Knowledge Connector — A working connector created following the add-knowledge-connector skill
- Vendor API response types — A
types.ts file in the connector's knowledge-connectors/ directory describing the shapes of vendor API responses. Examples:
- Confluence types.ts —
ConfluenceGetPageResponse, ConfluenceGetPageDescendantsResponse, ConfluenceGetPageBodyFormatStorageResponse
- Diffbot types.ts —
DiffbotV3AnalyzeResponse, DiffbotArticle
- Understanding of vendor API calls — Know which HTTP calls the connector makes (typically via
global.fetch), in what order, and what response shapes it expects
Step 1: Project Setup
Three files need to be created or updated to support testing.
1.1 Dev Dependencies
Add tsx and @types/node to devDependencies in the extension's package.json if not already present:
{
"devDependencies": {
"@types/node": "^24.2.0",
"tsx": "4.21.0",
"typescript": "5.9.2"
}
}
The nodejs test runner already supports Typescript files but imposes some restrictions on module configurations.
Using tsx allows us to run TypeScript test files without pre-compiling them, and it handles the necessary ESM loader configuration under the hood.
1.2 NPM Scripts
Add a test script and update the build script so tests gate the build:
{
"scripts": {
"test": "node --test --import=tsx --experimental-test-coverage --test-coverage-exclude='**/*.test.ts'",
"transpile": "tsc -p tsconfig.production.json",
"build": "npm run test && npm run transpile && npm run lint && npm run zip"
}
}
Script breakdown:
--test — Enables the Node.js built-in test runner
--import=tsx — Loads the tsx ESM loader to run TypeScript directly without pre-compilation
--experimental-test-coverage — Generates test coverage report
--test-coverage-exclude='**/*.test.ts' — Excludes test files themselves from coverage metrics
1.3 Production TypeScript Config
Create or update tsconfig.production.json to exclude test files from the production build output. The base tsconfig.json should continue to include test files so the IDE provides type checking.
tsconfig.production.json:
{
"extends": "./tsconfig.json",
"exclude": [
"node_modules",
"build",
"./vscode",
"**/*.test.ts",
]
}
Generally only the **/*.test.ts pattern needs to be added to exclude test files from the base tsconfig.json.
Ensure the transpile script in package.json uses the production config:
"transpile": "tsc -p tsconfig.production.json"
Step 2: Test File Structure
File Location and Naming
Test files are colocated with the connector code:
extensions/{extension-name}/
├── src/
│ └── knowledge-connectors/
│ ├── {connector}Connector.ts # Connector implementation
│ ├── {connector}Connector.test.ts # Test file (same name as connector)
│ ├── types.ts # Vendor API response types
│ └── helper/
│ └── utils.ts # Utilities under test
Convention: The test file has the same name as the connector file with a .test.ts suffix. Create one test file per knowledge connector in an extension.
Standard Imports
Every test file starts with these imports:
import * as assert from "node:assert";
import { beforeEach, describe, it, mock } from "node:test";
import type {
CreateKnowledgeChunkReturnValue,
CreateKnowledgeSourceReturnValue,
DeleteKnowledgeSourceReturnValue,
KnowledgeApi,
UpsertKnowledgeSourceReturnValue,
} from "@cognigy/extension-tools/build/interfaces/knowledgeConnector";
Then import the connector(s) under test and vendor-specific types:
import { myConnector } from "./myConnector";
import type { VendorApiResponse, VendorListResponse } from "./types";
DeepPartial Utility Type
Define this utility type at the top of the test file. It allows creating mock vendor API responses with only the fields relevant to each test, without requiring every property:
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : Partial<T[K]>;
};
Step 3: KnowledgeApi Mock Pattern
This is the standard, vendor-agnostic mock setup that applies to every knowledge connector test. It mocks the api parameter passed to the connector's function.
3.1 Declare Result Variables and Mock API
Inside the describe block, declare variables to control what each API method returns:
describe("myConnector", () => {
let mockApi: { [K in keyof KnowledgeApi]?: it.Mock<KnowledgeApi[K]> } = {};
let createKnowledgeSourceResult: CreateKnowledgeSourceReturnValue;
let deleteKnowledgeSourceResult: DeleteKnowledgeSourceReturnValue;
let upsertKnowledgeSourceResult: UpsertKnowledgeSourceReturnValue;
let createKnowledgeChunkResult: CreateKnowledgeChunkReturnValue;
3.2 Initialize Mocks in beforeEach
Reset all mocks before each test. Each mock function resolves with its corresponding result variable:
beforeEach(() => {
mockApi = {
createKnowledgeSource: mock.fn(
() => Promise.resolve(createKnowledgeSourceResult) as any,
),
deleteKnowledgeSource: mock.fn(
() => Promise.resolve(deleteKnowledgeSourceResult) as any,
),
upsertKnowledgeSource: mock.fn(
() => Promise.resolve(upsertKnowledgeSourceResult) as any,
),
createKnowledgeChunk: mock.fn(
() => Promise.resolve(createKnowledgeChunkResult) as any,
),
};
});
3.3 Setting Return Values per Test
Before calling the connector function in each test, set the result variables to control behavior:
it("should create a new knowledge source", async () => {
upsertKnowledgeSourceResult = { knowledgeSourceId: "source-123" };
});
Special case — null upsert (content unchanged):
it("should skip ingestion when content is unchanged", async () => {
upsertKnowledgeSourceResult = null;
});
3.4 Dynamic Mock Behavior
For tests where a mock needs to return different values across multiple calls (e.g., processing multiple pages), use mockImplementation:
mockApi.upsertKnowledgeSource.mock.mockImplementation(() =>
Promise.resolve(
mockApi.upsertKnowledgeSource.mock.callCount() > 0
? { knowledgeSourceId: "source-456" }
: { knowledgeSourceId: "source-123" },
),
);
3.5 Calling the Connector
Invoke the connector's function with the mock API, configuration, and sources:
await myConnector.function({
api: mockApi as KnowledgeApi,
config: {
connection: {
// Vendor-specific auth fields
},
// Vendor-specific config fields matching the connector's `fields` definition
},
sources: [], // Empty for new sources, populated for upsert/delete scenarios
});
The sources parameter simulates existing Knowledge Sources from previous connector runs:
sources: [
{
knowledgeSourceId: "source-123",
name: "Source Name",
description: "Data from Source Name",
chunkCount: 2,
externalIdentifier: "vendor-id-123",
tags: [],
contentHashOrTimestamp: "hash",
},
],
Step 4: Vendor-Specific API Mocking
Knowledge connectors typically use global.fetch to call vendor APIs. This section describes how to mock those calls.
Approach
Follow a tiered approach based on available information:
-
When types.ts exists in the connector directory: Use the exported types with DeepPartial<> to build mock responses. Examine the connector's function implementation to identify each fetch call and its expected response shape. Implement mock helpers on a best-effort basis.
-
When vendor API documentation is publicly available: Research the vendor's API reference to build accurate mock response shapes, even if types.ts is incomplete. Implement on a best-effort basis.
-
When neither types nor documentation are available: Flag the vendor-specific mocking as requiring developer collaboration. Leave TODO stubs with // DEVELOPER COLLABORATION REQUIRED: markers explaining what data shape is needed and why.
Mock Helper Functions
Create helper functions that mock global.fetch for each vendor API endpoint. Use { times: 1 } so mocks are consumed in order — this allows stacking multiple sequential API call mocks.
const mockVendorGetItemResponse = (
response: DeepPartial<VendorGetItemResponse> = {
// Sensible defaults for the most common test case
id: "item-123",
title: "Item Title",
content: "<p>Some content</p>",
},
) =>
mock.method(
global,
"fetch",
async () => ({
ok: true,
json: async () => response,
}),
{ times: 1 },
);
Key points:
- Each helper accepts an optional
DeepPartial<VendorType> parameter with sensible defaults
{ times: 1 } ensures the mock is consumed once, then the next stacked mock takes over
- Mocks are stacked in reverse call order — the last mock registered is consumed first
- The mock returns an object matching the
fetch Response interface (ok, json())
Stacking Multiple API Call Mocks
When the connector makes multiple sequential API calls, stack mocks in reverse order:
it("should handle multiple API calls", async () => {
mockVendorGetContentResponse();
mockVendorGetItemResponse();
upsertKnowledgeSourceResult = { knowledgeSourceId: "source-123" };
await myConnector.function({ /* ... */ });
});
Error Response Mocking
To test error handling, mock fetch to return non-ok responses:
const mockVendorErrorResponse = (status = 404, statusText = "Not Found") =>
mock.method(
global,
"fetch",
async () => ({
ok: false,
status,
statusText,
}),
{ times: 1 },
);
Using types.ts for Mock Data
The connector's types.ts file is the primary reference for constructing realistic mock data. Each exported interface maps to a vendor API response and tells you what fields the connector expects:
export interface VendorGetItemResponse {
id: string;
title: string;
body: {
content: { value: string };
};
_links: { webUrl: string };
}
import type { VendorGetItemResponse } from "./types";
const mockGetItemResponse = (
response: DeepPartial<VendorGetItemResponse> = {
id: "123",
title: "Test Item",
body: { content: { value: "<p>Test content</p>" } },
_links: { webUrl: "/items/123/Test+Item" },
},
) => mock.method(global, "fetch", async () => ({
ok: true,
json: async () => response,
}), { times: 1 });
When types.ts is not available, read the connector implementation to identify the response shapes used, then create the mock helpers with the minimal set of fields the connector accesses. Mark these with:
Step 5: Required Test Scenarios
Every knowledge connector test suite should cover these four scenarios.
5.1 Create New Source with Chunks
Tests the initial ingestion when no previous sources exist.
it("should create a new knowledge source with chunks", async () => {
upsertKnowledgeSourceResult = { knowledgeSourceId: "source-123" };
await myConnector.function({
api: mockApi as KnowledgeApi,
config: { /* vendor-specific config */ },
sources: [],
});
assert.partialDeepStrictEqual(
mockApi.upsertKnowledgeSource.mock.calls[0].arguments[0],
{
name: "Expected Source Name",
description: "Data from Expected Source Name",
tags: [],
chunkCount: 2,
externalIdentifier: "vendor-item-id",
},
);
assert.ok(
mockApi.upsertKnowledgeSource.mock.calls[0].arguments[0]
.contentHashOrTimestamp,
);
assert.strictEqual(mockApi.createKnowledgeChunk.mock.calls.length, 2);
assert.partialDeepStrictEqual(
mockApi.createKnowledgeChunk.mock.calls[0].arguments[0],
{
knowledgeSourceId: "source-123",
text: "Expected chunk text content",
data: {
},
},
);
assert.strictEqual(mockApi.deleteKnowledgeSource.mock.calls.length, 0);
});
5.2 Upsert Existing Source
Tests updating a source that already exists from a previous run.
it("should upsert an existing knowledge source with chunks", async () => {
upsertKnowledgeSourceResult = { knowledgeSourceId: "source-123" };
await myConnector.function({
api: mockApi as KnowledgeApi,
config: { /* vendor-specific config */ },
sources: [
{
knowledgeSourceId: "source-123",
name: "Source Name",
description: "Data from Source Name",
chunkCount: 2,
externalIdentifier: "vendor-item-id",
tags: [],
contentHashOrTimestamp: "previous-hash",
},
],
});
assert.partialDeepStrictEqual(
mockApi.upsertKnowledgeSource.mock.calls[0].arguments[0],
{
name: "Source Name",
chunkCount: 3,
externalIdentifier: "vendor-item-id",
},
);
assert.strictEqual(mockApi.createKnowledgeChunk.mock.calls.length, 3);
assert.strictEqual(mockApi.deleteKnowledgeSource.mock.calls.length, 0);
});
5.3 Delete Outdated Sources
Tests that sources from a previous run are deleted when the connector now points to different content.
it("should delete outdated sources", async () => {
upsertKnowledgeSourceResult = { knowledgeSourceId: "new-source-123" };
await myConnector.function({
api: mockApi as KnowledgeApi,
config: { /* vendor-specific config pointing to NEW content */ },
sources: [
{
knowledgeSourceId: "old-source-123",
name: "Old Source",
description: "Data from Old Source",
chunkCount: 2,
externalIdentifier: "old-vendor-item-id",
tags: [],
contentHashOrTimestamp: "hash",
},
],
});
assert.strictEqual(mockApi.upsertKnowledgeSource.mock.calls.length, 1);
assert.strictEqual(mockApi.createKnowledgeChunk.mock.calls.length, 2);
assert.strictEqual(
mockApi.deleteKnowledgeSource.mock.calls[0].arguments[0]
.knowledgeSourceId,
"old-source-123",
);
});
5.4 Skip Ingestion on Null Upsert
Tests that when upsertKnowledgeSource returns null (content hash unchanged), no chunks are created.
it("should skip chunk ingestion when upsert returns null", async () => {
upsertKnowledgeSourceResult = null;
await myConnector.function({
api: mockApi as KnowledgeApi,
config: { /* vendor-specific config */ },
sources: [
{
knowledgeSourceId: "source-123",
name: "Source Name",
description: "Data from Source Name",
chunkCount: 2,
externalIdentifier: "vendor-item-id",
tags: [],
contentHashOrTimestamp: "hash",
},
],
});
assert.strictEqual(mockApi.upsertKnowledgeSource.mock.calls.length, 1);
assert.strictEqual(mockApi.createKnowledgeChunk.mock.calls.length, 0);
assert.strictEqual(mockApi.deleteKnowledgeSource.mock.calls.length, 0);
});
Step 6: Assertion Patterns
Key Assertion Methods
| Method | Use Case | Example |
|---|
assert.partialDeepStrictEqual(actual, expected) | Check object contains expected properties (ignores extra) | Verify upsertKnowledgeSource params |
assert.strictEqual(actual, expected) | Exact equality for primitives | Check mock call counts |
assert.ok(value) | Truthy check | Verify contentHashOrTimestamp is set |
Accessing Mock Call Data
mockApi.upsertKnowledgeSource.mock.calls.length
mockApi.upsertKnowledgeSource.mock.calls[0].arguments[0]
mockApi.deleteKnowledgeSource.mock.calls[0].arguments[0].knowledgeSourceId
mockApi.upsertKnowledgeSource.mock.callCount()
Assertion Examples
assert.partialDeepStrictEqual(
mockApi.upsertKnowledgeSource.mock.calls[0].arguments[0],
{
name: "Page Title",
description: "Data from Page Title",
tags: ["tag1"],
chunkCount: 2,
externalIdentifier: "page-123",
},
);
assert.ok(
mockApi.upsertKnowledgeSource.mock.calls[0].arguments[0]
.contentHashOrTimestamp,
);
assert.partialDeepStrictEqual(
mockApi.createKnowledgeChunk.mock.calls[0].arguments[0],
{
knowledgeSourceId: "source-123",
text: "Expected text content",
data: {
heading: "Section Title",
url: "https://example.com/page/123",
},
},
);
assert.strictEqual(mockApi.deleteKnowledgeSource.mock.calls.length, 0);
assert.strictEqual(
mockApi.deleteKnowledgeSource.mock.calls[0].arguments[0].knowledgeSourceId,
"old-source-123",
);
Step 7: Complete Test File Template
Use this template as a starting point. The KnowledgeApi mock setup is universal and ready to use. Vendor-specific sections are marked with comments indicating where implementation is needed.
import * as assert from "node:assert";
import { beforeEach, describe, it, mock } from "node:test";
import type {
CreateKnowledgeChunkReturnValue,
CreateKnowledgeSourceReturnValue,
DeleteKnowledgeSourceReturnValue,
KnowledgeApi,
UpsertKnowledgeSourceReturnValue,
} from "@cognigy/extension-tools/build/interfaces/knowledgeConnector";
import { myConnector } from "./myConnector";
import type { VendorGetItemResponse, VendorListResponse } from "./types";
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : Partial<T[K]>;
};
describe("myConnector", () => {
let mockApi: { [K in keyof KnowledgeApi]?: it.Mock<KnowledgeApi[K]> } = {};
let createKnowledgeSourceResult: CreateKnowledgeSourceReturnValue;
let deleteKnowledgeSourceResult: DeleteKnowledgeSourceReturnValue;
let upsertKnowledgeSourceResult: UpsertKnowledgeSourceReturnValue;
let createKnowledgeChunkResult: CreateKnowledgeChunkReturnValue;
beforeEach(() => {
mockApi = {
createKnowledgeSource: mock.fn(
() => Promise.resolve(createKnowledgeSourceResult) as any,
),
deleteKnowledgeSource: mock.fn(
() => Promise.resolve(deleteKnowledgeSourceResult) as any,
),
upsertKnowledgeSource: mock.fn(
() => Promise.resolve(upsertKnowledgeSourceResult) as any,
),
createKnowledgeChunk: mock.fn(
() => Promise.resolve(createKnowledgeChunkResult) as any,
),
};
});
const mockGetItemResponse = (
response: DeepPartial<VendorGetItemResponse> = {
// Default values for the most common test case
id: "item-123",
title: "Item Title",
// Add fields the connector reads from the response
},
) =>
mock.method(
global,
"fetch",
async () => ({
ok: true,
json: async () => response,
}),
{ times: 1 },
);
it("should create a new knowledge source with chunks", async () => {
upsertKnowledgeSourceResult = { knowledgeSourceId: "source-123" };
await myConnector.function({
api: mockApi as KnowledgeApi,
config: {
connection: {
// Vendor-specific auth fields
},
// Vendor-specific config fields
},
sources: [],
});
assert.partialDeepStrictEqual(
mockApi.upsertKnowledgeSource.mock.calls[0].arguments[0],
{
name: "Item Title",
description: "Data from Item Title",
tags: [],
chunkCount: 1,
externalIdentifier: "item-123",
},
);
assert.ok(
mockApi.upsertKnowledgeSource.mock.calls[0].arguments[0]
.contentHashOrTimestamp,
);
assert.strictEqual(mockApi.createKnowledgeChunk.mock.calls.length, 1);
assert.partialDeepStrictEqual(
mockApi.createKnowledgeChunk.mock.calls[0].arguments[0],
{
knowledgeSourceId: "source-123",
text: "Expected chunk text",
},
);
assert.strictEqual(mockApi.deleteKnowledgeSource.mock.calls.length, 0);
});
it("should upsert an existing knowledge source with chunks", async () => {
upsertKnowledgeSourceResult = { knowledgeSourceId: "source-123" };
await myConnector.function({
api: mockApi as KnowledgeApi,
config: { /* vendor-specific config */ },
sources: [
{
knowledgeSourceId: "source-123",
name: "Item Title",
description: "Data from Item Title",
chunkCount: 1,
externalIdentifier: "item-123",
tags: [],
contentHashOrTimestamp: "previous-hash",
},
],
});
assert.partialDeepStrictEqual(
mockApi.upsertKnowledgeSource.mock.calls[0].arguments[0],
{
name: "Item Title",
externalIdentifier: "item-123",
},
);
assert.ok(mockApi.createKnowledgeChunk.mock.calls.length > 0);
assert.strictEqual(mockApi.deleteKnowledgeSource.mock.calls.length, 0);
});
it("should delete outdated sources", async () => {
upsertKnowledgeSourceResult = { knowledgeSourceId: "new-source-123" };
await myConnector.function({
api: mockApi as KnowledgeApi,
config: { /* vendor-specific config for NEW content */ },
sources: [
{
knowledgeSourceId: "old-source-123",
name: "Old Item",
description: "Data from Old Item",
chunkCount: 2,
externalIdentifier: "old-item-id",
tags: [],
contentHashOrTimestamp: "hash",
},
],
});
assert.strictEqual(mockApi.upsertKnowledgeSource.mock.calls.length, 1);
assert.strictEqual(
mockApi.deleteKnowledgeSource.mock.calls[0].arguments[0]
.knowledgeSourceId,
"old-source-123",
);
});
it("should skip chunk ingestion when upsert returns null", async () => {
upsertKnowledgeSourceResult = null;
await myConnector.function({
api: mockApi as KnowledgeApi,
config: { /* vendor-specific config */ },
sources: [
{
knowledgeSourceId: "source-123",
name: "Item Title",
description: "Data from Item Title",
chunkCount: 1,
externalIdentifier: "item-123",
tags: [],
contentHashOrTimestamp: "hash",
},
],
});
assert.strictEqual(mockApi.upsertKnowledgeSource.mock.calls.length, 1);
assert.strictEqual(mockApi.createKnowledgeChunk.mock.calls.length, 0);
assert.strictEqual(mockApi.deleteKnowledgeSource.mock.calls.length, 0);
});
});
Step 8: Verification
After implementing the tests, verify at the extension directory level:
Run Tests
npm test
Expected output: All tests pass with coverage report. Coverage should exclude *.test.ts files.
Verify Build
npm run build
Expected output: Tests pass, then TypeScript compiles, then linting runs, then the archive is created.
Verify Build Output Excludes Tests
ls build/**/*.test.* 2>/dev/null && echo "ERROR: Test files in build output" || echo "OK: No test files in build"
Verify Coverage Exclusion
The coverage report printed by --experimental-test-coverage should not list any *.test.ts files in its file-by-file breakdown.
Quick Reference
| Aspect | Convention |
|---|
| Test framework | node:test (built-in) |
| Assertions | node:assert |
| Mocking | mock from node:test |
| TypeScript loader | tsx (--import=tsx) |
| Test file location | src/knowledge-connectors/{connector}Connector.test.ts |
| Build exclusion | tsconfig.production.json excludes **/*.test.ts |
| Coverage exclusion | --test-coverage-exclude='**/*.test.ts' |
| Test in build | Tests run first: npm run test && npm run transpile && ... |
| KnowledgeApi import | @cognigy/extension-tools/build/interfaces/knowledgeConnector |
| Fetch mocking | mock.method(global, "fetch", ..., { times: 1 }) |
| Mock response typing | DeepPartial<VendorType> from ./types |