Codify
Plugin Development

Testing

Test your plugins thoroughly

Testing

Testing is crucial for plugins because they interact with the real system. The @codifycli/plugin-test package provides utilities for writing comprehensive tests that verify the entire resource lifecycle.

Test Strategy

Codify plugins should have two types of tests:

Unit Tests (in src/**/*.test.ts):

  • Fast, isolated tests
  • Test parsing logic, utility functions, data transformations
  • No system calls or side effects
  • Run with every code change

Integration Tests (in test/**/*.test.ts):

  • Full lifecycle tests against the real system
  • Test create → modify → destroy flow
  • Verify actual system changes
  • Slower, require specific prerequisites

Integration Testing with PluginTester

The PluginTester.fullTest() method runs a complete resource lifecycle test:

import { PluginTester, testSpawn } from '@codifycli/plugin-test';
import { describe, it } from 'vitest';
import path from 'node:path';

describe('Alias resource', () => {
  const pluginPath = path.resolve('./src/index.ts');

  it('manages shell alias lifecycle', { timeout: 300000 }, async () => {
    await PluginTester.fullTest(
      pluginPath,
      [
        {
          type: 'alias',
          alias: 'my-alias',
          value: 'ls -l'
        }
      ],
      {
        // Validate the CREATE operation
        validateApply: async () => {
          const { data } = await testSpawn('alias');
          expect(data).toContain('my-alias');
          expect(data).toContain('ls -l');

          // Test that alias actually works
          const result = await testSpawn('my-alias');
          expect(result.status).toBe('success');
        },

        // Test the MODIFY operation
        testModify: {
          modifiedConfigs: [{
            type: 'alias',
            alias: 'my-alias',
            value: 'pwd'  // Changed value
          }],
          validateModify: async () => {
            const { data } = await testSpawn('alias');
            expect(data).toContain('my-alias');
            expect(data).toContain('pwd');
            expect(data).not.toContain('ls -l');
          }
        },

        // Validate the DESTROY operation
        validateDestroy: async () => {
          const { data } = await testSpawn('alias');
          expect(data).not.toContain('my-alias');
        }
      }
    );
  });
});

What fullTest() Does

The fullTest() method executes this sequence:

  1. Initial Refresh: Saves current system state
  2. Validate: Validates configs against schemas
  3. Plan (Create): Generates CREATE plan
  4. Apply (Create): Executes CREATE operation
  5. Validate Apply: Runs your validateApply() callback
  6. Plan (Modify): Generates MODIFY plan with modified configs
  7. Apply (Modify): Executes MODIFY operation
  8. Validate Modify: Runs your validateModify() callback
  9. Plan (Destroy): Generates DESTROY plan
  10. Apply (Destroy): Executes DESTROY operation
  11. Validate Destroy: Runs your validateDestroy() callback

This ensures your resource handles the complete lifecycle correctly.

Testing Configuration Variations

Test different configuration scenarios:

it('handles multiple aliases', async () => {
  await PluginTester.fullTest(
    pluginPath,
    [
      {
        type: 'aliases',
        aliases: [
          { alias: 'gs', value: 'git status' },
          { alias: 'gp', value: 'git pull' },
          { alias: 'gc', value: 'git commit' }
        ]
      }
    ],
    {
      validateApply: async () => {
        const { data } = await testSpawn('alias');
        expect(data).toContain('gs=');
        expect(data).toContain('gp=');
        expect(data).toContain('gc=');
      },
      testModify: {
        modifiedConfigs: [{
          type: 'aliases',
          aliases: [
            { alias: 'gs', value: 'git status' },
            // Removed gp
            { alias: 'gc', value: 'git commit -v' },  // Modified
            { alias: 'gd', value: 'git diff' }  // Added
          ]
        }],
        validateModify: async () => {
          const { data } = await testSpawn('alias');
          expect(data).toContain('gs=');
          expect(data).not.toContain('gp=');
          expect(data).toContain('gc=\'git commit -v\'');
          expect(data).toContain('gd=');
        }
      },
      validateDestroy: async () => {
        const { data } = await testSpawn('alias');
        expect(data).not.toContain('gs=');
        expect(data).not.toContain('gc=');
        expect(data).not.toContain('gd=');
      }
    }
  );
});

