The @nx/conformance/testing
package provides utilities for testing conformance rules in a controlled environment.
It's recommended for all conformance rule tests to follow the same pattern using createReadOnlyTree
and createStubbedProjectGraphAndFileMapCache
when setting up test workspaces:
import type { ReadOnlyConformanceTree } from '@nx/conformance';import { applyProjectNodesAndFiles, createReadOnlyTree, createStubbedProjectGraphAndFileMapCache,} from '@nx/conformance/testing';import type { ProjectGraph } from '@nx/devkit';import type { FileMapCache } from 'nx/src/project-graph/nx-deps-cache';import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import rule from './index';
describe('my-rule', () => { let tree: ReadOnlyConformanceTree; let projectGraph: ProjectGraph; let fileMapCache: FileMapCache; let cleanup: () => void;
beforeEach(async () => { // Create the test workspace in a temporary directory and return a read-only tree and a cleanup function ({ tree, cleanup } = await createReadOnlyTree()); // Prepare a stubbed project graph and file map cache for the test workspace ({ projectGraph, fileMapCache } = await createStubbedProjectGraphAndFileMapCache(tree, [ // Optionally add projects and files to the tree, project graph and file map cache if the rule implementation needs them // This can also be done later in specific tests using the `applyProjectNodesAndFiles` function ])); });
// Invoke the cleanup function to remove the generated temporary directory after each test afterEach(() => cleanup());
it('should return a violation when something specific happens', async () => { // Optionally add projects and files to the tree, project graph and file map cache if the rule implementation needs them applyProjectNodesAndFiles(tree, projectGraph, fileMapCache, [ { projectNode: { name: 'my-lib', type: 'lib', data: { root: 'libs/my-lib', }, }, projectFiles: [ // If our specific rule needs to know about project graph dependencies, we add them like so // In this example this entry causes my-lib to depend on my-app on the project graph and have it correctly attributed to my-lib in the file map cache { projectRootRelativeFile: 'src/index.ts', depsItCreates: ['my-app'], }, ], }, { projectNode: { name: 'my-app', type: 'app', data: { root: 'apps/my-app', }, }, }, ]);
const result = await rule.implementation({ tree, projectGraph, fileMapCache, ruleOptions: {}, });
expect(result.details.violations).toMatchInlineSnapshot(` // YOUR SNAPSHOT HERE `); });});
Adding Files to the Test Workspace
Section titled “Adding Files to the Test Workspace”You can optionally provide an async callback to createReadOnlyTree
to add files or even run Nx generators before the tests run:
const { tree, cleanup } = await createReadOnlyTree(async (writableTree) => { // Add files to the tree before it becomes read-only writableTree.write( 'libs/my-lib/custom-config.json', JSON.stringify({ setting: 'value' }) ); // Run some Nx generator await libraryGenerator(writableTree, { // generator options });});
Testing Fix Generators
Section titled “Testing Fix Generators”If your rule includes a fix generator, you can test it by converting the read-only tree to a writable one using convertToWritable
and then running the fix generator:
import { convertToWritable, convertToReadOnly } from '@nx/conformance/testing';
it('should fix violations when fix generator is applied', async () => { // ... setup and rule implementation test ...
// Convert to a WritableConformanceTree and run fix generator const writableTree = convertToWritable(tree); await rule.fixGenerator(writableTree, { violations: result.details.violations, ruleOptions: {}, });
const resultAfterFix = await rule.implementation({ // Convert back to a ReadOnlyConformanceTree for the rule implementation and verify the fix worked tree: convertToReadOnly(writableTree), projectGraph, fileMapCache, ruleOptions: {}, });
expect(resultAfterFix.details.violations).toMatchInlineSnapshot(`[]`);});
Complete Example
Section titled “Complete Example”Here's a complete example testing a rule that checks for license headers and includes a fix generator:
import type { ReadOnlyConformanceTree } from '@nx/conformance';import { applyProjectNodesAndFiles, createReadOnlyTree, createStubbedProjectGraphAndFileMapCache, convertToWritable, convertToReadOnly,} from '@nx/conformance/testing';import type { ProjectGraph } from '@nx/devkit';import type { FileMapCache } from 'nx/src/project-graph/nx-deps-cache';import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import rule from './license-header-rule';
describe('license-header-rule', () => { let tree: ReadOnlyConformanceTree; let projectGraph: ProjectGraph; let fileMapCache: FileMapCache; let cleanup: () => void;
beforeEach(async () => { ({ tree, cleanup } = await createReadOnlyTree(async (writableTree) => { // Create a test file without a license header writableTree.write( 'libs/my-lib/src/index.ts', 'export const foo = "bar";' ); }));
({ projectGraph, fileMapCache } = await createStubbedProjectGraphAndFileMapCache(tree, [ { projectNode: { name: 'my-lib', type: 'lib', data: { root: 'libs/my-lib', }, }, }, ])); });
afterEach(() => cleanup());
it('should detect missing license header', async () => { const result = await rule.implementation({ tree, projectGraph, fileMapCache, ruleOptions: { addHeader: true }, });
expect(result.details.violations).toHaveLength(1); expect(result.details.violations[0]).toMatchObject({ message: 'Missing license header', file: 'libs/my-lib/src/index.ts', }); });
it('should fix missing license header when fix generator is applied', async () => { const result = await rule.implementation({ tree, projectGraph, fileMapCache, ruleOptions: { addHeader: true }, });
expect(result.details.violations).toHaveLength(1);
// Apply the fix const writableTree = convertToWritable(tree); await rule.fixGenerator(writableTree, { violations: result.details.violations, ruleOptions: { addHeader: true }, });
// Verify the fix worked const resultAfterFix = await rule.implementation({ tree: convertToReadOnly(writableTree), projectGraph, fileMapCache, ruleOptions: { addHeader: true }, });
expect(resultAfterFix.details.violations).toHaveLength(0);
// Verify the file content const fixedContent = writableTree.read('libs/my-lib/src/index.ts', 'utf-8'); expect(fixedContent).toContain('/* LICENSE */'); });});
Conformance Testing Best Practices
Section titled “Conformance Testing Best Practices”- Use snapshots: Use
toMatchInlineSnapshot()
for violation arrays to easily see changes in test output. - Test isolation: Always use cleanup in the
afterEach
(or your testing framework equivalent) to ensure tests don't interfere with each other. - Test both detection and fixes: If your rule has a fix generator, test both that violations are detected and that the fix works correctly.
- Test edge cases: Test with empty workspaces, missing files, and various project configurations.
Writing Performant Conformance Rules
Section titled “Writing Performant Conformance Rules”Each rule will show its respective execution time and you can use this to identify rules that are slow to run.
Avoid Blocking the Main Thread
Section titled “Avoid Blocking the Main Thread”Sometimes you may notice that a rule that seems to take a while to complete when run as part of the full rule set but is much faster when run individually. This is because another rule is blocking the main thread. Conformance rules are executed in parallel on the main thread so it is important to avoid blocking actions in a particular rule or it will impact the execution of others.
Available Conformance Testing Utilities
Section titled “Available Conformance Testing Utilities”The @nx/conformance/testing
package exports the following utilities:
createReadOnlyTree(callback?)
Section titled “createReadOnlyTree(callback?)”Creates a read-only tree with a basic Nx workspace structure. Returns Promise<{ tree, cleanup }>
.
Parameters:
callback
(optional): An async function that receives a writable tree before it becomes read-only. Use this to add files or run generators.
Returns:
tree
: AReadOnlyConformanceTree
instancecleanup
: A function to call inafterEach
to remove the temporary directory
createStubbedProjectGraphAndFileMapCache(tree, projectNodesWithFiles)
Section titled “createStubbedProjectGraphAndFileMapCache(tree, projectNodesWithFiles)”Creates a stubbed project graph and file map cache for testing. Returns Promise<{ projectGraph, fileMapCache }>
.
Parameters:
tree
: TheReadOnlyConformanceTree
fromcreateReadOnlyTree
projectNodesWithFiles
: An array ofProjectNodesWithFiles
to initialize the graph with (can be empty)
Returns:
projectGraph
: A stubbedProjectGraph
instancefileMapCache
: A stubbedFileMapCache
instance
applyProjectNodesAndFiles(tree, projectGraph, fileMapCache, projectNodesWithFiles)
Section titled “applyProjectNodesAndFiles(tree, projectGraph, fileMapCache, projectNodesWithFiles)”Adds projects and files to the test workspace, updating both the tree and the project graph.
Parameters:
tree
: TheReadOnlyConformanceTree
projectGraph
: TheProjectGraph
to updatefileMapCache
: TheFileMapCache
to updateprojectNodesWithFiles
: An array ofProjectNodesWithFiles
describing projects and their files to add
convertToWritable(tree)
Section titled “convertToWritable(tree)”Converts a ReadOnlyConformanceTree
to a WritableConformanceTree
for fix generator testing.
Parameters:
tree
: TheReadOnlyConformanceTree
to convert
Returns: A WritableConformanceTree
instance
convertToReadOnly(tree)
Section titled “convertToReadOnly(tree)”Converts a WritableConformanceTree
back to a ReadOnlyConformanceTree
.
Parameters:
tree
: TheWritableConformanceTree
to convert
Returns: A ReadOnlyConformanceTree
instance
type ProjectNodesWithFiles
Section titled “type ProjectNodesWithFiles”A type that represents the projects and files to be added to the test workspace:
type ProjectNodesWithFiles = { projectNode: { name: string; type: string; data: { root: string; // ... other project data }; }; projectFiles?: Array<{ projectRootRelativeFile: string; depsItCreates?: string[]; }>;};