Writing Tests
Core Principles
When writing tests:
- Use Jest with
describe/itblocks - Use Effect-TS for async operations (not async/await)
- Test both valid and invalid cases
- Add debugging to production code using
Effect.logDebugin the code being tested - Add debugging to test code using
console.login tests - Enable console logging while debugging - Use
enableConsoleLogging()andsetLogLevel('debug')inbeforeEach, then change tosetLogLevel('error')when tests are ready - Prefer real implementations over mocks - Use real services, managers, and components unless mocking is absolutely necessary
Test Structure
Basic Test Template
import { MyComponent } from '../../src/components/MyComponent';
import { ApexSymbolManager } from '../../src/symbols/ApexSymbolManager';
import { CompilerService } from '../../src/parser/compilerService';
import { Effect } from 'effect';
import { enableConsoleLogging, setLogLevel } from '@salesforce/apex-lsp-shared';
describe('MyComponent', () => {
let symbolManager: ApexSymbolManager;
let compilerService: CompilerService;
beforeEach(() => {
symbolManager = new ApexSymbolManager();
compilerService = new CompilerService();
// Enable console logging - set to 'debug' while debugging, 'error' when ready
enableConsoleLogging();
setLogLevel('error');
});
afterEach(() => {
symbolManager.clear();
});
it('should do something correctly', async () => {
// Test implementation
const result = await doSomething();
expect(result).toBe(expectedValue);
});
});
Using Real Code vs Mocks
Prefer real implementations - Tests should use real code whenever possible:
// ✅ Good - Use real implementations
let symbolManager: ApexSymbolManager;
let compilerService: CompilerService;
beforeEach(() => {
symbolManager = new ApexSymbolManager();
compilerService = new CompilerService();
});
Use mocks only when absolutely necessary:
- External services that can't be controlled (network calls, file system in some cases)
- Code that has side effects that would break test isolation
- Performance-critical tests where real implementations are too slow
- Testing error conditions that are difficult to trigger with real code
Avoid mocks for:
ApexSymbolManager- Use real instancesCompilerService- Use real instances- Internal services and components - Use real implementations
- Effect services - Use real
EffectTestLoggerLiveand other live implementations - Internal types and utilities - Use real implementations
Example of acceptable mock usage:
// Only mock when testing error conditions that are hard to trigger
const mockService = {
getData: jest.fn().mockRejectedValue(new Error('Network error')),
} as unknown as MyService;
Example of preferred real code:
// Use real symbol manager - it's fast and provides better test coverage
const symbolManager = new ApexSymbolManager();
await Effect.runPromise(symbolManager.addSymbolTable(symbolTable, uri));
Effect-TS in Tests
Always use Effect.runPromise (not async/await with Effect):
// ✅ Correct
await Effect.runPromise(symbolManager.resolveCrossFileReferencesForFile(uri));
// ❌ Wrong
await symbolManager.resolveCrossFileReferencesForFile(uri);
Debugging Tests
Two approaches for debugging:
Add debugging to production code using Effect logging:
// In your production code, add Effect logging for debugging: yield * Effect.logDebug(`Processing ${items.length} items`); yield * Effect.logInfo('Operation started'); yield * Effect.logWarning('Potential issue detected'); yield * Effect.logError(`Operation failed: ${error.message}`);Add debugging to test code using
console.log:const result = await myFunction(); // Debug: Log results if test fails if (result !== expectedValue) { console.log('Unexpected result:', JSON.stringify(result, null, 2)); console.log('Expected:', expectedValue); } expect(result).toBe(expectedValue);
Enable console logging while debugging tests:
import { enableConsoleLogging, setLogLevel } from '@salesforce/apex-lsp-shared';
beforeEach(() => {
symbolManager = new ApexSymbolManager();
compilerService = new CompilerService();
// Enable console logging and set to debug level while debugging
enableConsoleLogging();
setLogLevel('debug');
});
When tests are ready, change log level to error:
beforeEach(() => {
symbolManager = new ApexSymbolManager();
compilerService = new CompilerService();
// Set to error level for production-ready tests
enableConsoleLogging();
setLogLevel('error');
});
Note: Effect logging from production code will appear in test output when console logging is enabled and log level is set appropriately. Ensure Effect services provide EffectTestLoggerLive or similar logging layers.
Common Patterns
Testing Valid Cases
it('should handle valid input correctly', async () => {
const input = createValidInput();
const result = await myFunction(input);
expect(result).toBeDefined();
expect(result.isValid).toBe(true);
});
Testing Invalid Cases
it('should handle invalid input correctly', async () => {
const input = createInvalidInput();
const result = await myFunction(input);
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Expected error message');
});
Testing with Fixtures
import * as fs from 'fs';
import * as path from 'path';
const loadFixture = (filename: string): string => {
const fixturePath = path.join(__dirname, '../fixtures', filename);
return fs.readFileSync(fixturePath, 'utf8');
};
it('should process fixture correctly', async () => {
const content = loadFixture('MyFixture.cls');
const result = await processContent(content);
expect(result).toBeDefined();
});
Test File Naming
Test files follow the pattern: {ComponentName}.test.ts
Examples:
OperatorValidator.test.tsApexSymbolManager.getSymbolAtPosition.test.tsSymbolReference.test.tsApexSymbolCollectorListener.assignment.test.ts
Anti-Patterns to Avoid
❌ Don't use async/await with Effect:
// Bad
const result = await myEffect;
✅ Use Effect.runPromise:
// Good
const result = await Effect.runPromise(myEffect);
❌ Don't forget to clean up resources:
// Bad - missing afterEach
beforeEach(() => {
symbolManager = new ApexSymbolManager();
});
✅ Always clean up in afterEach:
// Good
afterEach(() => {
symbolManager.clear();
});
❌ Don't mock when real code works:
// Bad - unnecessary mock
const mockSymbolManager = {
getSymbol: jest.fn().mockReturnValue(symbol),
} as ApexSymbolManager;
✅ Use real implementations:
// Good - real code provides better test coverage
const symbolManager = new ApexSymbolManager();
await Effect.runPromise(symbolManager.addSymbolTable(symbolTable, uri));
❌ Don't leave debug logging enabled:
// Bad - debug level left in production-ready test
beforeEach(() => {
enableConsoleLogging();
setLogLevel('debug'); // Should be 'error' when ready
});
✅ Set log level to error when tests are ready:
// Good - error level for production-ready tests
beforeEach(() => {
enableConsoleLogging();
setLogLevel('error');
});
Validator-Specific Testing
For validator tests, use helpers from validation-test-helpers.ts:
compileFixtureWithOptions
Compiles a fixture file and creates validation options:
import {
compileFixtureWithOptions,
runValidator,
} from './helpers/validation-test-helpers';
const { symbolTable, options } = await compileFixtureWithOptions(
VALIDATOR_CATEGORY, // e.g., 'operator', 'variable-resolution'
'MyFixture.cls', // Filename in test/fixtures/validation/{category}/
undefined, // Optional file URI
symbolManager,
compilerService,
{
tier: ValidationTier.IMMEDIATE, // or ValidationTier.THOROUGH
allowArtifactLoading: false, // true for TIER 2 tests
},
);
runValidator
Runs a validator Effect with all required services:
const result = await runValidator(
MyValidator.validate(symbolTable, options),
symbolManager,
);
Returns: ValidationResult with isValid, errors, and warnings arrays.
Note: runValidator automatically provides EffectTestLoggerLive, so validators can use Effect logging (Effect.logDebug, Effect.logInfo, etc.). To see Effect logs in test output, enable console logging with enableConsoleLogging() and set an appropriate log level with setLogLevel().
Testing TIER 2 (THOROUGH) Validation
TIER 2 tests require cross-file resolution:
describe('TIER 2: Cross-file type resolution', () => {
it('should validate with resolved types', async () => {
// Compile dependent files first
await compileFixtureWithOptions(
VALIDATOR_CATEGORY,
'DependentClass.cls',
undefined,
symbolManager,
compilerService,
{
tier: ValidationTier.THOROUGH,
allowArtifactLoading: true,
},
);
// Compile the file under test
const { symbolTable, options } = await compileFixtureWithOptions(
VALIDATOR_CATEGORY,
'TestClass.cls',
undefined,
symbolManager,
compilerService,
{
tier: ValidationTier.THOROUGH,
allowArtifactLoading: true,
},
);
// Resolve cross-file references
await Effect.runPromise(
symbolManager.resolveCrossFileReferencesForFile(
symbolTable.getFileUri() || '',
),
);
const result = await runValidator(
MyValidator.validate(symbolTable, options),
symbolManager,
);
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
});
});
Testing Error Codes
For validator tests, check for specific error codes:
import { ErrorCodes } from '../../../../src/generated/ErrorCodes';
const hasError = result.errors.some(
(e: any) => e.code === ErrorCodes.INVALID_COMPARISON_TYPES,
);
expect(hasError).toBe(true);
Additional Resources
- Test Helpers: See
test/semantics/validation/validators/helpers/validation-test-helpers.tsfor validator-specific helpers - Error Codes: See
src/generated/ErrorCodes.tsfor validator error codes - Example Tests:
- Validators:
test/semantics/validation/validators/OperatorValidator.test.ts - Symbol Manager:
test/symbols/ApexSymbolManager.getSymbolAtPosition.test.ts - Types:
test/types/typeReference.test.ts
- Validators:
- Effect-TS Testing: See
prefer-effect-tsskill for Effect patterns