Testing Validation Errors

Test that your resource properly rejects invalid configurations:

it('rejects invalid alias names', async () => {
  await expect(async () => {
    await PluginTester.fullTest(
      pluginPath,
      [{
        type: 'alias',
        alias: 'invalid-name-with-$-symbols',
        value: 'ls'
      }]
    );
  }).rejects.toThrow();
});

it('requires both alias and value fields', async () => {
  await expect(async () => {
    await PluginTester.fullTest(
      pluginPath,
      [{
        type: 'alias',
        alias: 'myalias'
        // Missing value field
      }]
    );
  }).rejects.toThrow();
});

Platform-Specific Tests

Test different behaviors on different platforms:

import { Utils } from '@codifycli/plugin-core';

it('handles platform-specific installation', async () => {
  const expectedCommand = Utils.isMacOS()
    ? 'brew install my-tool'
    : 'apt-get install my-tool';

  await PluginTester.fullTest(
    pluginPath,
    [{ type: 'my-tool' }],
    {
      validateApply: async () => {
        // Verify tool was installed correctly
        const result = await testSpawn('which my-tool');
        expect(result.status).toBe('success');
      }
    }
  );
});

Test Setup and Teardown

Use Vitest's lifecycle hooks to set up and clean up test environments:

import { beforeAll, afterAll, beforeEach, describe, it } from 'vitest';

describe('Homebrew tests', () => {
  beforeAll(async () => {
    // Ensure Homebrew is installed before running tests
    const result = await testSpawn('which brew');
    if (result.status === 'error') {
      throw new Error('Homebrew must be installed to run these tests');
    }
  });

  afterAll(async () => {
    // Clean up any test artifacts
    await testSpawn('brew cleanup');
  });

  beforeEach(() => {
    // Reset state before each test
  });

  it('installs formulae', async () => {
    // Test implementation
  });
});

Testing with Real Files

When testing resources that modify files, use temporary directories:

import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs/promises';

it('creates config file', async () => {
  const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codify-test-'));

  try {
    await PluginTester.fullTest(
      pluginPath,
      [{
        type: 'my-config',
        configPath: path.join(tempDir, 'config.json'),
        setting: 'value'
      }],
      {
        validateApply: async () => {
          const configExists = await fs.access(
            path.join(tempDir, 'config.json')
          ).then(() => true).catch(() => false);

          expect(configExists).toBe(true);

          const config = JSON.parse(
            await fs.readFile(path.join(tempDir, 'config.json'), 'utf8')
          );
          expect(config.setting).toBe('value');
        },
        validateDestroy: async () => {
          const configExists = await fs.access(
            path.join(tempDir, 'config.json')
          ).then(() => true).catch(() => false);

          expect(configExists).toBe(false);
        }
      }
    );
  } finally {
    // Clean up temp directory
    await fs.rm(tempDir, { recursive: true, force: true });
  }
});

Unit Testing Utilities

For utility functions and parsing logic, write simple unit tests:

import { describe, it, expect } from 'vitest';
import { parseBrewList } from './homebrew-utils.js';

describe('parseBrewList', () => {
  it('parses brew list output', () => {
    const output = `git
node
python@3.11`;

    const result = parseBrewList(output);

    expect(result).toEqual(['git', 'node', 'python@3.11']);
  });

  it('handles empty output', () => {
    expect(parseBrewList('')).toEqual([]);
  });

  it('filters out warnings', () => {
    const output = `Warning: Some warning message
git
node`;

    const result = parseBrewList(output);

    expect(result).toEqual(['git', 'node']);
  });
});

Best Practices for Testing

  1. Use long timeouts for integration tests: System operations can be slow

    it('installs packages', { timeout: 300000 }, async () => {
      // Test implementation
    });
  2. Clean up after tests: Always restore system state

    afterAll(async () => {
      await cleanup();
    });
  3. Test edge cases: Empty configs, missing fields, invalid values

  4. Test error conditions: What happens when commands fail?

  5. Use descriptive test names: Make failures easy to understand

    it('installs Homebrew in custom directory and adds to PATH', async () => {
      // ...
    });
  6. Isolate tests: Each test should be independent and not rely on others

  7. Mock external dependencies in unit tests: Use mocking for fast, deterministic unit tests

On this page