Unit testing JSONata in AWS Step Functions: Breaking Free from Step Function Testing Constraints

Ever found yourself staring at a complex JSONata transformation buried deep inside an AWS Step Functions definition, wondering how to properly test it without spinning up the entire AWS infrastructure? Yeah, me too. That’s exactly the problem I set out to solve with this technical spike on unit testing JSONata in AWS Step Functions.

TL;DR

Embedding complex blocks of JSONata directly inside AWS Step Functions makes testing slow, complex, and frustrating. By moving JSONata templates into separate files, introducing a lightweight template manager, and running unit tests locally, you can validate transformations while you develop – no AWS deployments required.

Table of Contents

The Problem – JSONata Templates Embedded in Step Function Definition Files   

AWS Step Functions recently added support for JSONata as a query language, which is fantastic for data transformations. But there’s a catch – when your JSONata templates are embedded directly in your Step Function definitions, testing them becomes a nightmare: 

  • No isolation: You can’t test the transformation logic without running the entire Step Function 
  • Slow feedback loops: Every test requires AWS infrastructure 
  • Debugging hell: When something breaks, good luck figuring out if it’s your JSONata or your Step Function logic 
  • No IDE support: JSONata embedded in JSON strings means no syntax highlighting or validation 

The Solution – Separate, Test, then Integrate 

The approach I developed separates JSONata templates into their own files, allows for comprehensive unit testing, and then seamlessly integrates them back into Step Functions during deployment. Here’s how it works:

Step 1: Make JSONata Templates First-Class for Easier Unit Testing

Instead of embedding JSONata directly in Step Function definitions, I created separate .jsonata files: 

				
					// src/transforms/customer-transform.jsonata 
{ 
  "customerId": $source.customer.id, 
  "fullName": $source.customer.firstName & " " & $source.customer.lastName, 
  "email": $source.customer.email, 
  "address": { 
    "street": $source.customer.address.street, 
    "city": $source.customer.address.city, 
    "state": $source.customer.address.state, 
    "zipCode": $source.customer.address.zipCode, 
    "country": $source.customer.address.country ? $source.customer.address.country : "USA" 
  }, 
  "preferences": $source.customer.preferences[].{ 
    "category": category, 
    "enabled": enabled, 
    "priority": priority ? priority : 10 
  }, 
  "filter": "customerId eq '" & $source.customer.id & "'" 
}
				
			

When used together with your JSONata IDE plugin of choice, this gives us proper syntax highlighting, validation, and the ability to version control our transformations independently.

Step 2: Template Management for JSONata in AWS Step Functions

I built a simple but effective template management system that handles loading and caching: 

// src/transforms/index.ts 
export class JsonataTemplateManager { 
  private static templates: Map<string, string> = new Map(); 
 
  static loadTemplate(templateName: string): string { 
    if (this.templates.has(templateName)) { 
      return this.templates.get(templateName)!; 
    } 
    const templatePath = path.join(__dirname, `${templateName}.jsonata`); 
  
    if (!fs.existsSync(templatePath)) { 
      throw new Error(`JSONata template not found: ${templatePath}`); 
    } 
    const template = fs.readFileSync(templatePath, 'utf-8'); 
    this.templates.set(templateName, template); 
    return template; 
  } 
  static getAvailableTemplates(): string[] { 
    const templatesDir = __dirname; 
    return fs.readdirSync(templatesDir) 
      .filter(file => file.endsWith('.jsonata')) 
      .map(file => path.basename(file, '.jsonata')); 
  } 
} 
// Convenience exports 
export const getCustomerTransform = () => JsonataTemplateManager.loadTemplate('customer-transform');

This approach provides caching, validation, and a clean API for accessing templates.

Step 3: Unit Testing JSONata in AWS Step Functions Locally

Here’s where the magic happens. With templates as separate files, we can write proper unit tests:

// test/transforms/customer-transform.test.ts 
import jsonata from "jsonata"; 
import { getCustomerTransform } from "../../src/transforms"; 
 
