An extensible static analysis linter for the TypeScript language
—
TSLint provides a comprehensive testing framework for validating custom rules with markup-based test files and automated test runners.
TSLint's testing system uses a markup format that allows you to specify expected lint failures directly in test source code using comments.
import { Test } from 'tslint';
// Run tests in a directory
function runTest(testDirectory: string, rulesDirectory?: string | string[]): TestResult
// Run tests matching patterns
function runTests(patterns: string[], rulesDirectory?: string | string[]): TestResult[]
// Handle single test result output
function consoleTestResultHandler(testResult: TestResult, logger: Logger): void
// Handle multiple test results output
function consoleTestResultsHandler(testResults: TestResult[], logger: Logger): voidinterface Logger {
log(message: string): void;
error(message: string): void;
}
interface TestResult {
directory: string;
results: {
[fileName: string]: TestOutput | SkippedTest;
};
}
interface TestOutput {
skipped: false;
errorsFromLinter: LintError[];
errorsFromMarkup: LintError[];
fixesFromLinter: string;
fixesFromMarkup: string;
markupFromLinter: string;
markupFromMarkup: string;
}
interface SkippedTest {
skipped: true;
requirement: string;
}
interface LintError {
endPos: {
col: number;
line: number;
};
message: string;
startPos: {
col: number;
line: number;
};
}test/
├── rules/
│ ├── my-rule/
│ │ ├── test.ts.lint # Test file with markup
│ │ ├── test.ts.fix # Expected fix result (optional)
│ │ └── tslint.json # Rule configuration
│ └── another-rule/
│ ├── valid.ts.lint
│ ├── invalid.ts.lint
│ └── tslint.json
└── custom-rules/ # Custom rules directory
├── myRuleRule.ts
└── anotherRuleRule.ts{
"rules": {
"my-rule": [true, "option1", "option2"],
"semicolon": true
}
}Test files use special comments to indicate expected failures:
// In test.ts.lint file
function example() {
console.log('test');
~~~~~~~~~~~~~~~ [error message here]
}
let unused = 5;
~~~~~~ [Variable 'unused' is never used]
const noSemicolon = true
~ [Missing semicolon]Markup Rules:
~ characters mark the span where an error is expected[message] specifies the expected error messagefunction longFunction(
parameter1: string,
~~~~~~~~~~~~~~~~~~~ [Parameter should be on single line]
parameter2: number
~~~~~~~~~~~~~~~~~~
) {
return parameter1 + parameter2;
}let a = 1, b = 2;
~ [Variable 'a' should use const]
~ [Variable 'b' should use const]const message = 'Don\'t use quotes';
~~~~~~~~~~~~~~~~~ [Don't use single quotes inside strings]
// Escape brackets in messages
const test = `template ${variable}`;
~~~~~~~~~~~ [Prefer 'string' over \`template\`]For rules that provide auto-fixes, create a .fix file with the expected result:
test.ts.lint:
const value = "double";
~~~~~~~~ [Use single quotes]test.ts.fix:
const value = 'double';# Run all tests in test directory
tslint --test test/rules/
# Run specific rule tests
tslint --test test/rules/my-rule/
# Run tests with custom rules directory
tslint --test test/rules/ --rules-dir custom-rules/import { Test } from 'tslint';
// Run single test directory
const testResult = Test.runTest('test/rules/my-rule', 'custom-rules');
// Handle results
Test.consoleTestResultHandler(testResult, console);
// Check if tests passed
const passed = Object.values(testResult.results).every(result => {
if (result.skipped) return true;
return result.errorsFromLinter.length === 0;
});
console.log(`Tests ${passed ? 'PASSED' : 'FAILED'}`);import { Test } from 'tslint';
import * as glob from 'glob';
// Run multiple test directories
const testDirs = glob.sync('test/rules/*');
const testResults = Test.runTests(testDirs, 'custom-rules');
// Process all results
Test.consoleTestResultsHandler(testResults, console);
// Generate summary
const summary = testResults.reduce((acc, result) => {
Object.values(result.results).forEach(test => {
if (test.skipped) {
acc.skipped++;
} else if (test.errorsFromLinter.length > 0) {
acc.failed++;
} else {
acc.passed++;
}
});
return acc;
}, { passed: 0, failed: 0, skipped: 0 });
console.log(`Summary: ${summary.passed} passed, ${summary.failed} failed, ${summary.skipped} skipped`);// test/rules/my-rule/default-options.ts.lint
console.log('test');
~~~~~~~~~~~~~~~~~~~ [Default message]
// test/rules/my-rule/custom-options.ts.lint
console.log('test');
~~~~~~~~~~~~~~~~~~~ [Custom message with option]
// test/rules/my-rule/tslint.json
{
"rules": {
"my-rule": [true, { "customMessage": "Custom message with option" }]
}
}Type-aware rules require a tsconfig.json in the test directory:
test/rules/my-typed-rule/
├── test.ts.lint
├── tslint.json
├── tsconfig.json
└── helper.ts # Additional files for type checkingtsconfig.json:
{
"compilerOptions": {
"target": "es2017",
"module": "commonjs",
"strict": true
},
"files": [
"test.ts",
"helper.ts"
]
}test.ts.lint:
import { Helper } from './helper';
const helper: Helper = new Helper();
const result: string = helper.getValue(); // Type info available
~~~~~~~~~~~~~~~ [Expected number, got string]Test JavaScript-specific rules by using .js.lint extension:
test/rules/my-js-rule/
├── test.js.lint
└── tslint.jsontslint.json:
{
"jsRules": {
"my-js-rule": true
}
}Skip tests based on conditions using special markup:
// test-conditional.ts.lint
/* tslint:disable-file */ // Skip entire file
// Or skip with reason
/// <reference path="./skipThis.d.ts" />
/* tslint:disable */
// This test is skipped because TypeScript version < 3.0
/* tslint:enable */import { Test, TestResult } from 'tslint';
import * as fs from 'fs';
import * as path from 'path';
class CustomTestRunner {
private rulesDirectory: string;
constructor(rulesDirectory: string) {
this.rulesDirectory = rulesDirectory;
}
async runAllTests(): Promise<boolean> {
const testDirs = this.findTestDirectories();
const results: TestResult[] = [];
for (const testDir of testDirs) {
const result = Test.runTest(testDir, this.rulesDirectory);
results.push(result);
}
return this.processResults(results);
}
private findTestDirectories(): string[] {
const testRoot = './test/rules';
return fs.readdirSync(testRoot)
.map(dir => path.join(testRoot, dir))
.filter(dir => fs.statSync(dir).isDirectory());
}
private processResults(results: TestResult[]): boolean {
let allPassed = true;
results.forEach(result => {
console.log(`\nTesting ${result.directory}:`);
Object.entries(result.results).forEach(([fileName, testOutput]) => {
if (testOutput.skipped) {
console.log(` ${fileName}: SKIPPED (${testOutput.reason})`);
} else {
const errors = testOutput.errorsFromLinter;
if (errors.length === 0) {
console.log(` ${fileName}: PASSED`);
} else {
console.log(` ${fileName}: FAILED`);
errors.forEach(error => {
console.log(` ${error.message} at ${error.startPos.line}:${error.startPos.col}`);
});
allPassed = false;
}
}
});
});
return allPassed;
}
}
// Usage
const runner = new CustomTestRunner('./custom-rules');
runner.runAllTests().then(passed => {
process.exit(passed ? 0 : 1);
});import { Test } from 'tslint';
import * as fs from 'fs';
interface CITestResult {
success: boolean;
summary: {
total: number;
passed: number;
failed: number;
skipped: number;
};
failures: Array<{
rule: string;
file: string;
errors: string[];
}>;
}
function runCITests(): CITestResult {
const testResults = Test.runTests(['test/rules/*'], 'src/rules');
const result: CITestResult = {
success: true,
summary: { total: 0, passed: 0, failed: 0, skipped: 0 },
failures: []
};
testResults.forEach(testResult => {
const ruleName = path.basename(testResult.directory);
Object.entries(testResult.results).forEach(([fileName, output]) => {
result.summary.total++;
if (output.skipped) {
result.summary.skipped++;
} else if (output.errorsFromLinter.length > 0) {
result.summary.failed++;
result.success = false;
result.failures.push({
rule: ruleName,
file: fileName,
errors: output.errorsFromLinter.map(e => e.message)
});
} else {
result.summary.passed++;
}
});
});
return result;
}
// Generate CI report
const ciResult = runCITests();
fs.writeFileSync('test-results.json', JSON.stringify(ciResult, null, 2));
if (!ciResult.success) {
console.error('Tests failed!');
process.exit(1);
}import { Test, TestResult } from 'tslint';
import * as fs from 'fs';
import * as glob from 'glob';
interface CoverageReport {
rulesCovered: string[];
rulesUntested: string[];
coveragePercentage: number;
testStats: {
totalTests: number;
passingTests: number;
failingTests: number;
};
}
function analyzeCoverage(): CoverageReport {
// Find all available rules
const ruleFiles = glob.sync('src/rules/*Rule.ts');
const availableRules = ruleFiles.map(file =>
path.basename(file, 'Rule.ts').replace(/([A-Z])/g, '-$1').toLowerCase().substring(1)
);
// Find tested rules
const testDirs = glob.sync('test/rules/*');
const testedRules = testDirs.map(dir => path.basename(dir));
// Run tests to get statistics
const testResults = Test.runTests(testDirs, 'src/rules');
const testStats = testResults.reduce((stats, result) => {
Object.values(result.results).forEach(output => {
stats.totalTests++;
if (!output.skipped) {
if (output.errorsFromLinter.length === 0) {
stats.passingTests++;
} else {
stats.failingTests++;
}
}
});
return stats;
}, { totalTests: 0, passingTests: 0, failingTests: 0 });
const rulesUntested = availableRules.filter(rule => !testedRules.includes(rule));
return {
rulesCovered: testedRules,
rulesUntested,
coveragePercentage: Math.round((testedRules.length / availableRules.length) * 100),
testStats
};
}
// Generate coverage report
const coverage = analyzeCoverage();
console.log(`Rule Coverage: ${coverage.coveragePercentage}% (${coverage.rulesCovered.length}/${coverage.rulesCovered.length + coverage.rulesUntested.length})`);
console.log(`Untested rules: ${coverage.rulesUntested.join(', ')}`);test/
├── rules/
│ ├── rule-name/
│ │ ├── basic/
│ │ │ ├── test.ts.lint
│ │ │ └── tslint.json
│ │ ├── with-options/
│ │ │ ├── test.ts.lint
│ │ │ └── tslint.json
│ │ ├── edge-cases/
│ │ │ ├── empty-file.ts.lint
│ │ │ ├── large-file.ts.lint
│ │ │ └── tslint.json
│ │ └── fixes/
│ │ ├── test.ts.lint
│ │ ├── test.ts.fix
│ │ └── tslint.jsonimport { Test } from 'tslint';
function debugFailedTest(testPath: string) {
const result = Test.runTest(testPath, 'custom-rules');
Object.entries(result.results).forEach(([fileName, output]) => {
if (!output.skipped && output.errorsFromLinter.length > 0) {
console.log(`\nDebugging ${fileName}:`);
console.log('Expected errors from markup:');
output.errorsFromMarkup.forEach(error => {
console.log(` ${error.startPos.line}:${error.startPos.col} - ${error.message}`);
});
console.log('Actual errors from linter:');
output.errorsFromLinter.forEach(error => {
console.log(` ${error.startPos.line}:${error.startPos.col} - ${error.message}`);
});
console.log('Differences:');
// Compare and show differences
const expectedMessages = new Set(output.errorsFromMarkup.map(e => e.message));
const actualMessages = new Set(output.errorsFromLinter.map(e => e.message));
expectedMessages.forEach(msg => {
if (!actualMessages.has(msg)) {
console.log(` Missing: ${msg}`);
}
});
actualMessages.forEach(msg => {
if (!expectedMessages.has(msg)) {
console.log(` Unexpected: ${msg}`);
}
});
}
});
}Install with Tessl CLI
npx tessl i tessl/npm-tslint