test: Add testing guidelines for AI tools (no-changelog) (#20161)

This commit is contained in:
yehorkardash 2025-10-02 11:56:03 +00:00 committed by GitHub
parent 6717fb3ba1
commit 09fda978b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 2541 additions and 11 deletions

View file

@ -0,0 +1,36 @@
To write unit tests it is suggested to use AI for
help.
## Approach for standard unit tests
1. **Create the test file**: Decide which node you want to test and create a test file with the corresponding name in the test folder. For example, for a node in `nodeA/v2/NodeAV2.node.ts`, create a test file in `nodeA/v2/test/NodeAV2.test.ts`.
2. **Use AI assistance**: Send this prompt to your AI tool (Cursor, Copilot, Claude, etc.):
```
Using guidelines in @TESTING_PROMPT.md, write tests for @NodeAV2.node.ts in @NodeAV2.node.test.ts
```
Make sure file names after `@` are detected and referenced by your tool.
You can improve the prompt by asking to cover specific test cases.
3. **Review and refine**: Thoroughly review the generated tests, make necessary fixes, and remove redundant tests. __Even if generated by AI, it's still your responsibility to ensure tests are working and reasonable.__
## Approach for workflow unit tests
Workflow unit tests are tests that use user predefined workflows in json format and NodeTestHarness helper that runs the workflow. This is closer to integration tests.
For these tests you can follow the guidelines defined above, but with some modifications:
- Use `TESTING_PROMPT_WORKFLOW.md` instead
- Use a different prompt. It's also important to specify a credentials schema if any credentials are being used, because AI struggles with identifying the schema. You can use the following prompt:
```
I need you to write workflow unit tests for @NodeAV2.node.ts in @NodeAV2.node.test.ts
using guidelines in @TESTING_PROMPT_WORKFLOW.md
You should test each resource and operation in the node
After writing a first test make sure it passes, then write other tests.
To mock credentials use this schema
oauth2: {
scope: '',
oauthTokenData: {
access_token: 'ACCESSTOKEN',
},
}
```

View file

@ -0,0 +1,481 @@
# AI Agent Prompt: Writing Reliable Unit Tests for n8n Nodes
You are an expert AI agent specialized in writing comprehensive, reliable unit tests for n8n nodes in the `@packages/nodes-base` folder. Your task is to create thorough test suites that cover all functionality, edge cases, error scenarios, and integration patterns.
## Core Testing Principles
### 1. Test Structure and Organization
- **File Naming**: Use `.test.ts` extension, place in `test/` or `__tests__/` directories
- **Test Organization**: Group tests by functionality using `describe()` blocks. Test concrete operations and resources.
- **Test Naming**: Use descriptive test names that explain the expected behavior
- **Setup/Teardown**: Use `beforeEach()` and `afterEach()` for consistent test isolation
### 3. Testing guidelines
- **Don't add useless comments** such as "Arrange, Assert, Act" or "Mock something".
- **Always work from within the package directory** when running tests
- **Use `pnpm test`** for running tests
- **Mock all external dependencies** in unit tests
### 4. Essential Test Categories
Always include tests for:
- **Happy Path**: Normal operation with valid inputs
- **Error Handling**: Invalid inputs, API failures, network errors
- **Edge Cases**: Empty data, null values, boundary conditions
- **Parameter Validation**: Required vs optional parameters
- **Binary Data**: File uploads, downloads, data streams
- **Authentication**: Credential handling, token refresh
- **Rate Limiting**: API throttling, retry logic
- **Data Transformation**: Input/output data processing
- **Node Versioning**: Different node type versions
## Mocking Strategies
### 1. Core n8n Interfaces Mocking
```typescript
import { mock, mockDeep } from 'jest-mock-extended';
import type { IExecuteFunctions, IWebhookFunctions, INode } from 'n8n-workflow';
// Standard execute functions mock
const mockExecuteFunctions = mockDeep<IExecuteFunctions>();
// Webhook functions mock
const mockWebhookFunctions = mock<IWebhookFunctions>();
// Node mock
const mockNode = mock<INode>({
id: 'test-node',
name: 'Test Node',
type: 'n8n-nodes-base.test',
typeVersion: 1,
position: [0, 0],
parameters: {},
});
```
### 2. Common Mock Patterns
```typescript
// Input data mocking
mockExecuteFunctions.getInputData.mockReturnValue([
{ json: { test: 'data' } },
{ json: { another: 'item' } }
]);
// Node parameter mocking
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const mockParams = {
'operation': 'create',
'resource': 'user',
'name': 'Test User',
'email': 'test@example.com'
};
return mockParams[paramName];
});
// Credentials mocking
mockExecuteFunctions.getCredentials.mockResolvedValue({
accessToken: 'test-token',
baseUrl: 'https://api.example.com'
});
// Binary data mocking
mockExecuteFunctions.helpers.prepareBinaryData.mockResolvedValue({
data: 'base64data',
mimeType: 'text/plain',
fileName: 'test.txt'
});
```
### 3. External API Mocking
```typescript
// Using jest.spyOn for API functions
const apiRequestSpy = jest.spyOn(GenericFunctions, 'apiRequest');
apiRequestSpy.mockResolvedValue({
id: '123',
name: 'Test Item',
status: 'active'
});
// Using nock for HTTP mocking
import nock from 'nock';
beforeEach(() => {
nock('https://api.example.com')
.get('/users')
.reply(200, { users: [{ id: 1, name: 'John' }] });
});
afterEach(() => {
nock.cleanAll();
});
```
### 4. Database and External Service Mocking
```typescript
// Database mocking
const mockDataTable = mock<IDataStoreProjectService>({
getColumns: jest.fn(),
addColumn: jest.fn(),
updateRow: jest.fn(),
});
// Redis client mocking
const mockClient = mock<RedisClient>();
const createClient = jest.fn().mockReturnValue(mockClient);
jest.mock('redis', () => ({ createClient }));
```
## Test Implementation Patterns
### 1. Basic Node Execution Test
```typescript
describe('Node Execution', () => {
beforeEach(() => {
jest.clearAllMocks();
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
});
it('should execute successfully with valid parameters', async () => {
// Setup mocks
mockExecuteFunctions.getNodeParameter.mockImplementation((param) => {
const params = { operation: 'create', name: 'Test' };
return params[param];
});
apiRequestSpy.mockResolvedValue({ id: '123', name: 'Test' });
// Execute
const result = await node.execute.call(mockExecuteFunctions);
// Assertions
expect(result).toEqual([[
{ json: { id: '123', name: 'Test' }, pairedItem: { item: 0 } }
]]);
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/items', { name: 'Test' });
});
});
```
### 2. Error Handling Tests
```typescript
describe('Error Handling', () => {
it('should throw error for invalid credentials', async () => {
mockExecuteFunctions.getCredentials.mockRejectedValue(
new Error('Invalid credentials')
);
await expect(node.execute.call(mockExecuteFunctions))
.rejects.toThrow('Invalid credentials');
});
it('should handle API errors gracefully', async () => {
apiRequestSpy.mockRejectedValue(new Error('API Error'));
mockExecuteFunctions.continueOnFail.mockReturnValue(true);
const result = await node.execute.call(mockExecuteFunctions);
expect(result[0][0].json).toHaveProperty('error');
});
it('should validate required parameters', async () => {
mockExecuteFunctions.getNodeParameter.mockReturnValue(undefined);
await expect(node.execute.call(mockExecuteFunctions))
.rejects.toThrow(NodeOperationError);
});
});
```
### 3. Binary Data Testing
```typescript
describe('Binary Data Handling', () => {
it('should process binary files correctly', async () => {
const mockBinaryData = {
data: 'base64data',
mimeType: 'image/png',
fileName: 'test.png'
};
mockExecuteFunctions.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
mockExecuteFunctions.helpers.prepareBinaryData.mockResolvedValue(mockBinaryData);
const result = await node.execute.call(mockExecuteFunctions);
expect(result[0][0].binary).toBeDefined();
expect(mockExecuteFunctions.helpers.prepareBinaryData).toHaveBeenCalled();
});
it('should handle file upload operations', async () => {
const fileBuffer = Buffer.from('test file content');
mockExecuteFunctions.helpers.getBinaryStream.mockResolvedValue(fileBuffer);
// Test file upload logic
const result = await node.execute.call(mockExecuteFunctions);
expect(result[0][0].json).toHaveProperty('fileId');
});
});
```
### 4. Webhook Testing
```typescript
describe('Webhook Operations', () => {
it('should handle GET requests', async () => {
const mockRequest = { method: 'GET', query: { id: '123' } };
const mockResponse = { render: jest.fn(), send: jest.fn() };
mockWebhookFunctions.getRequestObject.mockReturnValue(mockRequest);
mockWebhookFunctions.getResponseObject.mockReturnValue(mockResponse);
await node.webhook(mockWebhookFunctions);
expect(mockResponse.render).toHaveBeenCalledWith('template', expect.any(Object));
});
it('should process POST data', async () => {
const mockRequest = {
method: 'POST',
body: { name: 'Test', email: 'test@example.com' }
};
mockWebhookFunctions.getRequestObject.mockReturnValue(mockRequest);
mockWebhookFunctions.getBodyData.mockReturnValue(mockRequest.body);
const result = await node.webhook(mockWebhookFunctions);
expect(result.workflowData).toBeDefined();
expect(result.workflowData[0][0].json).toEqual(mockRequest.body);
});
});
```
### 5. Data Transformation Testing
```typescript
describe('Data Processing', () => {
it('should transform input data correctly', async () => {
const inputData = [
{ json: { firstName: 'John', lastName: 'Doe' } },
{ json: { firstName: 'Jane', lastName: 'Smith' } }
];
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
const result = await node.execute.call(mockExecuteFunctions);
expect(result[0]).toHaveLength(2);
expect(result[0][0].json).toHaveProperty('fullName', 'John Doe');
});
it('should handle empty input gracefully', async () => {
mockExecuteFunctions.getInputData.mockReturnValue([]);
const result = await node.execute.call(mockExecuteFunctions);
expect(result).toEqual([[]]);
});
});
```
## Advanced Testing Patterns
### 1. Using NodeTestHarness for Integration Tests
```typescript
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
describe('Integration Tests', () => {
new NodeTestHarness().setupTests({
credentials: {
'testApi': { accessToken: 'test-token' }
},
nock: {
baseUrl: 'https://api.example.com',
mocks: [{
method: 'get',
path: '/users',
statusCode: 200,
responseBody: { users: [] }
}]
}
});
});
```
### 2. Testing Node Methods and Properties
```typescript
describe('Node Methods', () => {
it('should have required methods defined', () => {
expect(node.methods.credentialTest).toBeDefined();
expect(node.methods.loadOptions).toBeDefined();
expect(node.methods.listSearch).toBeDefined();
});
it('should validate credential test method', async () => {
const mockCredentialTestFunctions = mock<ICredentialTestFunctions>();
mockCredentialTestFunctions.getCredentials.mockResolvedValue({
accessToken: 'test-token'
});
const result = await node.methods.credentialTest.testApiCredentialTest.call(
mockCredentialTestFunctions
);
expect(result).toEqual({ status: 'OK' });
});
});
```
### 3. Testing Load Options
```typescript
describe('Load Options', () => {
it('should load resource options', async () => {
const mockLoadOptionsFunctions = mock<ILoadOptionsFunctions>();
mockLoadOptionsFunctions.getCredentials.mockResolvedValue({
accessToken: 'test-token'
});
apiRequestSpy.mockResolvedValue([
{ id: '1', name: 'Option 1' },
{ id: '2', name: 'Option 2' }
]);
const result = await node.methods.loadOptions.resourceOptions.call(
mockLoadOptionsFunctions
);
expect(result).toEqual([
{ name: 'Option 1', value: '1' },
{ name: 'Option 2', value: '2' }
]);
});
});
```
## Testing Guidelines
### 1. Test Coverage Requirements
- **Minimum 80% code coverage** for all node files
- **100% coverage** for critical error handling paths
- **Test all public methods** and exported functions
- **Cover all conditional branches** and edge cases
### 2. Test Data Management
- Use **realistic test data** that mirrors production scenarios
- Create **reusable test fixtures** for common data patterns
- Use **factory functions** for generating test data
- **Clean up test data** in afterEach hooks
### 3. Assertion Best Practices
```typescript
// Use specific assertions
expect(result).toEqual(expectedData);
expect(mockFunction).toHaveBeenCalledWith(expectedArgs);
expect(mockFunction).toHaveBeenCalledTimes(1);
// Test error messages
expect(() => functionCall()).toThrow('Expected error message');
// Test async operations
await expect(asyncFunction()).resolves.toEqual(expectedResult);
await expect(asyncFunction()).rejects.toThrow(Error);
```
### 4. Performance and Reliability
- **Mock external dependencies** to ensure test reliability
- **Use deterministic test data** for consistent results
- **Test timeout scenarios** for long-running operations
- **Validate memory usage** for large data processing
### 5. Documentation and Maintenance
- **Document complex test scenarios** with inline comments
- **Use descriptive test names** that explain the test purpose
- **Group related tests** logically in describe blocks
- **Keep tests independent** - no test should depend on another
## Common Anti-Patterns to Avoid
1. **Don't test implementation details** - focus on behavior
2. **Don't use real external APIs** in unit tests
3. **Don't skip error handling tests** - they're critical
4. **Don't use hardcoded values** - use constants or factories
5. **Don't ignore async operations** - always await promises
6. **Don't test multiple concerns** in a single test case
## Example Complete Test Suite
```typescript
import { mock, mockDeep } from 'jest-mock-extended';
import type { IExecuteFunctions, INode } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { TestNode } from '../TestNode';
import * as GenericFunctions from '../GenericFunctions';
describe('TestNode', () => {
let node: TestNode;
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
const apiRequestSpy = jest.spyOn(GenericFunctions, 'apiRequest');
beforeEach(() => {
node = new TestNode();
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
jest.clearAllMocks();
});
afterEach(() => {
jest.resetAllMocks();
});
describe('execute', () => {
beforeEach(() => {
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
mockExecuteFunctions.getNode.mockReturnValue({
id: 'test',
name: 'Test Node',
type: 'n8n-nodes-base.test',
typeVersion: 1,
position: [0, 0],
parameters: {}
});
});
describe('successful execution', () => {
it('should process data correctly', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((param) => {
const params = { operation: 'create', name: 'Test Item' };
return params[param];
});
apiRequestSpy.mockResolvedValue({ id: '123', name: 'Test Item' });
const result = await node.execute.call(mockExecuteFunctions);
expect(result).toEqual([[
{ json: { id: '123', name: 'Test Item' }, pairedItem: { item: 0 } }
]]);
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/items', { name: 'Test Item' });
});
});
describe('error handling', () => {
it('should throw error for missing required parameter', async () => {
mockExecuteFunctions.getNodeParameter.mockReturnValue(undefined);
await expect(node.execute.call(mockExecuteFunctions))
.rejects.toThrow(NodeOperationError);
});
it('should handle API errors with continueOnFail', async () => {
mockExecuteFunctions.getNodeParameter.mockReturnValue('create');
mockExecuteFunctions.continueOnFail.mockReturnValue(true);
apiRequestSpy.mockRejectedValue(new Error('API Error'));
const result = await node.execute.call(mockExecuteFunctions);
expect(result[0][0].json).toHaveProperty('error', 'API Error');
});
});
});
});
```

View file

@ -0,0 +1,562 @@
# AI Agent Prompt: Writing Reliable Workflow Unit Tests for n8n Nodes
You are an expert AI agent specialized in writing comprehensive, reliable workflow unit tests for n8n nodes in the `@packages/nodes-base` folder. Your task is to create thorough test suites that use `.workflow.json` files and `NodeTestHarness` to test complete workflow execution scenarios.
## Core Guidelines
- **Don't add useless comments** such as "Arrange, Assert, Act" or "Mock something"
- **Always work from within the package directory** when running tests
- **Use `pnpm test`** for running tests. Example: `cd packages/nodes-base/ && pnpm test TestFileName
## Essential Test Structure
### Basic Test Setup
```typescript
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import nock from 'nock';
describe('NodeName', () => {
describe('Run Test Workflow', () => {
beforeAll(() => {
const mock = nock('https://api.example.com');
mock.post('/endpoint').reply(200, mockResponse);
mock.get('/data').reply(200, mockData);
});
new NodeTestHarness().setupTests();
});
});
```
### Advanced Test with Credentials
```typescript
describe('NodeName', () => {
const credentials = {
nodeApi: {
accessToken: 'test-token',
baseUrl: 'https://api.example.com',
},
};
describe('Run Test Workflow', () => {
beforeAll(() => {
const mock = nock(credentials.nodeApi.baseUrl);
mock.post('/users').reply(200, userCreateResponse);
});
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['workflow.json'],
assertBinaryData: true
});
});
});
```
## Workflow JSON Structure
### Basic Workflow Template
```json
{
"name": "NodeName Test Workflow",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute Workflow'"
},
{
"parameters": {
"operation": "create",
"resource": "user",
"name": "Test User",
"email": "test@example.com"
},
"type": "n8n-nodes-base.nodeName",
"typeVersion": 1,
"position": [200, 0],
"id": "node-id",
"name": "Node Operation",
"credentials": {
"nodeApi": {
"id": "credential-id",
"name": "Test Credentials"
}
}
}
],
"pinData": {
"Node Operation": [
{
"json": {
"id": "123",
"name": "Test User",
"email": "test@example.com",
"status": "active"
}
}
]
},
"connections": {
"When clicking 'Execute Workflow'": {
"main": [
[
{
"node": "Node Operation",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
}
}
```
## Node Parameter Types
### Basic Parameters
```json
{
"displayName": "Parameter Name",
"name": "parameterName",
"type": "string|number|boolean|options",
"default": "defaultValue",
"required": true
}
```
### Collection Parameters
```json
{
"displayName": "Additional Fields",
"name": "additionalFields",
"type": "collection",
"default": {},
"options": [
{
"displayName": "Custom Field",
"name": "customField",
"type": "string",
"default": ""
}
]
}
```
### Fixed Collection Parameters
```json
{
"displayName": "Fields to Set",
"name": "fields",
"type": "fixedCollection",
"typeOptions": {
"multipleValues": true
},
"options": [
{
"name": "values",
"displayName": "Values",
"values": [
{
"displayName": "Name",
"name": "name",
"type": "string",
"default": ""
}
]
}
]
}
```
## HTTP Mocking with Nock
### Basic API Mocking
```typescript
beforeAll(() => {
const mock = nock('https://api.example.com');
// Mock GET request
mock.get('/users')
.reply(200, {
users: [
{ id: 1, name: 'User 1' },
{ id: 2, name: 'User 2' }
]
});
// Mock POST request
mock.post('/users', {
name: 'Test User',
email: 'test@example.com'
})
.reply(201, {
id: 123,
name: 'Test User',
email: 'test@example.com',
status: 'active'
});
// Mock error responses
mock.get('/error-endpoint')
.reply(500, { error: 'Internal Server Error' });
});
```
### Advanced Mocking
```typescript
beforeAll(() => {
const mock = nock('https://api.example.com');
// Mock with headers
mock.get('/protected-endpoint')
.matchHeader('Authorization', 'Bearer test-token')
.reply(200, { data: 'protected' });
// Mock with query parameters
mock.get('/search')
.query({ q: 'test', limit: 10 })
.reply(200, { results: [] });
// Mock with request body validation
mock.post('/validate', (body) => {
return body.name && body.email;
})
.reply(200, { valid: true });
});
```
### Credentials Mocking
Some workflows require credentials for NodeHarness. If the execution result of a test is null it means that workflow has invalid inputs. Very often it's misconfigured credentials.
```typescript
const credentials = {
googleAnalyticsOAuth2: {
scope: '',
oauthTokenData: {
access_token: 'ACCESSTOKEN',
},
}
}
```
```typescript
const credentials = {
aws: {
region: 'eu-central-1',
accessKeyId: 'test',
secretAccessKey: 'test',
},
}
```
```typescript
wordpressApi: {
url: 'https://myblog.com',
allowUnauthorizedCerts: false,
username: 'nodeqa',
password: 'fake-password',
},
```
```typescript
const credentials = {
telegramApi: {
accessToken: 'testToken',
baseUrl: 'https://api.telegram.org',
},
};
```
## Binary Data Testing
### Binary Data Workflow
```json
{
"pinData": {
"Upload Node": [
{
"json": {
"fileId": "123",
"fileName": "test.txt",
"fileSize": 1024,
"mimeType": "text/plain"
},
"binary": {
"data": {
"data": "base64data",
"mimeType": "text/plain",
"fileName": "test.txt"
}
}
}
]
}
}
```
### Binary Data Test Setup
```typescript
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['binary.workflow.json'],
assertBinaryData: true
});
```
## Error Scenario Testing
### Error Workflow
```json
{
"pinData": {
"Error Node": [
{
"json": {
"error": "User not found",
"message": "Invalid request",
"code": 404
}
}
]
}
}
```
### Error Mock Setup
```typescript
beforeAll(() => {
const mock = nock('https://api.example.com');
mock.get('/users/nonexistent')
.reply(404, { error: 'User not found' });
});
```
## Advanced Workflow Patterns
### Switch Node Testing
```json
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"conditions": [
{
"leftValue": "={{ $json.status }}",
"rightValue": "active",
"operator": {
"type": "string",
"operation": "equals"
}
}
]
},
"outputKey": "Active Users"
}
]
}
}
}
```
### Set Node Testing
```json
{
"parameters": {
"fields": {
"values": [
{
"name": "processed",
"stringValue": "true"
},
{
"name": "timestamp",
"stringValue": "={{ new Date().toISOString() }}"
}
]
}
}
}
```
### Code Node Testing
```json
{
"parameters": {
"jsCode": "return [\n { id: 1, name: 'Item 1' },\n { id: 2, name: 'Item 2' }\n]"
}
}
```
## Credential Types
### API Key Credentials
```json
{
"credentials": {
"openAiApi": {
"id": "openai-cred-id",
"name": "OpenAI API Key"
}
}
}
```
### OAuth2 Credentials
```json
{
"credentials": {
"slackOAuth2Api": {
"id": "slack-oauth-id",
"name": "Slack OAuth2"
}
}
}
```
### Database Credentials
```json
{
"credentials": {
"postgres": {
"id": "postgres-cred-id",
"name": "PostgreSQL Database"
}
}
}
```
## Essential Test Categories
Always include tests for:
- **Complete Workflow Execution**: End-to-end workflow scenarios
- **API Integration**: External API calls with proper mocking
- **Data Flow**: Input data transformation through multiple nodes
- **Error Scenarios**: Workflow execution with API failures
- **Binary Data Handling**: File uploads, downloads, and processing
- **Authentication**: Credential handling across workflow execution
- **Node Interactions**: Multiple nodes working together
- **Conditional Logic**: Switch nodes, conditional execution paths
## Best Practices
### Workflow JSON Design
- **Trigger Node**: Always start with `n8n-nodes-base.manualTrigger`
- **Node Parameters**: Include all required parameters with realistic values
- **Node Connections**: Define clear data flow between nodes
- **Pin Data**: Provide expected outputs for validation
- **Credentials**: Reference appropriate credential types
### Mock Setup
- **Mock all external API calls** to ensure test reliability
- **Use realistic response data** that matches expected outputs
- **Test both success and error scenarios**
- **Include proper HTTP status codes**
- **Clean up mocks** between test runs
### Test Organization
- **Group related workflows** in the same test file
- **Use descriptive test names** that explain the scenario
- **Keep workflow JSON files** in the same directory as test files
- **Use consistent naming conventions** for workflow files
## Common Anti-Patterns to Avoid
1. **Don't use real external APIs** in workflow tests
2. **Don't skip pinData** - it's essential for output validation
3. **Don't forget to mock all API calls** - missing mocks cause test failures
4. **Don't use hardcoded credentials** - use test credentials
5. **Don't ignore error scenarios** - test both success and failure cases
6. **Don't create overly complex workflows** - keep them focused and testable
7. **Don't forget to clean up nock mocks** between tests
8. **Don't use production data** in test workflows
9. **Don't skip credential testing** - test authentication flows
10. **Don't ignore node version differences** - test multiple node versions
## Complete Example
```typescript
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import nock from 'nock';
describe('NodeName', () => {
const credentials = {
nodeApi: {
accessToken: 'test-token',
baseUrl: 'https://api.example.com',
},
};
describe('Basic Operations', () => {
beforeAll(() => {
const mock = nock(credentials.nodeApi.baseUrl);
mock.get('/users')
.reply(200, {
users: [
{ id: 1, name: 'User 1', email: 'user1@example.com' },
{ id: 2, name: 'User 2', email: 'user2@example.com' }
]
});
mock.post('/users', {
name: 'Test User',
email: 'test@example.com'
})
.reply(201, {
id: 123,
name: 'Test User',
email: 'test@example.com',
status: 'active'
});
});
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['basic.workflow.json']
});
});
describe('Error Handling', () => {
beforeAll(() => {
const mock = nock(credentials.nodeApi.baseUrl);
mock.get('/users')
.reply(500, { error: 'Internal Server Error' });
});
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['error.workflow.json']
});
});
describe('Binary Data Operations', () => {
beforeAll(() => {
const mock = nock(credentials.nodeApi.baseUrl);
mock.post('/upload')
.reply(200, {
fileId: '123',
fileName: 'test.txt',
fileSize: 1024,
mimeType: 'text/plain'
});
});
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['binary.workflow.json'],
assertBinaryData: true
});
});
});
```

View file

@ -0,0 +1,104 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import nock from 'nock';
describe('GoogleAnalyticsV2', () => {
const credentials = {
googleAnalyticsOAuth2: {
scope: '',
oauthTokenData: {
access_token: 'ACCESSTOKEN',
},
},
};
describe('Report Resource - GA4 Get Operation', () => {
beforeAll(() => {
const mock = nock('https://analyticsdata.googleapis.com');
mock.post('/v1beta/properties/123456789:runReport').reply(200, {
dimensionHeaders: [{ name: 'date' }],
metricHeaders: [{ name: 'totalUsers', type: 'TYPE_INTEGER' }],
rows: [
{
dimensionValues: [{ value: '20240101' }],
metricValues: [{ value: '100' }],
},
],
rowCount: 1,
});
});
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['report-ga4-get.workflow.json'],
});
});
describe('Report Resource - Universal Analytics Get Operation', () => {
beforeAll(() => {
const mock = nock('https://analyticsreporting.googleapis.com');
mock.post('/v4/reports:batchGet').reply(200, {
reports: [
{
columnHeader: {
dimensions: ['ga:date'],
metricHeader: {
metricHeaderEntries: [
{ name: 'ga:users', type: 'INTEGER' },
{ name: 'ga:sessions', type: 'INTEGER' },
],
},
},
data: {
rows: [
{
dimensions: ['20240101'],
metrics: [{ values: ['100', '50'] }],
},
],
},
},
],
});
});
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['report-universal-get.workflow.json'],
});
});
describe('UserActivity Resource - Search Operation', () => {
beforeAll(() => {
const mock = nock('https://analyticsreporting.googleapis.com');
mock.post('/v4/userActivity:search').reply(200, {
sessions: [
{
sessionId: 'session123',
deviceCategory: 'desktop',
platform: 'web',
dataSource: 'web',
activities: [
{
activityTime: '2024-01-01T10:00:00Z',
source: 'web',
medium: 'organic',
channelGrouping: 'Organic Search',
campaign: 'spring_sale',
keyword: 'analytics',
hostname: 'example.com',
},
],
},
],
});
});
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['useractivity-search.workflow.json'],
});
});
});

View file

@ -0,0 +1,80 @@
{
"name": "Google Analytics GA4 Report Test Workflow",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute Workflow'"
},
{
"parameters": {
"resource": "report",
"operation": "get",
"propertyType": "ga4",
"propertyId": {
"mode": "id",
"value": "123456789"
},
"dateRange": "last7days",
"metricsGA4": {
"metricValues": [
{
"listName": "totalUsers"
}
]
},
"dimensionsGA4": {
"dimensionValues": [
{
"listName": "date"
}
]
},
"returnAll": false,
"limit": 10,
"simple": true
},
"type": "n8n-nodes-base.googleAnalytics",
"typeVersion": 2,
"position": [200, 0],
"id": "ga4-report-node",
"name": "GA4 Report",
"credentials": {
"googleAnalyticsOAuth2": {
"id": "ga-oauth-cred-id",
"name": "Google Analytics OAuth2"
}
}
}
],
"pinData": {
"GA4 Report": [
{
"json": {
"date": "20240101",
"totalUsers": "100"
}
}
]
},
"connections": {
"When clicking 'Execute Workflow'": {
"main": [
[
{
"node": "GA4 Report",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
}
}

View file

@ -0,0 +1,83 @@
{
"name": "Google Analytics Universal Report Test Workflow",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute Workflow'"
},
{
"parameters": {
"resource": "report",
"operation": "get",
"propertyType": "universal",
"viewId": {
"mode": "id",
"value": "123456789"
},
"dateRange": "last7days",
"metricsUA": {
"metricValues": [
{
"listName": "ga:users"
},
{
"listName": "ga:sessions"
}
]
},
"dimensionsUA": {
"dimensionValues": [
{
"listName": "ga:date"
}
]
},
"returnAll": false,
"limit": 10
},
"type": "n8n-nodes-base.googleAnalytics",
"typeVersion": 2,
"position": [200, 0],
"id": "universal-report-node",
"name": "Universal Report",
"credentials": {
"googleAnalyticsOAuth2": {
"id": "ga-oauth-cred-id",
"name": "Google Analytics OAuth2"
}
}
}
],
"pinData": {
"Universal Report": [
{
"json": {
"ga:date": "20240101",
"ga:users": "100",
"ga:sessions": "50"
}
}
]
},
"connections": {
"When clicking 'Execute Workflow'": {
"main": [
[
{
"node": "Universal Report",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
}
}

View file

@ -0,0 +1,77 @@
{
"name": "Google Analytics UserActivity Search Test Workflow",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute Workflow'"
},
{
"parameters": {
"resource": "userActivity",
"operation": "search",
"viewId": "123456789",
"userId": "user123",
"returnAll": false,
"limit": 100,
"additionalFields": {
"activityTypes": ["PAGEVIEW", "EVENT"]
}
},
"type": "n8n-nodes-base.googleAnalytics",
"typeVersion": 2,
"position": [200, 0],
"id": "useractivity-search-node",
"name": "UserActivity Search",
"credentials": {
"googleAnalyticsOAuth2": {
"id": "ga-oauth-cred-id",
"name": "Google Analytics OAuth2"
}
}
}
],
"pinData": {
"UserActivity Search": [
{
"json": {
"sessionId": "session123",
"deviceCategory": "desktop",
"platform": "web",
"dataSource": "web",
"activities": [
{
"activityTime": "2024-01-01T10:00:00Z",
"source": "web",
"medium": "organic",
"channelGrouping": "Organic Search",
"campaign": "spring_sale",
"keyword": "analytics",
"hostname": "example.com"
}
]
}
}
]
},
"connections": {
"When clicking 'Execute Workflow'": {
"main": [
[
{
"node": "UserActivity Search",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
}
}

View file

@ -0,0 +1,594 @@
import { mock, mockDeep } from 'jest-mock-extended';
import type { IExecuteFunctions, INode } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { execute } from '../../../../v2/actions/sheet/append.operation';
import type { GoogleSheet } from '../../../../v2/helpers/GoogleSheet';
import * as GoogleSheetsUtils from '../../../../v2/helpers/GoogleSheets.utils';
jest.mock('../../../../v2/helpers/GoogleSheets.utils', () => ({
autoMapInputData: jest.fn(),
mapFields: jest.fn(),
checkForSchemaChanges: jest.fn(),
cellFormatDefault: jest.fn(),
}));
describe('Google Sheets Append Operation', () => {
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
let mockSheet: jest.Mocked<GoogleSheet>;
let mockNode: jest.Mocked<INode>;
beforeEach(() => {
jest.clearAllMocks();
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
mockNode = mock<INode>({
id: 'test-node',
name: 'Google Sheets Append',
type: 'n8n-nodes-base.googleSheets',
typeVersion: 3,
position: [0, 0],
parameters: {},
});
mockSheet = mock<GoogleSheet>();
mockSheet.getData = jest.fn();
mockSheet.appendSheetData = jest.fn();
mockSheet.appendEmptyRowsOrColumns = jest.fn();
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
mockExecuteFunctions.getInputData.mockReturnValue([
{ json: { name: 'John', email: 'john@example.com' } },
{ json: { name: 'Jane', email: 'jane@example.com' } },
]);
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const mockParams: Record<string, any> = {
dataMode: 'defineBelow',
'columns.mappingMode': 'defineBelow',
options: {},
'columns.schema': [],
};
return mockParams[paramName];
});
(GoogleSheetsUtils.autoMapInputData as jest.Mock).mockResolvedValue([
{ name: 'John', email: 'john@example.com' },
{ name: 'Jane', email: 'jane@example.com' },
]);
(GoogleSheetsUtils.mapFields as jest.Mock).mockReturnValue([
{ name: 'John', email: 'john@example.com' },
{ name: 'Jane', email: 'jane@example.com' },
]);
(GoogleSheetsUtils.cellFormatDefault as jest.Mock).mockReturnValue('USER_ENTERED');
});
afterEach(() => {
jest.resetAllMocks();
});
describe('Basic Execution', () => {
it('should execute successfully with valid parameters', async () => {
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
const result = await execute.call(
mockExecuteFunctions,
mockSheet,
'Sheet1!A1:B2',
'sheet123',
);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
json: { name: 'John', email: 'john@example.com' },
pairedItem: { item: 0 },
});
expect(result[1]).toEqual({
json: { name: 'Jane', email: 'jane@example.com' },
pairedItem: { item: 1 },
});
expect(mockSheet.appendSheetData).toHaveBeenCalledWith({
inputData: [
{ name: 'John', email: 'john@example.com' },
{ name: 'Jane', email: 'jane@example.com' },
],
range: 'Sheet1!A1:B2',
keyRowIndex: 1,
valueInputMode: 'USER_ENTERED',
lastRow: 3,
});
});
it('should return empty array when no input data', async () => {
mockExecuteFunctions.getInputData.mockReturnValue([]);
const result = await execute.call(
mockExecuteFunctions,
mockSheet,
'Sheet1!A1:B2',
'sheet123',
);
expect(result).toEqual([]);
expect(mockSheet.appendSheetData).not.toHaveBeenCalled();
});
it('should return empty array when dataMode is nothing', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'dataMode' || paramName === 'columns.mappingMode') {
return 'nothing';
}
return {};
});
const result = await execute.call(
mockExecuteFunctions,
mockSheet,
'Sheet1!A1:B2',
'sheet123',
);
expect(result).toEqual([]);
expect(mockSheet.appendSheetData).not.toHaveBeenCalled();
});
});
describe('Data Mode Handling', () => {
it('should use autoMapInputData mode when sheet is empty', async () => {
mockSheet.getData.mockResolvedValue([]);
mockSheet.appendSheetData.mockResolvedValue([]);
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
expect(GoogleSheetsUtils.autoMapInputData).toHaveBeenCalledWith(
'Sheet1!A1:B2',
mockSheet,
[
{ json: { name: 'John', email: 'john@example.com' }, pairedItem: { item: 0 } },
{ json: { name: 'Jane', email: 'jane@example.com' }, pairedItem: { item: 1 } },
],
{},
);
});
it('should use defineBelow mode when sheet has data', async () => {
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
expect(GoogleSheetsUtils.mapFields).toHaveBeenCalledWith(2);
});
it('should handle autoMapInputData mode explicitly', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'dataMode' || paramName === 'columns.mappingMode') {
return 'autoMapInputData';
}
return {};
});
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
expect(GoogleSheetsUtils.autoMapInputData).toHaveBeenCalled();
});
});
describe('Node Version Handling', () => {
it('should use dataMode parameter for node version < 4', async () => {
mockNode.typeVersion = 3;
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'dataMode') return 'autoMapInputData';
return {};
});
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
expect(GoogleSheetsUtils.autoMapInputData).toHaveBeenCalled();
});
it('should use columns.mappingMode parameter for node version >= 4', async () => {
mockNode.typeVersion = 4;
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'columns.mappingMode') return 'autoMapInputData';
return {};
});
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
expect(GoogleSheetsUtils.autoMapInputData).toHaveBeenCalled();
});
});
describe('Options and Configuration', () => {
it('should handle custom header row', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'options') {
return {
locationDefine: {
values: { headerRow: 2 },
},
};
}
return {};
});
mockSheet.getData.mockResolvedValue([
['Header1', 'Header2'],
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
expect(mockSheet.getData).toHaveBeenCalledWith('Sheet1!A1:B2', 'FORMATTED_VALUE');
});
it('should handle useAppend option', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'options') {
return { useAppend: true };
}
return {};
});
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
expect(mockSheet.appendSheetData).toHaveBeenCalledWith({
inputData: [
{ name: 'John', email: 'john@example.com' },
{ name: 'Jane', email: 'jane@example.com' },
],
range: 'Sheet1!A1:B2',
keyRowIndex: 1,
valueInputMode: 'USER_ENTERED',
useAppend: true,
});
});
it('should handle cell format option', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'options') {
return { cellFormat: 'RAW' };
}
return {};
});
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
expect(mockSheet.appendSheetData).toHaveBeenCalledWith(
expect.objectContaining({
valueInputMode: 'RAW',
}),
);
});
});
describe('Error Handling', () => {
it('should throw error when column names cannot be retrieved (node version >= 4.4)', async () => {
mockNode.typeVersion = 4.4;
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'columns.mappingMode') return 'defineBelow';
if (paramName === 'columns.schema') return [{ id: 'name' }, { id: 'email' }];
return {};
});
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
// Mock checkForSchemaChanges to throw error
(GoogleSheetsUtils.checkForSchemaChanges as jest.Mock).mockImplementation(() => {
throw new NodeOperationError(mockNode, 'Column names were updated');
});
await expect(
execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123'),
).rejects.toThrow(NodeOperationError);
});
it('should throw error when header row is out of bounds', async () => {
mockNode.typeVersion = 4.4;
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'columns.mappingMode') return 'defineBelow';
if (paramName === 'columns.schema') return [{ id: 'name' }, { id: 'email' }];
if (paramName === 'options') {
return {
locationDefine: {
values: { headerRow: 5 },
},
};
}
return {};
});
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
await expect(
execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123'),
).rejects.toThrow('Could not retrieve the column names from row 5');
});
it('should handle empty input data gracefully', async () => {
(GoogleSheetsUtils.mapFields as jest.Mock).mockReturnValue([]);
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
(GoogleSheetsUtils.autoMapInputData as jest.Mock).mockResolvedValue([]);
const result = await execute.call(
mockExecuteFunctions,
mockSheet,
'Sheet1!A1:B2',
'sheet123',
);
expect(result).toEqual([]);
expect(mockSheet.appendSheetData).not.toHaveBeenCalled();
});
});
describe('Return Data Formatting', () => {
it('should return original items for node version < 4', async () => {
mockNode.typeVersion = 3;
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
const result = await execute.call(
mockExecuteFunctions,
mockSheet,
'Sheet1!A1:B2',
'sheet123',
);
expect(result).toHaveLength(2);
expect(result[0].json).toEqual({ name: 'John', email: 'john@example.com' });
expect(result[0].pairedItem).toEqual({ item: 0 });
});
it('should return mapped data for node version >= 4 with defineBelow mode', async () => {
mockNode.typeVersion = 4;
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'columns.mappingMode') return 'defineBelow';
return {};
});
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
const result = await execute.call(
mockExecuteFunctions,
mockSheet,
'Sheet1!A1:B2',
'sheet123',
);
expect(result).toHaveLength(2);
expect(result[0].json).toEqual({ name: 'John', email: 'john@example.com' });
expect(result[0].pairedItem).toEqual({ item: 0 });
});
it('should return original items for autoMapInputData mode regardless of node version', async () => {
mockNode.typeVersion = 4;
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'columns.mappingMode') return 'autoMapInputData';
return {};
});
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
const result = await execute.call(
mockExecuteFunctions,
mockSheet,
'Sheet1!A1:B2',
'sheet123',
);
expect(result).toHaveLength(2);
expect(result[0].json).toEqual({ name: 'John', email: 'john@example.com' });
expect(result[0].pairedItem).toEqual({ item: 0 });
});
});
describe('Sheet Data Processing', () => {
it('should calculate lastRow correctly when sheet has data', async () => {
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
['Jane', 'jane@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
expect(mockSheet.appendEmptyRowsOrColumns).toHaveBeenCalledWith('sheet123', 1, 0);
expect(mockSheet.appendSheetData).toHaveBeenCalledWith({
inputData: [
{ name: 'John', email: 'john@example.com' },
{ name: 'Jane', email: 'jane@example.com' },
],
range: 'Sheet1!A1:B2',
keyRowIndex: 1,
valueInputMode: 'USER_ENTERED',
lastRow: 4,
});
});
it('should handle empty sheet data for lastRow calculation', async () => {
mockSheet.getData.mockResolvedValue([]);
mockSheet.appendSheetData.mockResolvedValue([]);
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
expect(mockSheet.appendEmptyRowsOrColumns).toHaveBeenCalledWith('sheet123', 1, 0);
expect(mockSheet.appendSheetData).toHaveBeenCalledWith({
inputData: [
{ name: 'John', email: 'john@example.com' },
{ name: 'Jane', email: 'jane@example.com' },
],
range: 'Sheet1!A1:B2',
keyRowIndex: 1,
valueInputMode: 'USER_ENTERED',
lastRow: 1,
});
});
});
describe('Integration with GoogleSheets Utils', () => {
it('should call autoMapInputData with correct parameters', async () => {
mockSheet.getData.mockResolvedValue([]);
mockSheet.appendSheetData.mockResolvedValue([]);
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
expect(GoogleSheetsUtils.autoMapInputData).toHaveBeenCalledWith(
'Sheet1!A1:B2',
mockSheet,
[
{ json: { name: 'John', email: 'john@example.com' }, pairedItem: { item: 0 } },
{ json: { name: 'Jane', email: 'jane@example.com' }, pairedItem: { item: 1 } },
],
{},
);
});
it('should call mapFields with correct input size', async () => {
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
expect(GoogleSheetsUtils.mapFields).toHaveBeenCalledWith(2);
});
it('should call checkForSchemaChanges for node version >= 4.4', async () => {
mockNode.typeVersion = 4.4;
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'columns.mappingMode') return 'defineBelow';
if (paramName === 'columns.schema') return [{ id: 'name' }, { id: 'email' }];
return {};
});
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
expect(GoogleSheetsUtils.checkForSchemaChanges).toHaveBeenCalledWith(
mockNode,
['Name', 'Email'],
[{ id: 'name' }, { id: 'email' }],
);
});
});
describe('Edge Cases', () => {
it('should handle single item input', async () => {
mockExecuteFunctions.getInputData.mockReturnValue([
{ json: { name: 'John', email: 'john@example.com' } },
]);
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
const result = await execute.call(
mockExecuteFunctions,
mockSheet,
'Sheet1!A1:B2',
'sheet123',
);
expect(result).toHaveLength(1);
expect(result[0].json).toEqual({ name: 'John', email: 'john@example.com' });
});
it('should handle large input data', async () => {
const largeInputData = Array.from({ length: 100 }, (_, i) => ({
json: { name: `User${i}`, email: `user${i}@example.com` },
}));
mockExecuteFunctions.getInputData.mockReturnValue(largeInputData);
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
const result = await execute.call(
mockExecuteFunctions,
mockSheet,
'Sheet1!A1:B2',
'sheet123',
);
expect(result).toHaveLength(100);
expect(mockSheet.appendSheetData).toHaveBeenCalled();
});
it('should handle undefined options gracefully', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'options') return {};
return {};
});
mockSheet.getData.mockResolvedValue([
['Name', 'Email'],
['John', 'john@example.com'],
]);
mockSheet.appendSheetData.mockResolvedValue([]);
await expect(
execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123'),
).resolves.not.toThrow();
});
});
});

View file

@ -1,19 +1,33 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import type { INodeTypeBaseDescription } from 'n8n-workflow';
import { mockDeep } from 'jest-mock-extended';
import type {
IExecuteFunctions,
ILoadOptionsFunctions,
INodeTypeBaseDescription,
} from 'n8n-workflow';
import { NodeOperationError, ApplicationError } from 'n8n-workflow';
import { SwitchV3 } from '../SwitchV3.node';
describe('Execute Switch Node', () => {
new NodeTestHarness().setupTests();
describe('SwitchV3 Node', () => {
let switchNode: SwitchV3;
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
let mockLoadOptionsFunctions: jest.Mocked<ILoadOptionsFunctions>;
const baseDescription: INodeTypeBaseDescription = {
displayName: 'Switch',
name: 'n8n-nodes-base.switch',
group: ['transform'],
description: 'Route items to different outputs',
};
beforeEach(() => {
switchNode = new SwitchV3(baseDescription);
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
mockLoadOptionsFunctions = mockDeep<ILoadOptionsFunctions>();
jest.clearAllMocks();
});
describe('Version-specific behavior', () => {
const baseDescription: INodeTypeBaseDescription = {
displayName: 'Switch',
name: 'n8n-nodes-base.switch',
group: ['transform'],
description: 'Route items to different outputs',
};
it('should have two numberOutputs parameters with different version conditions', () => {
const switchNode = new SwitchV3(baseDescription);
const numberOutputsParams = switchNode.description.properties.filter(
@ -54,4 +68,503 @@ describe('Execute Switch Node', () => {
expect(switchNode.description.version).toContain(3.3);
});
});
describe('Expression Mode Execution', () => {
beforeEach(() => {
mockExecuteFunctions.getNode.mockReturnValue({
id: 'switch-node',
name: 'Switch',
type: 'n8n-nodes-base.switch',
typeVersion: 3.3,
position: [0, 0],
parameters: {},
});
});
it('should route items to correct output in expression mode', async () => {
const inputData = [{ json: { value: 1 } }, { json: { value: 2 } }, { json: { value: 3 } }];
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
mockExecuteFunctions.getNodeParameter.mockImplementation(
(paramName: string, itemIndex: number) => {
const params: Record<string, any> = {
mode: 'expression',
numberOutputs: 3,
output: itemIndex % 3,
};
return params[paramName];
},
);
const result = await switchNode.execute.call(mockExecuteFunctions);
expect(result).toHaveLength(3);
expect(result[0]).toHaveLength(1);
expect(result[0][0].json).toEqual({ value: 1 });
expect(result[1]).toHaveLength(1);
expect(result[1][0].json).toEqual({ value: 2 });
expect(result[2]).toHaveLength(1);
expect(result[2][0].json).toEqual({ value: 3 });
});
it('should handle multiple items routed to same output', async () => {
const inputData = [{ json: { value: 1 } }, { json: { value: 2 } }, { json: { value: 3 } }];
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, any> = {
mode: 'expression',
numberOutputs: 2,
output: 0,
};
return params[paramName];
});
const result = await switchNode.execute.call(mockExecuteFunctions);
expect(result).toHaveLength(2);
expect(result[0]).toHaveLength(3);
expect(result[1]).toHaveLength(0);
});
it('should throw error for invalid output index', async () => {
const inputData = [{ json: { value: 1 } }];
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, any> = {
mode: 'expression',
numberOutputs: 2,
output: 5,
};
return params[paramName];
});
await expect(switchNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
NodeOperationError,
);
});
it('should throw error for negative output index', async () => {
const inputData = [{ json: { value: 1 } }];
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, any> = {
mode: 'expression',
numberOutputs: 2,
output: -1,
};
return params[paramName];
});
await expect(switchNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
NodeOperationError,
);
});
it('should handle empty input data', async () => {
mockExecuteFunctions.getInputData.mockReturnValue([]);
mockExecuteFunctions.getNodeParameter.mockReturnValue('expression');
const result = await switchNode.execute.call(mockExecuteFunctions);
expect(result).toEqual([[]]);
});
});
describe('Rules Mode Execution', () => {
beforeEach(() => {
mockExecuteFunctions.getNode.mockReturnValue({
id: 'switch-node',
name: 'Switch',
type: 'n8n-nodes-base.switch',
typeVersion: 3.3,
position: [0, 0],
parameters: {},
});
});
it('should route items based on matching rules', async () => {
const inputData = [
{ json: { status: 'active' } },
{ json: { status: 'inactive' } },
{ json: { status: 'pending' } },
];
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'mode') return 'rules';
if (paramName === 'rules.values') {
return [
{
conditions: {
conditions: [
{
leftValue: '={{$json.status}}',
rightValue: 'active',
operator: { type: 'string', operation: 'equals' },
},
],
combinator: 'and',
},
},
{
conditions: {
conditions: [
{
leftValue: '={{$json.status}}',
rightValue: 'inactive',
operator: { type: 'string', operation: 'equals' },
},
],
combinator: 'and',
},
},
];
}
if (paramName === 'options') return {};
return false;
});
mockExecuteFunctions.getNodeParameter.mockImplementation(
(paramName: string, itemIndex: number, defaultValue: any, options?: any) => {
if (paramName === 'mode') return 'rules';
if (paramName === 'rules.values') {
return [
{ conditions: { conditions: [], combinator: 'and' } },
{ conditions: { conditions: [], combinator: 'and' } },
];
}
if (paramName === 'options') return {};
if (paramName.includes('conditions') && options?.extractValue) {
if (itemIndex === 0) return true; // active matches first rule
if (itemIndex === 1) return false; // inactive doesn't match first rule
return false;
}
return defaultValue;
},
);
const result = await switchNode.execute.call(mockExecuteFunctions);
expect(result).toHaveLength(2);
expect(result[0]).toHaveLength(1);
expect(result[0][0].json).toEqual({ status: 'active' });
expect(result[1]).toHaveLength(0);
});
it('should handle fallback output when no rules match', async () => {
const inputData = [{ json: { status: 'unknown' } }];
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
mockExecuteFunctions.getNodeParameter.mockImplementation(
(paramName: string, _itemIndex: number, defaultValue: any, options?: any) => {
if (paramName === 'mode') return 'rules';
if (paramName === 'rules.values') {
return [{ conditions: { conditions: [], combinator: 'and' } }];
}
if (paramName === 'options') return { fallbackOutput: 'extra' };
if (paramName.includes('conditions') && options?.extractValue) {
return false; // No rule matches
}
return defaultValue;
},
);
const result = await switchNode.execute.call(mockExecuteFunctions);
expect(result).toHaveLength(2); // One rule output + one fallback output
expect(result[0]).toHaveLength(0); // No matches for rule
expect(result[1]).toHaveLength(1); // Item goes to fallback
expect(result[1][0].json).toEqual({ status: 'unknown' });
});
it('should handle allMatchingOutputs option', async () => {
const inputData = [{ json: { value: 10 } }];
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
mockExecuteFunctions.getNodeParameter.mockImplementation(
(paramName: string, _itemIndex: number, defaultValue: any, options?: any) => {
if (paramName === 'mode') return 'rules';
if (paramName === 'rules.values') {
return [
{ conditions: { conditions: [], combinator: 'and' } },
{ conditions: { conditions: [], combinator: 'and' } },
];
}
if (paramName === 'options') return { allMatchingOutputs: true };
if (paramName.includes('conditions') && options?.extractValue) {
return true; // Both rules match
}
return defaultValue;
},
);
const result = await switchNode.execute.call(mockExecuteFunctions);
expect(result).toHaveLength(2);
expect(result[0]).toHaveLength(1);
expect(result[1]).toHaveLength(1);
expect(result[0][0].json).toEqual({ value: 10 });
expect(result[1][0].json).toEqual({ value: 10 });
});
it('should skip items when no rules are defined', async () => {
const inputData = [{ json: { value: 1 } }];
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'mode') return 'rules';
if (paramName === 'rules.values') return [];
if (paramName === 'options') return {};
return undefined;
});
const result = await switchNode.execute.call(mockExecuteFunctions);
expect(result).toEqual([[]]);
});
});
describe('Error Handling', () => {
beforeEach(() => {
mockExecuteFunctions.getNode.mockReturnValue({
id: 'switch-node',
name: 'Switch',
type: 'n8n-nodes-base.switch',
typeVersion: 3.3,
position: [0, 0],
parameters: {},
});
});
it('should handle errors with continueOnFail', async () => {
const inputData = [{ json: { value: 1 } }];
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'mode') return 'expression';
if (paramName === 'numberOutputs') return 1;
if (paramName === 'output') {
throw new Error('Parameter error');
}
return undefined;
});
mockExecuteFunctions.continueOnFail.mockReturnValue(true);
const result = await switchNode.execute.call(mockExecuteFunctions);
expect(result[0][0].json).toHaveProperty('error', 'Parameter error');
});
it('should rethrow NodeOperationError', async () => {
const inputData = [{ json: { value: 1 } }];
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
mockExecuteFunctions.getNodeParameter.mockImplementation(() => {
throw new NodeOperationError(mockExecuteFunctions.getNode(), 'Invalid parameter');
});
await expect(switchNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
NodeOperationError,
);
});
it('should handle ApplicationError with context', async () => {
const inputData = [{ json: { value: 1 } }];
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
mockExecuteFunctions.getNodeParameter.mockImplementation(() => {
const error = new ApplicationError('Application error');
throw error;
});
await expect(switchNode.execute.call(mockExecuteFunctions)).rejects.toThrow(ApplicationError);
});
it('should wrap generic errors in NodeOperationError', async () => {
const inputData = [{ json: { value: 1 } }];
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'mode') return 'expression';
if (paramName === 'numberOutputs') return 1;
if (paramName === 'output') {
throw new Error('Generic error');
}
return undefined;
});
await expect(switchNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
NodeOperationError,
);
});
});
describe('Load Options', () => {
it('should load fallback output options with no rules', async () => {
mockLoadOptionsFunctions.getCurrentNodeParameter.mockReturnValue([]);
const result =
await switchNode.methods.loadOptions.getFallbackOutputOptions.call(
mockLoadOptionsFunctions,
);
expect(result).toEqual([
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'None (default)',
value: 'none',
description: 'Items will be ignored',
},
{
name: 'Extra Output',
value: 'extra',
description: 'Items will be sent to the extra, separate, output',
},
]);
});
it('should load fallback output options with rules', async () => {
const rules = [
{ outputKey: 'Rule 1' },
{ outputKey: 'Rule 2' },
{}, // Rule without outputKey
];
mockLoadOptionsFunctions.getCurrentNodeParameter.mockReturnValue(rules);
const result =
await switchNode.methods.loadOptions.getFallbackOutputOptions.call(
mockLoadOptionsFunctions,
);
expect(result).toEqual([
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'None (default)',
value: 'none',
description: 'Items will be ignored',
},
{
name: 'Extra Output',
value: 'extra',
description: 'Items will be sent to the extra, separate, output',
},
{
name: 'Output Rule 1',
value: 0,
description: 'Items will be sent to the same output as when matched rule 1',
},
{
name: 'Output Rule 2',
value: 1,
description: 'Items will be sent to the same output as when matched rule 2',
},
{
name: 'Output 2',
value: 2,
description: 'Items will be sent to the same output as when matched rule 3',
},
]);
});
it('should handle null rules parameter', async () => {
mockLoadOptionsFunctions.getCurrentNodeParameter.mockReturnValue(null);
const result =
await switchNode.methods.loadOptions.getFallbackOutputOptions.call(
mockLoadOptionsFunctions,
);
expect(result).toEqual([
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'None (default)',
value: 'none',
description: 'Items will be ignored',
},
{
name: 'Extra Output',
value: 'extra',
description: 'Items will be sent to the extra, separate, output',
},
]);
});
});
describe('Edge Cases', () => {
beforeEach(() => {
mockExecuteFunctions.getNode.mockReturnValue({
id: 'switch-node',
name: 'Switch',
type: 'n8n-nodes-base.switch',
typeVersion: 3.3,
position: [0, 0],
parameters: {},
});
});
it('should handle items with pairedItem already set', async () => {
const inputData = [{ json: { value: 1 }, pairedItem: { item: 5 } }];
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, any> = {
mode: 'expression',
numberOutputs: 1,
output: 0,
};
return params[paramName];
});
const result = await switchNode.execute.call(mockExecuteFunctions);
expect(result[0][0].pairedItem).toEqual({ item: 0 });
});
it('should handle output index equal to returnData length', async () => {
const inputData = [{ json: { value: 1 } }];
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, any> = {
mode: 'expression',
numberOutputs: 2,
output: 2, // Equal to returnData length
};
return params[paramName];
});
await expect(switchNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
NodeOperationError,
);
});
it('should handle fallback output to existing rule output', async () => {
const inputData = [{ json: { status: 'unknown' } }];
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
mockExecuteFunctions.getNodeParameter.mockImplementation(
(paramName: string, _itemIndex: number, defaultValue: any, options?: any) => {
if (paramName === 'mode') return 'rules';
if (paramName === 'rules.values') {
return [{ conditions: { conditions: [], combinator: 'and' } }];
}
if (paramName === 'options') return { fallbackOutput: 0 };
if (paramName.includes('conditions') && options?.extractValue) {
return false; // No rule matches
}
return defaultValue;
},
);
const result = await switchNode.execute.call(mockExecuteFunctions);
expect(result).toHaveLength(1);
expect(result[0]).toHaveLength(1);
expect(result[0][0].json).toEqual({ status: 'unknown' });
});
});
});