Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/vscode-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ jobs:
- name: Compile extension
run: npm run compile

- name: Test smell preferences
run: npm run test:preferences

- name: Compare regression corpus
run: npm run test:corpus

Expand Down
1 change: 1 addition & 0 deletions .vscodeignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ src/**
scripts/**
java/**
regression-corpus/**
test/**
.gitignore
tsconfig.json
package-lock.json
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,22 @@ If Java is installed under a different executable path, set
- Removes stale diagnostics as soon as a Java file changes.
- Can be disabled through the `testSmellDetector.analyzeOnSave` setting.

## Choose reported smells

Every supported smell is enabled by default. To choose which findings the
extension reports:

1. Open VS Code **Settings**.
2. Search for **Test Smell Detector**.
3. Under **Test Smell Detector > Smells**, check or clear individual smells.
4. Run **Analyze Current File** again, or save the test file when incremental
analysis is enabled.

Disabled smells are filtered from the Problems panel, Output panel, editor
squiggles, hover messages, and result notification on the next analysis. The
bundled engine still evaluates the file, so changing these preferences does
not alter tsDetect itself.

## Automated checks

The GitHub Actions workflow runs for pull requests targeting `main`, pushes to
Expand Down
127 changes: 127 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,132 @@
"type": "boolean",
"default": true,
"description": "Automatically analyze saved Java test files and refresh their diagnostics."
},
"testSmellDetector.smells.assertionRoulette": {
"type": "boolean",
"default": true,
"scope": "resource",
"description": "Report Assertion Roulette findings."
},
"testSmellDetector.smells.conditionalTestLogic": {
"type": "boolean",
"default": true,
"scope": "resource",
"description": "Report Conditional Test Logic findings."
},
"testSmellDetector.smells.constructorInitialization": {
"type": "boolean",
"default": true,
"scope": "resource",
"description": "Report Constructor Initialization findings."
},
"testSmellDetector.smells.defaultTest": {
"type": "boolean",
"default": true,
"scope": "resource",
"description": "Report Default Test findings."
},
"testSmellDetector.smells.dependentTest": {
"type": "boolean",
"default": true,
"scope": "resource",
"description": "Report Dependent Test findings."
},
"testSmellDetector.smells.duplicateAssert": {
"type": "boolean",
"default": true,
"scope": "resource",
"description": "Report Duplicate Assert findings."
},
"testSmellDetector.smells.eagerTest": {
"type": "boolean",
"default": true,
"scope": "resource",
"description": "Report Eager Test findings."
},
"testSmellDetector.smells.emptyTest": {
"type": "boolean",
"default": true,
"scope": "resource",
"description": "Report Empty Test findings."
},
"testSmellDetector.smells.exceptionCatchingThrowing": {
"type": "boolean",
"default": true,
"scope": "resource",
"description": "Report Exception Catching Throwing findings."
},
"testSmellDetector.smells.generalFixture": {
"type": "boolean",
"default": true,
"scope": "resource",
"description": "Report General Fixture findings."
},
"testSmellDetector.smells.ignoredTest": {
"type": "boolean",
"default": true,
"scope": "resource",
"description": "Report Ignored Test findings."
},
"testSmellDetector.smells.lazyTest": {
"type": "boolean",
"default": true,
"scope": "resource",
"description": "Report Lazy Test findings."
},
"testSmellDetector.smells.magicNumberTest": {
"type": "boolean",
"default": true,
"scope": "resource",
"description": "Report Magic Number Test findings."
},
"testSmellDetector.smells.mysteryGuest": {
"type": "boolean",
"default": true,
"scope": "resource",
"description": "Report Mystery Guest findings."
},
"testSmellDetector.smells.redundantAssertion": {
"type": "boolean",
"default": true,
"scope": "resource",
"description": "Report Redundant Assertion findings."
},
"testSmellDetector.smells.redundantPrint": {
"type": "boolean",
"default": true,
"scope": "resource",
"description": "Report Redundant Print findings."
},
"testSmellDetector.smells.resourceOptimism": {
"type": "boolean",
"default": true,
"scope": "resource",
"description": "Report Resource Optimism findings."
},
"testSmellDetector.smells.sensitiveEquality": {
"type": "boolean",
"default": true,
"scope": "resource",
"description": "Report Sensitive Equality findings."
},
"testSmellDetector.smells.sleepyTest": {
"type": "boolean",
"default": true,
"scope": "resource",
"description": "Report Sleepy Test findings."
},
"testSmellDetector.smells.unknownTest": {
"type": "boolean",
"default": true,
"scope": "resource",
"description": "Report Unknown Test findings."
},
"testSmellDetector.smells.verboseTest": {
"type": "boolean",
"default": true,
"scope": "resource",
"description": "Report Verbose Test findings."
}
}
}
Expand All @@ -55,6 +181,7 @@
"compile": "npm run clean && tsc -p ./",
"watch": "tsc -watch -p ./",
"check": "tsc --noEmit -p ./",
"test:preferences": "node --test test/smell-preferences.test.js",
"test:corpus": "node scripts/run-regression-corpus.js",
"package:vsix": "vsce package --out test-smell-detector.vsix"
},
Expand Down
46 changes: 34 additions & 12 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
isLikelyJavaTestDocument,
ProductionFileResolver,
} from './productionFileResolver';
import { isSmellEnabled } from './smellPreferences';
import { DetectorFinding, runDetector } from './testSmellDetector';

const ANALYZE_CURRENT_FILE_COMMAND = 'testSmellDetector.analyzeCurrentFile';
Expand Down Expand Up @@ -258,15 +259,36 @@ export function activate(context: vscode.ExtensionContext): void {
return;
}

if (result.detectedSmells.length === 0) {
const reportingConfiguration = vscode.workspace.getConfiguration(
'testSmellDetector',
document.uri,
);
const reportedFindings = result.findings.filter((finding) =>
isSmellEnabled(finding.name, (settingKey) =>
reportingConfiguration.get<boolean>(
`smells.${settingKey}`,
true,
),
),
);
const reportedSmells = result.detectedSmells.filter((smellName) =>
isSmellEnabled(smellName, (settingKey) =>
reportingConfiguration.get<boolean>(
`smells.${settingKey}`,
true,
),
),
);

if (reportedSmells.length === 0) {
diagnostics.delete(document.uri);
} else {
diagnostics.set(
document.uri,
createDiagnostics(
document,
result.findings,
result.detectedSmells,
reportedFindings,
reportedSmells,
),
);
}
Expand All @@ -280,22 +302,22 @@ export function activate(context: vscode.ExtensionContext): void {
output.appendLine(`Test methods: ${result.numberOfTestMethods}`);
output.appendLine('');

if (result.detectedSmells.length === 0) {
output.appendLine('No test smells detected.');
if (reportedSmells.length === 0) {
output.appendLine('No enabled test smells reported.');
output.show(true);
void vscode.window.showInformationMessage(
`No test smells detected in ${fileName}.`,
`No enabled test smells reported in ${fileName}.`,
);
} else {
output.appendLine(
`Detected smells (${result.detectedSmells.length}):`,
`Detected smells (${reportedSmells.length}):`,
);
for (const smell of result.detectedSmells) {
for (const smell of reportedSmells) {
output.appendLine(`- ${smell}`);
}
output.appendLine('');
output.appendLine(`Mapped findings (${result.findings.length}):`);
for (const finding of result.findings) {
output.appendLine(`Mapped findings (${reportedFindings.length}):`);
for (const finding of reportedFindings) {
const mappedElement = finding.location
? `${finding.scope} ${finding.elementName} at line ${finding.location.startLine}`
: 'file level (exact declaration unavailable)';
Expand All @@ -304,7 +326,7 @@ export function activate(context: vscode.ExtensionContext): void {
output.show(true);
void vscode.window
.showWarningMessage(
`${result.findings.length} test smell finding(s) detected in ${fileName}.`,
`${reportedFindings.length} test smell finding(s) detected in ${fileName}.`,
'Open Problems',
)
.then((selection) => {
Expand All @@ -317,7 +339,7 @@ export function activate(context: vscode.ExtensionContext): void {
}
} else {
reportAutomaticMessage(
`${fileName}: ${result.numberOfTestMethods} test method(s), ${result.findings.length} smell finding(s).`,
`${fileName}: ${result.numberOfTestMethods} test method(s), ${reportedFindings.length} enabled smell finding(s).`,
);
}
} catch (error: unknown) {
Expand Down
56 changes: 56 additions & 0 deletions src/smellPreferences.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/** A tsDetect smell and the VS Code setting used to enable its reporting. */
export interface SmellPreference {
name: string;
settingKey: string;
}

