test: Add unit testing to nodes (no-changelog) (#4890)

* 🧪 Add base for building unit testing within nodes

* Improve helper functions

* 🧪 If node test

* 🧪 Airtable node test

* 🧪 If node test improvements

* 🧪 Airtable node test improvements

* ♻️ cleanup node unit tests

* ♻️ refactor getting node result data to use helper method

*  removed unused variables

* ♻️ Helper to read json files

---------

Co-authored-by: Marcus <marcus@n8n.io>
Co-authored-by: Michael Kret <michael.k@radency.com>
This commit is contained in:
agobrech 2023-01-30 12:20:33 +01:00 committed by GitHub
parent dbcbe595cc
commit 5b9c650e55
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 772 additions and 0 deletions

View file

@ -0,0 +1,64 @@
import { INodeType } from 'n8n-workflow';
import { executeWorkflow } from '../ExecuteWorkflow';
import * as Helpers from '../Helpers';
import { WorkflowTestData } from '../types';
import nock from 'nock';
import { ManualTrigger } from '../../../nodes/ManualTrigger/ManualTrigger.node';
import { Airtable } from '../../../nodes/Airtable/Airtable.node';
const records = [
{
id: 'rec2BWBoyS5QsS7pT',
createdTime: '2022-08-25T08:22:34.000Z',
fields: {
name: 'Tim',
email: 'tim@email.com',
},
},
];
describe('Execute Airtable Node', () => {
beforeEach(() => {
nock.disableNetConnect();
nock('https://api.airtable.com/v0')
.get('/appIaXXdDqS5ORr4V/tbljyBEdYzCPF0NDh?pageSize=100')
.reply(200, { records });
});
afterEach(() => {
nock.restore();
});
const tests: Array<WorkflowTestData> = [
{
description: 'List Airtable Records',
input: {
workflowData: Helpers.readJsonFileSync('test/nodes/Airtable/workflow.json'),
},
output: {
nodeData: {
Airtable: [[...records]],
},
},
},
];
const nodes: INodeType[] = [new ManualTrigger(), new Airtable()];
const nodeTypes = Helpers.setup(nodes);
for (const testData of tests) {
test(testData.description, async () => {
// execute workflow
const { result } = await executeWorkflow(testData, nodeTypes);
// check if result node data matches expected test data
const resultNodeData = Helpers.getResultNodeData(result, testData);
resultNodeData.forEach(({ nodeName, resultData }) =>
expect(resultData).toEqual(testData.output.nodeData[nodeName]),
);
expect(result.finished).toEqual(true);
});
}
});

View file

@ -0,0 +1,63 @@
{
"meta": {
"instanceId": "104a4d08d8897b8bdeb38aaca515021075e0bd8544c983c2bb8c86e6a8e6081c"
},
"nodes": [
{
"parameters": {},
"id": "f857c37f-36c1-4c9c-9b5f-f6ef49db67e3",
"name": "On clicking 'execute'",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
820,
380
]
},
{
"parameters": {
"operation": "list",
"application": {
"__rl": true,
"value": "https://airtable.com/appIaXXdDqS5ORr4V/tbljyBEdYzCPF0NDh/viwInsMdsxffad0aU",
"mode": "url",
"__regex": "https://airtable.com/([a-zA-Z0-9]{2,})"
},
"table": {
"__rl": true,
"value": "https://airtable.com/appIaXXdDqS5ORr4V/tbljyBEdYzCPF0NDh/viwInsMdsxffad0aU",
"mode": "url",
"__regex": "https://airtable.com/[a-zA-Z0-9]{2,}/([a-zA-Z0-9]{2,})"
},
"additionalOptions": {}
},
"id": "5654d3b3-fe83-4988-889b-94f107d41807",
"name": "Airtable",
"type": "n8n-nodes-base.airtable",
"typeVersion": 1,
"position": [
1020,
380
],
"credentials": {
"airtableApi": {
"id": "20",
"name": "Airtable account"
}
}
}
],
"connections": {
"On clicking 'execute'": {
"main": [
[
{
"node": "Airtable",
"type": "main",
"index": 0
}
]
]
}
}
}

View file

