Skip to content

Commit 2672731

Browse files
committed
feat: implement automatic test file detection and allow unrestricted imports for test files
1 parent 876aed2 commit 2672731

File tree

9 files changed

+217
-17
lines changed

9 files changed

+217
-17
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ A custom ESLint plugin for enforcing modular patterns in Vue 3 projects.
1414
- Custom linting rules for Vue 3 modular architecture
1515
- Supports single-file components (SFC)
1616
- Enforces architectural boundaries between features
17+
- **Automatic test file detection** - test files can import from anywhere without restrictions
1718
- Supports both flat config (ESLint v9+) and legacy config formats
1819
- Easily extendable for your team's needs
1920

docs/rules/enforce-import-boundaries.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,9 @@ Use the `allow` option to whitelist specific import patterns when necessary. The
7575

7676
- The rule ignores imports not starting with the configured `src` root, relative imports are resolved against the importer file.
7777
- Type-only imports can be ignored by setting `ignoreTypeImports: true` (default).
78-
- Test files may require special allowances. Use the `allow` option to whitelist `tests/**` or add ESLint override sections for test directories.
78+
- Test files are automatically detected and allowed to import from anywhere without restrictions. This includes:
79+
- Files in `tests/` directories (e.g., `tests/unit/auth.test.js`, `/project/tests/integration/`)
80+
- Files with `.test.` in the name (e.g., `component.test.js`, `utils.test.ts`)
81+
- Files with `.spec.` in the name (e.g., `component.spec.js`, `utils.spec.ts`)
82+
- Files in `__tests__/` directories (e.g., `src/__tests__/setup.js`, `src/components/__tests__/Button.js`)
83+
- For additional custom patterns, use the `allow` option to whitelist specific import patterns or add ESLint override sections for custom test directories.

docs/vue3-project-modules-blueprint.md

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -217,29 +217,25 @@ export type { SearchResult, SearchFilters } from './entities/SearchResult'
217217

218218
## Testing and test folder exceptions
219219

220-
Tests often need deeper access to implementation details. Test code (placed under `tests/`, `__tests__/` or similar) is allowed to import from any layer — including internal module files — to make unit and integration testing practical.
220+
Tests often need deeper access to implementation details. Test code is automatically allowed to import from any layer — including internal module files — to make unit and integration testing practical.
221221

222-
This allowance also applies to individual test files following common naming patterns such as `*.test.*` or `*.spec.*` (for example: `some.test.ts`, `some.spec.ts`, `some.test.tsx`).
222+
**Automatic Test File Detection**: The ESLint rules automatically detect test files and allow them unrestricted imports. This includes:
223+
224+
- Files in `tests/` directories (e.g., `tests/unit/auth.test.js`, `/project/tests/integration/`)
225+
- Files with `.test.` in the name (e.g., `component.test.js`, `utils.test.ts`)
226+
- Files with `.spec.` in the name (e.g., `component.spec.js`, `utils.spec.ts`)
227+
- Files in `__tests__/` directories (e.g., `src/__tests__/setup.js`, `src/components/__tests__/Button.js`)
228+
229+
**No additional configuration required** - test files are detected automatically and exempt from import boundary restrictions.
223230

224231
Recommended patterns:
225232