describe("Customer Transform", () => { 
  let template: string; 
 
  beforeAll(() => { 
    template = getCustomerTransform(); 
  }); 
 
  it("should transform a complete customer object", async () => { 
    const input = { 
      customer: { 
        id: "cust-123", 
        firstName: "John", 
        lastName: "Doe", 
        email: "john.doe@example.com", 
        address: { 
          street: "123 Main St", 
          city: "Anytown", 
          state: "CA", 
          zipCode: "12345", 
          country: "USA", 
        }, 
        preferences: [ 
          { category: "email", enabled: true, priority: 1 }, 
          { category: "sms", enabled: false, priority: 2 }, 
        ], 
      }, 
    }; 
    const expression = jsonata(template); 
    const result = await expression.evaluate({}, { 
      source: input, // Consistent variable naming 
    }); 
    expect(result.customerId).toBe("cust-123"); 
    expect(result.fullName).toBe("John Doe"); 
    expect(result.address.country).toBe("USA"); 
    expect(Array.from(result.preferences)).toEqual([ 
      { category: "email", enabled: true, priority: 1 }, 
      { category: "sms", enabled: false, priority: 2 }, 
    ]); 
  }); 
  it("should handle missing country with default", async () => { 
    const input = { 
      customer: { 
        id: "cust-456", 
        firstName: "Jane", 
        lastName: "Smith", 
        email: "jane.smith@example.com", 
        address: { 
          street: "456 Oak Ave", 
          city: "Somewhere", 
          state: "NY", 
          zipCode: "67890", 
          // country is missing 
        }, 
        preferences: [], 
      }, 
    }; 
    const expression = jsonata(template); 
    const result = await expression.evaluate({}, { source: input });
    expect(result.address.country).toBe("USA"); // Default applied 
  }); 
});