@ -0,0 +1,24 @@
import { WorkflowExecute } from 'n8n-core';
import { createDeferredPromise, INodeTypes, IRun, Workflow } from 'n8n-workflow';
import * as Helpers from './Helpers';
export async function executeWorkflow(testData, nodeTypes: INodeTypes) {
const executionMode = 'manual';
const workflowInstance = new Workflow({
id: 'test',
nodes: testData.input.workflowData.nodes,
connections: testData.input.workflowData.connections,
active: false,
nodeTypes,
});
const waitPromise = await createDeferredPromise<IRun>();
const nodeExecutionOrder: string[] = [];
const additionalData = Helpers.WorkflowExecuteAdditionalData(waitPromise, nodeExecutionOrder);
const workflowExecute = new WorkflowExecute(additionalData, executionMode);
const executionData = await workflowExecute.run(workflowInstance);
const result = await waitPromise.promise();
return { executionData, result, nodeExecutionOrder };
}

View file

@ -0,0 +1,183 @@
import { readFileSync } from 'fs';
import { Credentials } from 'n8n-core';
import {
ICredentialDataDecryptedObject,
ICredentialsHelper,
IDeferredPromise,
IExecuteWorkflowInfo,
IHttpRequestHelper,
IHttpRequestOptions,
ILogger,
INode,
INodeCredentialsDetails,
INodeType,
INodeTypeData,
INodeTypes,
IRun,
ITaskData,
IVersionedNodeType,
IWorkflowBase,
IWorkflowExecuteAdditionalData,
LoggerProxy,
NodeHelpers,
WorkflowHooks,
} from 'n8n-workflow';
import { WorkflowTestData } from './types';
export class CredentialsHelper extends ICredentialsHelper {
async authenticate(
credentials: ICredentialDataDecryptedObject,
typeName: string,
requestParams: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
return requestParams;
}
async preAuthentication(
helpers: IHttpRequestHelper,
credentials: ICredentialDataDecryptedObject,
typeName: string,
node: INode,
credentialsExpired: boolean,
): Promise<ICredentialDataDecryptedObject | undefined> {
return undefined;
}
getParentTypes(name: string): string[] {
return [];
}
async getDecrypted(
nodeCredentials: INodeCredentialsDetails,
type: string,
): Promise<ICredentialDataDecryptedObject> {
return {};
}
async getCredentials(
nodeCredentials: INodeCredentialsDetails,
type: string,
): Promise<Credentials> {
return new Credentials({ id: null, name: '' }, '', [], '');
}
async updateCredentials(
nodeCredentials: INodeCredentialsDetails,
type: string,
data: ICredentialDataDecryptedObject,
): Promise<void> {}
}
export function WorkflowExecuteAdditionalData(
waitPromise: IDeferredPromise<IRun>,
nodeExecutionOrder: string[],
): IWorkflowExecuteAdditionalData {
const hookFunctions = {
nodeExecuteAfter: [
async (nodeName: string, data: ITaskData): Promise<void> => {
nodeExecutionOrder.push(nodeName);
},
],
workflowExecuteAfter: [
async (fullRunData: IRun): Promise<void> => {
waitPromise.resolve(fullRunData);
},
],
};
const workflowData: IWorkflowBase = {
name: '',
createdAt: new Date(),
updatedAt: new Date(),
active: true,
nodes: [],
connections: {},
};
return {
credentialsHelper: new CredentialsHelper(''),
hooks: new WorkflowHooks(hookFunctions, 'trigger', '1', workflowData),
executeWorkflow: async (workflowInfo: IExecuteWorkflowInfo): Promise<any> => {},
sendMessageToUI: (message: string) => {},
restApiUrl: '',
encryptionKey: 'test',
timezone: 'America/New_York',
webhookBaseUrl: 'webhook',
webhookWaitingBaseUrl: 'webhook-waiting',
webhookTestBaseUrl: 'webhook-test',
userId: '123',
};
}
class NodeTypesClass implements INodeTypes {
nodeTypes: INodeTypeData = {};
getByName(nodeType: string): INodeType | IVersionedNodeType {
return this.nodeTypes[nodeType].type;
}
addNode(nodeTypeName: string, nodeType: INodeType | IVersionedNodeType) {
const loadedNode = {
[nodeTypeName]: {
sourcePath: '',
type: nodeType,
},
};
this.nodeTypes = {
...this.nodeTypes,
...loadedNode,
};
//Object.assign(this.nodeTypes, loadedNode);
}
getByNameAndVersion(nodeType: string, version?: number): INodeType {
return NodeHelpers.getVersionedNodeType(this.nodeTypes[nodeType].type, version);
}
}
let nodeTypesInstance: NodeTypesClass | undefined;
export function NodeTypes(): NodeTypesClass {
if (nodeTypesInstance === undefined) {
nodeTypesInstance = new NodeTypesClass();
}
return nodeTypesInstance;
}
export function setup(nodes: INodeType[]) {
const nodeTypes = NodeTypes();
for (const node of nodes) {
nodeTypes.addNode('n8n-nodes-base.' + node.description.name, node);
}
const fakeLogger = {
log: () => {},
debug: () => {},
verbose: () => {},
info: () => {},
warn: () => {},
error: () => {},
} as ILogger;
LoggerProxy.init(fakeLogger);
return nodeTypes;
}
export function getResultNodeData(result: IRun, testData: WorkflowTestData) {
return Object.keys(testData.output.nodeData).map((nodeName) => {
if (result.data.resultData.runData[nodeName] === undefined) {
throw new Error(`Data for node "${nodeName}" is missing!`);
}
const resultData = result.data.resultData.runData[nodeName].map((nodeData) => {
if (nodeData.data === undefined) {
return null;
}
return nodeData.data.main[0]!.map((entry) => entry.json);
});
return {
nodeName,
resultData,
};
});
}
export function readJsonFileSync(path: string) {
return JSON.parse(readFileSync(path, 'utf-8'));
}