226233
- Unit tests: importing internal files directly is acceptable to test implementation specifics.
227234
- Integration tests: prefer exercising modules/features through their public API (`index.ts`) to validate real integration points.
228235
- Test helpers: create a `tests/test-utils.ts` or `tests/utils/` that re-exports common setup/mocks; prefer adapters that call public APIs when feasible to ease refactors.
229236
- Organization: keep internal-focused tests separate (e.g. `tests/internal/`) from cross-module/integration tests.
230-
- Tooling: configure ESLint rules to allow `tests/**` exceptions (for example, whitelist `tests/**` in the `no-cross-module-imports` rule) so test imports do not trigger violations.
231-
232-
Example ESLint whitelist pattern (conceptual):
233-
234-
`allowedPatterns: ['tests/**', '**/*.spec.{js,ts,tsx}', '**/*.test.{js,ts,tsx}']`
235-
236-
Note: if your tests are located outside `src/` (for example a top-level `tests/` folder), some ESLint configurations or path-based rule implementations that only scan `src/**` may ignore them. To ensure test files are checked:
237-
238-
- Add the test paths to your ESLint file globs or the rule's whitelist (as shown above).
239-
- Use an `.eslintrc` `overrides` section to enable rules for `**/*.spec.*` / `**/*.test.*` and `tests/**` paths.
240-
- Or place tests under `src/` so they fall under existing `src/**` globs.
241237

242-
These options make sure test-only imports are permitted for testing purposes while keeping runtime import rules enforced for production code.
238+
For custom test patterns not covered by automatic detection, you can still use the `allow` option in ESLint rule configurations to whitelist specific patterns.
243239

244240
Keep test-only imports clearly flagged as test code and out of production bundles (tests should not accidentally ship in the app). These exceptions exist to improve testability without weakening runtime architectural guarantees.
245241

src/index.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import enforceFeatureExports from './rules/enforce-feature-exports.js'
99
import enforceImportBoundaries from './rules/enforce-import-boundaries.js'
1010
import enforceNamingConvention from './rules/enforce-naming-convention.js'
1111