/** Every smell exposed as a checkbox in Test Smell Detector settings. */
export const SMELL_PREFERENCES: readonly SmellPreference[] = [
{ name: 'Assertion Roulette', settingKey: 'assertionRoulette' },
{ name: 'Conditional Test Logic', settingKey: 'conditionalTestLogic' },
{
name: 'Constructor Initialization',
settingKey: 'constructorInitialization',
},
{ name: 'Default Test', settingKey: 'defaultTest' },
{ name: 'Dependent Test', settingKey: 'dependentTest' },
{ name: 'Duplicate Assert', settingKey: 'duplicateAssert' },
{ name: 'Eager Test', settingKey: 'eagerTest' },
{ name: 'Empty Test', settingKey: 'emptyTest' },
{
name: 'Exception Catching Throwing',
settingKey: 'exceptionCatchingThrowing',
},
{ name: 'General Fixture', settingKey: 'generalFixture' },
{ name: 'Ignored Test', settingKey: 'ignoredTest' },
{ name: 'Lazy Test', settingKey: 'lazyTest' },
{ name: 'Magic Number Test', settingKey: 'magicNumberTest' },
{ name: 'Mystery Guest', settingKey: 'mysteryGuest' },
{ name: 'Redundant Assertion', settingKey: 'redundantAssertion' },
{ name: 'Redundant Print', settingKey: 'redundantPrint' },
{ name: 'Resource Optimism', settingKey: 'resourceOptimism' },
{ name: 'Sensitive Equality', settingKey: 'sensitiveEquality' },
{ name: 'Sleepy Test', settingKey: 'sleepyTest' },
{ name: 'Unknown Test', settingKey: 'unknownTest' },
{ name: 'Verbose Test', settingKey: 'verboseTest' },
];