View file

@ -0,0 +1,58 @@
import { INodeType } from 'n8n-workflow';
import * as Helpers from '../Helpers';
import { WorkflowTestData } from '../types';
import { ManualTrigger } from '../../../nodes/ManualTrigger/ManualTrigger.node';
import { Set } from '../../../nodes/Set/Set.node';
import { If } from '../../../nodes/If/If.node';
import { NoOp } from '../../../nodes/NoOp/NoOp.node';
import { Code } from '../../../nodes/Code/Code.node';
import { executeWorkflow } from '../ExecuteWorkflow';
describe('Execute If Node', () => {
const tests: Array<WorkflowTestData> = [
{
description: 'should execute IF node true/false boolean',
input: {
workflowData: Helpers.readJsonFileSync('test/nodes/If/workflow.json'),
},
output: {
nodeData: {
'On True': [
[
{
value: true,
},
],
],
'On False': [
[
{
value: false,
},
],
],
},
},
},
];
const nodes: INodeType[] = [new ManualTrigger(), new Code(), new Set(), new If(), new NoOp()];
const nodeTypes = Helpers.setup(nodes);
for (const testData of tests) {
test(testData.description, async () => {
// execute workflow
const { result } = await executeWorkflow(testData, nodeTypes);
// check if result node data matches expected test data
const resultNodeData = Helpers.getResultNodeData(result, testData);
resultNodeData.forEach(({ nodeName, resultData }) =>
expect(resultData).toEqual(testData.output.nodeData[nodeName]),
);
expect(result.finished).toEqual(true);
});
}
});

View file

@ -0,0 +1,115 @@
{
"meta": {
"instanceId": "104a4d08d8897b8bdeb38aaca515021075e0bd8544c983c2bb8c86e6a8e6081c"
},
"nodes": [
{
"parameters": {},
"id": "47003824-c11f-4ae3-80a5-0e1a6d840b21",
"name": "On clicking 'execute'",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
720,
460
]
},
{
"parameters": {
"conditions": {
"boolean": [
{
"value1": "={{ $json[\"value\"] }}",
"value2": true
}
]
}
},
"id": "5420fe7d-a216-44e0-b91f-188ba5b6a340",
"name": "IF",
"type": "n8n-nodes-base.if",
"typeVersion": 1,
"position": [
1160,
460
]
},
{
"parameters": {},
"id": "52d58f32-7faf-4874-afff-e6842bd02430",
"name": "On False",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
1400,
580
]
},
{
"parameters": {},
"id": "9be683ac-cd3f-4ba1-8fa4-052102c3d891",
"name": "On True",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
1400,
340
]
},
{
"parameters": {
"jsCode": "return [\n { value: true },\n { value: false }\n];"
},
"id": "5b3207e7-37e3-43c8-a4da-1ffebb0de134",
"name": "Code",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [
940,
460
]
}
],
"connections": {
"On clicking 'execute'": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
},
"IF": {
"main": [
[
{
"node": "On True",
"type": "main",
"index": 0
}
],
[
{
"node": "On False",
"type": "main",
"index": 0
}
]
]
},
"Code": {
"main": [
[
{
"node": "IF",
"type": "main",
"index": 0
}
]
]
}
}
}

View file

