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 → destroyflow - 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:
- Initial Refresh: Saves current system state
- Validate: Validates configs against schemas
- Plan (Create): Generates CREATE plan
- Apply (Create): Executes CREATE operation
- Validate Apply: Runs your
validateApply()callback - Plan (Modify): Generates MODIFY plan with modified configs
- Apply (Modify): Executes MODIFY operation
- Validate Modify: Runs your
validateModify()callback - Plan (Destroy): Generates DESTROY plan
- Apply (Destroy): Executes DESTROY operation
- 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
-
Use long timeouts for integration tests: System operations can be slow
it('installs packages', { timeout: 300000 }, async () => { // Test implementation }); -
Clean up after tests: Always restore system state
afterAll(async () => { await cleanup(); }); -
Test edge cases: Empty configs, missing fields, invalid values
-
Test error conditions: What happens when commands fail?
-
Use descriptive test names: Make failures easy to understand
it('installs Homebrew in custom directory and adds to PATH', async () => { // ... }); -
Isolate tests: Each test should be independent and not rely on others
-
Mock external dependencies in unit tests: Use mocking for fast, deterministic unit tests