const SETTING_KEYS_BY_SMELL = new Map(
SMELL_PREFERENCES.map(({ name, settingKey }) => [name, settingKey]),
);

/** Return the setting key used to enable or disable a known smell. */
export function getSmellSettingKey(smellName: string): string | undefined {
return SETTING_KEYS_BY_SMELL.get(smellName);
}

/** Whether a finding should be reported for the current settings. */
export function isSmellEnabled(
smellName: string,
readSetting: (settingKey: string) => boolean,
): boolean {
const settingKey = getSmellSettingKey(smellName);

// Preserve unfamiliar engine findings until a preference exists for them.
return settingKey === undefined || readSetting(settingKey);
}
56 changes: 56 additions & 0 deletions test/smell-preferences.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
const assert = require('node:assert/strict');
const { test } = require('node:test');

const packageJson = require('../package.json');
const {
SMELL_PREFERENCES,
isSmellEnabled,
} = require('../out/smellPreferences.js');

test('exposes one enabled-by-default checkbox for every supported smell', () => {
const properties = packageJson.contributes.configuration.properties;
const names = new Set();
const settingKeys = new Set();

assert.equal(SMELL_PREFERENCES.length, 21);
for (const preference of SMELL_PREFERENCES) {
assert.equal(names.has(preference.name), false);
assert.equal(settingKeys.has(preference.settingKey), false);
names.add(preference.name);
settingKeys.add(preference.settingKey);

const property =
properties[`testSmellDetector.smells.${preference.settingKey}`];
assert.ok(property, `Missing setting for ${preference.name}`);
assert.equal(property.type, 'boolean');
assert.equal(property.default, true);
}

const contributedSmellSettings = Object.keys(properties).filter((key) =>
key.startsWith('testSmellDetector.smells.'),
);
assert.equal(contributedSmellSettings.length, SMELL_PREFERENCES.length);
});

test('does not report a known smell when its setting is disabled', () => {
assert.equal(
isSmellEnabled('Magic Number Test', (settingKey) => {
assert.equal(settingKey, 'magicNumberTest');
return false;
}),
false,
);
});

test('reports a known smell when its setting is enabled', () => {
assert.equal(isSmellEnabled('Magic Number Test', () => true), true);
});

test('continues reporting an unfamiliar engine smell', () => {
assert.equal(
isSmellEnabled('Future Smell', () => {
throw new Error('Unknown smells must not read a nonexistent setting.');
}),
true,
);
});
Loading