From 5b9c650e55c4cc089b14bf8f5322157a4f70f1f0 Mon Sep 17 00:00:00 2001 From: agobrech <45268029+agobrech@users.noreply.github.com> Date: Mon, 30 Jan 2023 12:20:33 +0100 Subject: [PATCH] test: Add unit testing to nodes (no-changelog) (#4890) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🧪 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 * :zap: removed unused variables * ♻️ Helper to read json files --------- Co-authored-by: Marcus Co-authored-by: Michael Kret --- .../test/nodes/Airtable/Airtable.node.test.ts | 64 ++++++ .../test/nodes/Airtable/workflow.json | 63 ++++++ .../nodes-base/test/nodes/ExecuteWorkflow.ts | 24 +++ packages/nodes-base/test/nodes/Helpers.ts | 183 ++++++++++++++++ .../nodes-base/test/nodes/If/If.node.test.ts | 58 +++++ .../nodes-base/test/nodes/If/workflow.json | 115 ++++++++++ .../nodes-base/test/nodes/Set/SetNode.test.ts | 200 ++++++++++++++++++ .../test/nodes/Start/StartNode.test.ts | 48 +++++ packages/nodes-base/test/nodes/types.ts | 17 ++ 9 files changed, 772 insertions(+) create mode 100644 packages/nodes-base/test/nodes/Airtable/Airtable.node.test.ts create mode 100644 packages/nodes-base/test/nodes/Airtable/workflow.json create mode 100644 packages/nodes-base/test/nodes/ExecuteWorkflow.ts create mode 100644 packages/nodes-base/test/nodes/Helpers.ts create mode 100644 packages/nodes-base/test/nodes/If/If.node.test.ts create mode 100644 packages/nodes-base/test/nodes/If/workflow.json create mode 100644 packages/nodes-base/test/nodes/Set/SetNode.test.ts create mode 100644 packages/nodes-base/test/nodes/Start/StartNode.test.ts create mode 100644 packages/nodes-base/test/nodes/types.ts diff --git a/packages/nodes-base/test/nodes/Airtable/Airtable.node.test.ts b/packages/nodes-base/test/nodes/Airtable/Airtable.node.test.ts new file mode 100644 index 00000000000..cb03648804a --- /dev/null +++ b/packages/nodes-base/test/nodes/Airtable/Airtable.node.test.ts @@ -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 = [ + { + 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); + }); + } +}); diff --git a/packages/nodes-base/test/nodes/Airtable/workflow.json b/packages/nodes-base/test/nodes/Airtable/workflow.json new file mode 100644 index 00000000000..43b959b92e2 --- /dev/null +++ b/packages/nodes-base/test/nodes/Airtable/workflow.json @@ -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 + } + ] + ] + } + } +} diff --git a/packages/nodes-base/test/nodes/ExecuteWorkflow.ts b/packages/nodes-base/test/nodes/ExecuteWorkflow.ts new file mode 100644 index 00000000000..a9b62a35ab7 --- /dev/null +++ b/packages/nodes-base/test/nodes/ExecuteWorkflow.ts @@ -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(); + 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 }; +} diff --git a/packages/nodes-base/test/nodes/Helpers.ts b/packages/nodes-base/test/nodes/Helpers.ts new file mode 100644 index 00000000000..5c5ab95fe05 --- /dev/null +++ b/packages/nodes-base/test/nodes/Helpers.ts @@ -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 { + return requestParams; + } + + async preAuthentication( + helpers: IHttpRequestHelper, + credentials: ICredentialDataDecryptedObject, + typeName: string, + node: INode, + credentialsExpired: boolean, + ): Promise { + return undefined; + } + + getParentTypes(name: string): string[] { + return []; + } + + async getDecrypted( + nodeCredentials: INodeCredentialsDetails, + type: string, + ): Promise { + return {}; + } + + async getCredentials( + nodeCredentials: INodeCredentialsDetails, + type: string, + ): Promise { + return new Credentials({ id: null, name: '' }, '', [], ''); + } + + async updateCredentials( + nodeCredentials: INodeCredentialsDetails, + type: string, + data: ICredentialDataDecryptedObject, + ): Promise {} +} + +export function WorkflowExecuteAdditionalData( + waitPromise: IDeferredPromise, + nodeExecutionOrder: string[], +): IWorkflowExecuteAdditionalData { + const hookFunctions = { + nodeExecuteAfter: [ + async (nodeName: string, data: ITaskData): Promise => { + nodeExecutionOrder.push(nodeName); + }, + ], + workflowExecuteAfter: [ + async (fullRunData: IRun): Promise => { + 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 => {}, + 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')); +} diff --git a/packages/nodes-base/test/nodes/If/If.node.test.ts b/packages/nodes-base/test/nodes/If/If.node.test.ts new file mode 100644 index 00000000000..5c141755476 --- /dev/null +++ b/packages/nodes-base/test/nodes/If/If.node.test.ts @@ -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 = [ + { + 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); + }); + } +}); diff --git a/packages/nodes-base/test/nodes/If/workflow.json b/packages/nodes-base/test/nodes/If/workflow.json new file mode 100644 index 00000000000..9a33888f5a9 --- /dev/null +++ b/packages/nodes-base/test/nodes/If/workflow.json @@ -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 + } + ] + ] + } + } +} diff --git a/packages/nodes-base/test/nodes/Set/SetNode.test.ts b/packages/nodes-base/test/nodes/Set/SetNode.test.ts new file mode 100644 index 00000000000..75407df9bfe --- /dev/null +++ b/packages/nodes-base/test/nodes/Set/SetNode.test.ts @@ -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 = [ + { + 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([]); + }); + } +}); diff --git a/packages/nodes-base/test/nodes/Start/StartNode.test.ts b/packages/nodes-base/test/nodes/Start/StartNode.test.ts new file mode 100644 index 00000000000..5c9ccf8c6cf --- /dev/null +++ b/packages/nodes-base/test/nodes/Start/StartNode.test.ts @@ -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 = [ + { + 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([]); + }); + } +}); diff --git a/packages/nodes-base/test/nodes/types.ts b/packages/nodes-base/test/nodes/types.ts new file mode 100644 index 00000000000..37ffd12aea0 --- /dev/null +++ b/packages/nodes-base/test/nodes/types.ts @@ -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[][]; + }; + }; +}