@ -0,0 +1,200 @@
import { INodeType } from 'n8n-workflow';
import * as Helpers from '../Helpers';
import { Start } from '../../../nodes/Start/Start.node';
import { Set } from '../../../nodes/Set/Set.node';
import { executeWorkflow } from '../ExecuteWorkflow';
import { WorkflowTestData } from '../types';
describe('Execute Set Node', () => {
const tests: Array<WorkflowTestData> = [
{
description: 'should set value',
input: {
workflowData: {
nodes: [
{
id: 'uuid-1',
parameters: {},
name: 'Start',
type: 'n8n-nodes-base.start',
typeVersion: 1,
position: [100, 300],
},
{
id: 'uuid-2',
parameters: {
values: {
number: [
{
name: 'value1',
value: 1,
},
],
},
},
name: 'Set',
type: 'n8n-nodes-base.set',
typeVersion: 1,
position: [280, 300],
},
],
connections: {
Start: {
main: [
[
{
node: 'Set',
type: 'main',
index: 0,
},
],
],
},
},
},
},
output: {
nodeExecutionOrder: ['Start', 'Set'],
nodeData: {
Set: [
[
{
value1: 1,
},
],
],
},
},
},
{
description: 'should set multiple values',
input: {
workflowData: {
nodes: [
{
id: 'uuid-1',
parameters: {},
name: 'Start',
type: 'n8n-nodes-base.start',
typeVersion: 1,
position: [100, 300],
},
{
id: 'uuid-2',
parameters: {
values: {
number: [
{
name: 'value1',
value: 1,
},
],
boolean: [
{
name: 'value2',
value: true,
},
],
},
},
name: 'Set',
type: 'n8n-nodes-base.set',
typeVersion: 1,
position: [280, 300],
},
{
id: 'uuid-3',
parameters: {
values: {
number: [
{
name: 'value1',
value: 2,
},
],
boolean: [
{
name: 'value2',
value: false,
},
],
},
},
name: 'Set1',
type: 'n8n-nodes-base.set',
typeVersion: 1,
position: [280, 300],
},
],
connections: {
Start: {
main: [
[
{
node: 'Set',
type: 'main',
index: 0,
},
],
],
},
Set: {
main: [
[
{
node: 'Set1',
type: 'main',
index: 0,
},
],
],
},
},
},
},
output: {
nodeExecutionOrder: ['Start', 'Set'],
nodeData: {
Set: [
[
{
value1: 1,
value2: true,
},
],
],
Set1: [
[
{
value1: 2,
value2: false,
},
],
],
},
},
},
];
const nodes: INodeType[] = [new Start(), new Set()];
const nodeTypes = Helpers.setup(nodes);
for (const testData of tests) {
test(testData.description, async () => {
// execute workflow
const { result } = await executeWorkflow(testData, nodeTypes);
// check if result node data matches expected test data
const resultNodeData = Helpers.getResultNodeData(result, testData);
resultNodeData.forEach(({ nodeName, resultData }) =>
expect(resultData).toEqual(testData.output.nodeData[nodeName]),
);
// Check if other data has correct value
expect(result.finished).toEqual(true);
expect(result.data.executionData!.contextData).toEqual({});
expect(result.data.executionData!.nodeExecutionStack).toEqual([]);
});
}
});

View file

@ -0,0 +1,48 @@
import { INodeType } from 'n8n-workflow';
import * as Helpers from '../Helpers';
import { Start } from '../../../nodes/Start/Start.node';
import { WorkflowTestData } from '../types';
import { executeWorkflow } from '../ExecuteWorkflow';
describe('Execute Start Node', () => {
const tests: Array<WorkflowTestData> = [
{
description: 'should run start node',
input: {
workflowData: {
nodes: [
{
id: 'uuid-1',
parameters: {},
name: 'Start',
type: 'n8n-nodes-base.start',
typeVersion: 1,
position: [100, 300],
},
],
connections: {},
},
},
output: {
nodeExecutionOrder: ['Start'],
nodeData: {},
},
},
];
const nodes: INodeType[] = [new Start()];
const nodeTypes = Helpers.setup(nodes);
for (const testData of tests) {
test(testData.description, async () => {
// execute workflow
const { result, nodeExecutionOrder } = await executeWorkflow(testData, nodeTypes);
// Check if the nodes did execute in the correct order
expect(nodeExecutionOrder).toEqual(testData.output.nodeExecutionOrder);
// Check if other data has correct value
expect(result.finished).toEqual(true);
expect(result.data.executionData!.contextData).toEqual({});
expect(result.data.executionData!.nodeExecutionStack).toEqual([]);
});
}
});

View file

@ -0,0 +1,17 @@
import { INode, IConnections } from 'n8n-workflow';
export interface WorkflowTestData {
description: string;
input: {
workflowData: {
nodes: INode[];
connections: IConnections;
};
};
output: {
nodeExecutionOrder?: string[];
nodeData: {
[key: string]: any[][];
};
};
}