12+
// Import utilities
13+
import { isTestFile } from './utils/import-boundaries.js'
14+
1215
const plugin = {
1316
meta,
1417
rules: {
@@ -23,6 +26,9 @@ const plugin = {
2326
},
2427
processors: {},
2528
configs: {},
29+
utils: {
30+
isTestFile,
31+
},
2632
}
2733

2834
// Flat config for ESLint v9+

src/rules/enforce-import-boundaries.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import path from 'node:path'
2+
import { isTestFile } from '../utils/import-boundaries.js'
23

34
const defaultOptions = {
45
src: 'src',
@@ -20,6 +21,12 @@ function resolveAlias(importPath, aliases) {
2021

2122
function getLayerForFile(filePath, options) {
2223
if (!filePath) return null
24+
25+
// Check if this is a test file first
26+
if (isTestFile(filePath)) {
27+
return { layer: 'test' }
28+
}
29+
2330
const parts = path.normalize(filePath).split(path.sep)
2431
const srcIdx = parts.indexOf(options.src)
2532
if (srcIdx === -1) return null
@@ -110,6 +117,9 @@ export default {
110117
const importerFilename = context.getFilename()
111118
if (!importerFilename || importerFilename === '<input>') return
112119

120+
// Allow test files to import from anywhere without restrictions
121+
if (isTestFile(importerFilename)) return
122+
113123
let resolved = resolveAlias(importPathRaw, opts.aliases)
114124
// support common @/ alias mapping to src when not configured
115125
if (resolved.startsWith('@/')) {

src/rules/no-cross-feature-imports.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isDeepFeatureImport, getModulePublicImport, isWithinSameFeature } from '../utils/import-boundaries.js'
1+
import { isDeepFeatureImport, getModulePublicImport, isWithinSameFeature, isTestFile } from '../utils/import-boundaries.js'
22

33
export default {
44
meta: {
@@ -51,6 +51,9 @@ export default {
5151
ImportDeclaration(node) {
5252
const source = node.source.value
5353

54+
// Allow test files to import from anywhere without restrictions
55+
if (isTestFile(filename)) return
56+
5457
const modulePublicName = getModulePublicImport(source, opts)
5558
if (modulePublicName) {
5659
const isAppLayerFile = filename.includes(`/${src}/app/`)

src/rules/no-cross-module-imports.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isDeepModuleImport, isWithinSameModule, applyAliases } from '../utils/import-boundaries.js'
1+
import { isDeepModuleImport, isWithinSameModule, applyAliases, isTestFile } from '../utils/import-boundaries.js'
22

33
export default {
44
meta: {
@@ -46,6 +46,9 @@ export default {
4646
const source = node.source.value
4747
if (!source.includes('/')) return
4848

49+
// Allow test files to import from anywhere without restrictions
50+
if (isTestFile(filename)) return
51+
4952
// normalize input to helper expectations
5053
const opts = { src, modulesDir }
5154
const aliased = applyAliases(source, options.aliases || {}, opts.src)

src/utils/import-boundaries.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,35 @@ export const defaultOptions = {
77
aliases: {},
88
}
99

10+
/**
11+
* Check if a file is a test file based on common patterns
12+
* @param {string} filePath - Path to the file
13+
* @returns {boolean} - True if the file is a test file
14+
*/
15+
export function isTestFile(filePath) {
16+
if (!filePath) return false
17+
18+
const normalizedPath = path.normalize(filePath)
19+
const basename = path.basename(filePath)
20+
21+
// Check if file is in a tests directory (sibling to src or anywhere in the path)
22+
if (normalizedPath.includes('/tests/') || normalizedPath.includes('\\tests\\')) {
23+
return true
24+
}
25+
26+
// Check if file has test patterns in the name
27+
if (basename.includes('.test.') || basename.includes('.spec.')) {
28+
return true
29+
}
30+
31+
// Check for __tests__ directories (common Jest pattern)
32+
if (normalizedPath.includes('/__tests__/') || normalizedPath.includes('\\__tests__\\')) {
33+
return true
34+
}
35+
36+
return false
37+
}
38+
1039
export function applyAliases(importPath, aliases, defaultSrc) {
1140
for (const [k, v] of Object.entries(aliases || {})) {
1241
if (importPath === k || importPath.startsWith(k + '/')) {

tests/test-files-handling.spec.js

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { describe, it, beforeEach, expect } from 'vitest'
2+
import { RuleTester } from 'eslint'
3+
import plugin from '../src/index.js'
4+
5+
describe('Test files handling', () => {
6+
let ruleTester
7+
8+
beforeEach(() => {
9+
ruleTester = new RuleTester({
10+
languageOptions: {
11+
ecmaVersion: 2022,
12+
sourceType: 'module',
13+
},
14+
plugins: {
15+
'vue-modular': plugin,
16+
},
17+
})
18+
19+
if (global.__eslintVueModularState) delete global.__eslintVueModularState
20+
})
21+
22+
describe('enforce-import-boundaries', () => {
23+
it('should allow test files to import from anywhere', () => {
24+
ruleTester.run('enforce-import-boundaries', plugin.rules['enforce-import-boundaries'], {
25+
valid: [
26+
// ✅ Test files in tests/ directory can import module internals
27+
{
28+
code: "import userService from '@/modules/user/services/userService'",
29+
filename: '/project/tests/user.test.js',
30+
},
31+
{
32+
code: "import { UserComponent } from '@/modules/user/components/UserProfile'",
33+
filename: '/project/tests/unit/user-module.spec.js',
34+
},
35+
// ✅ Test files with .test. pattern can import feature internals
36+
{
37+
code: "import loginValidator from '@/features/auth/validators/loginValidator'",
38+
filename: '/project/src/features/auth/login.test.js',
39+
},
40+
{
41+
code: "import { searchHelpers } from '@/features/search/utils/helpers'",
42+
filename: '/project/src/utils/search.spec.ts',
43+
},
44+
// ✅ Test files with .spec. pattern can import app internals
45+
{
46+
code: "import appConfig from '@/app/config/settings'",
47+
filename: '/project/src/app/app.spec.js',
48+
},
49+
// ✅ Test files in __tests__ directory can import anything
50+
{
51+
code: "import { StoreModule } from '@/stores/user'",
52+
filename: '/project/src/modules/auth/__tests__/auth-store.test.js',
53+
},
54+
{
55+
code: "import componentHelpers from '@/components/Button/helpers'",
56+
filename: '/project/src/__tests__/components.spec.js',
57+
},
58+
// ✅ Top-level tests folder (sibling to src) can import anything
59+
{
60+
code: "import { getAllModules } from '@/modules/admin/internal/utils'",
61+
filename: '/project/tests/integration/admin.test.js',
62+
},
63+
],
64+
invalid: [],
65+
})
66+
})
67+
})
68+
69+
describe('no-cross-module-imports', () => {
70+
it('should allow test files to import across modules', () => {
71+
ruleTester.run('no-cross-module-imports', plugin.rules['no-cross-module-imports'], {
72+
valid: [
73+
// ✅ Test files can import deep into other modules
74+
{
75+
code: "import userService from '@/modules/user/services/userService'",
76+
filename: '/project/tests/admin.test.js',
77+
},
78+
{
79+
code: "import { AdminComponent } from '../../modules/admin/components/AdminPanel'",
80+
filename: '/project/src/modules/user/user.spec.js',
81+
},
82+
// ✅ Files with test patterns can cross module boundaries
83+
{
84+
code: "import billingUtils from '@/modules/billing/utils/calculator'",
85+
filename: '/project/src/modules/user/billing.test.ts',
86+
},
87+
],
88+
invalid: [],
89+
})
90+
})
91+
})
92+
93+
describe('no-cross-feature-imports', () => {
94+
it('should allow test files to import across features', () => {
95+
ruleTester.run('no-cross-feature-imports', plugin.rules['no-cross-feature-imports'], {
96+
valid: [
97+
// ✅ Test files can import deep into other features
98+
{
99+
code: "import authValidator from '@/features/auth/validators/loginValidator'",
100+
filename: '/project/tests/search.test.js',
101+
},
102+
{
103+
code: "import { SearchComponent } from '../../features/search/components/SearchBar'",
104+
filename: '/project/src/features/auth/auth.spec.js',
105+
},
106+
// ✅ Files with test patterns can cross feature boundaries
107+
{
108+
code: "import notificationHelpers from '@/features/notifications/utils/helpers'",
109+
filename: '/project/src/features/user/notifications.test.ts',
110+
},
111+
],
112+
invalid: [],
113+
})
114+
})
115+
})
116+
117+
describe('isTestFile utility', () => {
118+
it('should correctly identify test files', () => {
119+
const { isTestFile } = plugin.utils || {}
120+
121+
if (isTestFile) {
122+
// Files in tests/ directory
123+
expect(isTestFile('/project/tests/user.js')).toBe(true)
124+
expect(isTestFile('/project/tests/unit/auth.js')).toBe(true)
125+
expect(isTestFile('/project/src/tests/helpers.js')).toBe(true)
126+
127+
// Files with .test. pattern
128+
expect(isTestFile('/project/src/components/Button.test.js')).toBe(true)
129+
expect(isTestFile('/project/src/utils/helpers.test.ts')).toBe(true)
130+
131+
// Files with .spec. pattern
132+
expect(isTestFile('/project/src/components/Button.spec.js')).toBe(true)
133+
expect(isTestFile('/project/src/utils/helpers.spec.ts')).toBe(true)
134+
135+
// Files in __tests__ directory
136+
expect(isTestFile('/project/src/__tests__/setup.js')).toBe(true)
137+
expect(isTestFile('/project/src/components/__tests__/Button.js')).toBe(true)
138+
139+
// Non-test files
140+
expect(isTestFile('/project/src/components/Button.js')).toBe(false)
141+
expect(isTestFile('/project/src/utils/helpers.js')).toBe(false)
142+
expect(isTestFile('/project/src/stores/user.js')).toBe(false)
143+
expect(isTestFile('/project/src/modules/auth/index.js')).toBe(false)
144+
}
145+
})
146+
})
147+
})

0 commit comments

Comments
 (0)