The key insight here is using a consistent `$source` variable in templates instead of relying on `$states.input`. This makes templates more testable and reusable. To support workflow variables, you can also pass them into the `expression.evaluate() function as additional bindings:

const result = await expression.evaluate({}, {  

  myVariable: "some value",  

  anotherVariable: { field: "value" },  

  source: input  

}); 

Step 4: Integrating Tested JSONata Templates Back into Step Functions

The final piece is seamlessly integrating these templates back into the Step Function definition file. I use placeholder syntax in the Step Function definition:

{ 
  "Comment": "A description of my state machine", 
  "StartAt": "Init variables", 
  "States": { 
    "Init variables": { 
      "Type": "Pass", 
      "Next": "Transform into Canonical", 
      "Assign": { 
        "source": "{% $states.input %}" 
      } 
    }, 
    "Transform into Canonical": { 
      "Type": "Pass", 
      "End": true, 
      "Output": "{% {{CUSTOMER_TRANSFORM_TEMPLATE}} %}" 
    } 
  }, 
  "QueryLanguage": "JSONata" 
}

Then during CDK deployment, I substitute the placeholders with actual templates: 

// src/cdk/step-function-stack.ts 
private substituteTemplates(definition: string, templates: Record<string, string>): string { 
  let processedDefinition = definition; 
 
  Object.entries(templates).forEach(([templateName, templateContent]) => { 
    const placeholder = `{{${templateName.toUpperCase().replace(/-/g, '_')}_TEMPLATE}}`; 
     
    // Escape the JSONata template for JSON embedding 
    const escapedTemplate = JSON.stringify(templateContent); 
    const embeddedTemplate = escapedTemplate.slice(1, -1); 
     
    processedDefinition = processedDefinition.replace( 
      new RegExp(placeholder, 'g'), 
      embeddedTemplate 
    ); 
  }); 
  // Validate that all placeholders were replaced 
  const remainingPlaceholders = processedDefinition.match(/\{\{[^}]+\}\}/g); 
  if (remainingPlaceholders) { 
    throw new Error( 
      `Unresolved template placeholders: ${remainingPlaceholders.join(', ')}` 
    ); 
  } 
  return processedDefinition; 
}

The Results: Fast, Reliable, Maintainable

This approach delivers several key benefits:

Lightning-Fast Test Feedback

$ npm test 

 PASS  test/transforms/customer-transform.test.ts 

  Customer Transform 

    Basic transformation 

      ✓ should transform a complete customer object (4 ms) 

      ✓ should handle missing country with default (1 ms) 

      ✓ should handle missing priority with default (1 ms) 

    Edge cases 

      ✓ should handle empty preferences array (1 ms) 

      ✓ should handle null values gracefully (1 ms) 

    Validation 

      ✓ should handle missing customer object (1 ms) 

      ✓ should handle missing required fields (1 ms) 

 

Test Suites: 1 passed, 1 total 

Tests:       7 passed, 7 total 

Time:        0.892 s 

Tests run in milliseconds, not minutes. No AWS infrastructure deployment required.

Comprehensive Edge Case Testing

The framework makes it easy to test edge cases that would be painful to reproduce in a full Step Function:

it("should handle null values gracefully", async () => { 
  const input = { 
    customer: { 
      id: "cust-null", 
      firstName: "Null", 
      lastName: null, // Testing null handling 
      email: "null@example.com", 
      preferences: null, 
    }, 
  }; 
 
  const expression = jsonata(template); 
  const result = await expression.evaluate({}, { source: input }); 
   
  expect(result.fullName).toBe("Null null"); // JSONata handles nulls gracefully 
});

Integration Testing with CDK

The framework also includes integration tests that validate the CDK deployment process:

// test/integration/step-function.test.ts 
describe('Step Function Integration Tests', () => { 
  it('should create stack without errors', () => { 
    const app = new cdk.App(); 
    const stack = new StepFunctionStack(app, 'TestStack', { 
      environment: 'test', 
    }); 
     
    expect(stack).toBeDefined(); 
    expect(stack.customerProcessingStateMachine).toBeDefined(); 
  }); 
 
  it('should have valid JSON definition', () => { 
    const template = Template.fromStack(stack); 
    const stateMachines = template.findResources('AWS::StepFunctions::StateMachine'); 
    const stateMachine = Object.values(stateMachines)[0] as any; 
    const definition = stateMachine.Properties.DefinitionString; 
     
    // Should be valid JSON with no unresolved placeholders 
    expect(() => JSON.parse(definition)).not.toThrow(); 
  }); 
});

Key Patterns and Best Practices for JSONata Unit Testing 

Through building this framework, I discovered several important patterns:

1. Consistent Variable Naming

Always use $source in templates instead of $states.input. This makes templates more testable and allows you to control the input structure in tests.

2. Template Placeholder Convention

Use a consistent naming convention for placeholders: {{TEMPLATE_NAME_TEMPLATE}}. This makes it easy to identify and replace them programmatically.

3. Proper JSON Escaping

When embedding JSONata in JSON strings, proper escaping is crucial: 

const escapedTemplate = JSON.stringify(templateContent); 

const embeddedTemplate = escapedTemplate.slice(1, -1); // Remove outer quotes 

4. Build-Time Validation

Validate templates during the build process to catch syntax errors early:

private validateJsonataTemplates(): void { 
  const templates = getAllTemplates(); 
  const jsonata = require('jsonata'); 
 
  Object.entries(templates).forEach(([name, template]) => { 
    try { 
      jsonata(template); 
      console.log(`✓ JSONata template '${name}' is valid`); 
    } catch (error) { 
      throw new Error(`Invalid JSONata template '${name}': ${error.message}`); 
    } 
  }); 
}

What’s Next? 

This spike proves that separating JSONata templates from Step Functions is not only possible but highly beneficial. The next steps would be: 

  • Template Versioning: Add support for template versioning and migration 
  • Performance Testing: Add performance benchmarks for complex transformations 
  • IDE Integration: Create VS Code extensions for JSONata syntax highlighting and validation 
  • Template Library: Build a shared library of common transformation patterns 

Interested in testing beyond transformations? Cevo’s related blog “How to Test Step Functions State Machine Locally” walks you through using Docker and Step Functions Local for isolated workflow testing.

Takeaway: Why Unit Testing JSONata in AWS Step Functions Matters

Testing JSONata transformations doesn’t have to be painful. Treat your JSONata templates as first-class citizens, not buried strings inside Step Functions. Separating, testing, and validating them independently makes your transformations faster to debug, easier to maintain, and safer to run in production. 

The complete code for this spike is available in this repository, including all the patterns, tests, and CDK integration code. 

Happy transforming!

Enjoyed this blog?

Share it with your network!

Move faster with confidence