Skip to main content
Testing is critical for ensuring migration recipes work correctly across different code patterns and edge cases.

Test Structure

Each recipe includes a tests/ directory with two subdirectories:
recipes/my-migration/
└── tests/
    ├── input/       # Test input files (before transformation)
    └── expected/    # Expected output files (after transformation)

Writing Tests

1

Create test input files

Add files to tests/input/ representing code patterns your recipe should handle:
tests/input/file-0.js
const { tmpDir } = require('os');

var t0 = tmpDir();
let t1 = tmpDir();
const t2 = tmpDir();
tests/input/file-1.mjs
import { tmpDir } from 'node:os';

const temp = tmpDir();
console.log(temp);
2

Create expected output files

Add corresponding files to tests/expected/ with the correct transformations:
tests/expected/file-0.js
const { tmpdir } = require('os');

var t0 = tmpdir();
let t1 = tmpdir();
const t2 = tmpdir();
tests/expected/file-1.mjs
import { tmpdir } from 'node:os';

const temp = tmpdir();
console.log(temp);
File names in input/ and expected/ must match exactly.
3

Run the tests

Execute tests using the test script:
cd recipes/my-migration
npm test

Test Coverage

Your tests should cover:

Import/Require Variations

Test all common import patterns:
// CommonJS require
const { api } = require('module');
const api = require('module').api;
const mod = require('node:module');

// ES6 import
import { api } from 'module';
import { api } from 'node:module';
import * as mod from 'module';

Edge Cases

// Renamed imports
import { oldAPI as custom } from 'module';
const { oldAPI: renamed } = require('module');

// Multiple imports
import { api1, api2, oldAPI } from 'module';

// Destructured usage
const result = oldAPI().property;

// Method chaining
oldAPI().method().another();

No-op Cases

Test that your recipe doesn’t modify files that don’t need changes:
tests/input/no-changes.js
// File without the deprecated API
const { someOtherAPI } = require('module');

const result = someOtherAPI();
The expected output should be identical:
tests/expected/no-changes.js
// File without the deprecated API
const { someOtherAPI } = require('module');

const result = someOtherAPI();

Unit Testing (Advanced)

For complex recipes with helper functions, create unit tests alongside your code:
src/my-helper.test.ts
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { myHelper } from './my-helper.ts';

describe('myHelper', () => {
  it('should transform pattern correctly', () => {
    const input = 'oldAPI()';
    const expected = 'newAPI()';
    assert.equal(myHelper(input), expected);
  });

  it('should handle edge case', () => {
    const input = 'oldAPI.method()';
    const expected = 'newAPI.method()';
    assert.equal(myHelper(input), expected);
  });
});
Run unit tests:
node --test src/*.test.ts

Integration Testing

The default test command runs integration tests using the jssg test runner:
package.json
{
  "scripts": {
    "test": "npx codemod jssg test -l typescript ./src/workflow.ts ./"
  }
}
This command:
  1. Loads your transformation from src/workflow.ts
  2. Applies it to files in tests/input/
  3. Compares results against tests/expected/
  4. Reports any differences

Test Fixtures

When testing file system operations or complex scenarios, use fixtures:
recipes/correct-ts-specifiers/
└── src/
    ├── fixtures/
    │   └── e2e/
    │       ├── test.js
    │       └── module.ts
    └── workflow.test.ts
Reference fixtures in tests:
import { fileURLToPath } from 'node:url';

const fixtureDir = fileURLToPath(
  import.meta.resolve('./fixtures/e2e/')
);

Running Tests Locally

Before Committing

Always run the full test suite before committing:
npm run pre-commit
This runs:
  1. Linting with auto-fix
  2. Type checking
  3. All tests

Individual Test Commands

npm run lint:fix

CI/CD Testing

All tests run automatically in GitHub Actions on:
  • Every push to any branch
  • Every pull request
  • Multiple Node.js versions (22+)
  • Multiple operating systems (Ubuntu, macOS, Windows)
The CI workflow runs:
.github/workflows/ci.yml
- Lint and type checking
- YAML validation
- jssg tests on all platforms
- Legacy tests on multiple Node versions

Test Warnings

Some integration tests modify fixture files when running the entire codemod. Remember to restore these files before committing:
git restore recipes/*/tests/

Debugging Failed Tests

When a test fails:
1

Check the diff

The test runner shows differences between actual and expected output:
- const { oldAPI } = require('os');
+ const { newAPI } = require('os');
2

Run the transformation manually

Test your transformation on a single file:
npx codemod jssg run -l typescript ./src/workflow.ts ./tests/input/file-0.js
3

Add debug logging

Add console.log statements in your transformation:
export default function transform(root: SgRoot<Js>): string | null {
  const rootNode = root.root();
  console.log('Processing file...');
  
  const matches = rootNode.findAll({ rule: { pattern: 'oldAPI' } });
  console.log(`Found ${matches.length} matches`);
  
  // ... rest of transformation
}
4

Verify the pattern

Test your AST pattern in Codemod Studio:
  1. Visit Codemod Studio
  2. Paste your code sample
  3. Test your pattern matching

Best Practices

Include tests for .js, .mjs, .cjs, .ts, .tsx, etc.
tests/input/
├── file-0.js
├── file-0.mjs
├── file-1.ts
└── file-1.tsx
Use real-world examples from actual codebases, not just simple cases.
Ensure transformations work when only some imports need updating:
// Should only change oldAPI, not otherAPI
const { oldAPI, otherAPI } = require('module');
Each test file should test one specific pattern or edge case.
Use clear names that indicate what’s being tested:
file-0.js      → basic-require.js
file-1.mjs     → esm-import.mjs
file-2.ts      → renamed-import.ts

Next Steps

Development Workflow

Learn about the development workflow and PR process

Build docs developers (and LLMs) love