Have you ever needed to verify that your code creates the right files and directories? Maybe you're building a CLI tool, scaffolding a project, or writing tests for a file-processing utility. In these scenarios, you want to ensure your code creates the expected file structure.
Jest has many built-in matchers for various types of comparisons, but surprisingly, none specifically for comparing file structures. In this post, we'll build a custom Jest matcher called toMatchFileStructure() that lets you elegantly validate directory contents in your tests.
Imagine you're writing a test for a project generator. You want to verify it creates these files:
Currently, you'd need to write verbose test code with multiple assertions:
test('Creates correct project structure', () => {
generateProject('my-project');
expect(fs.existsSync('my-project')).toBe(true);
expect(fs.existsSync('my-project/src')).toBe(true);
expect(fs.existsSync('my-project/src/index.js')).toBe(true);
expect(fs.existsSync('my-project/src/utils.js')).toBe(true);
expect(fs.existsSync('my-project/tests')).toBe(true);
expect(fs.existsSync('my-project/tests/index.test.js')).toBe(true);
expect(fs.existsSync('my-project/package.json')).toBe(true);
expect(fs.existsSync('my-project/README.md')).toBe(true);
});
Wouldn't it be nicer to write something like:
test('Creates correct project structure', () => {
generateProject('my-project');
expect('my-project').toMatchFileStructure(
{
src: {
'index.js': /export default/, // Checks that index.js contains this text
'utils.js': true
},
tests: {
'index.test.js': true
},
'package.json': true,
'README.md': true
},
{
ignoreFiles: ["node_modules"],
}
);
});
Let's build this matcher!
The heart of the matcher is a recursive function called checkFileStructure, which walks through the expected structure and compares it against the actual file system.
function checkFileStructure(basePath, expected, options = {}) {
const { ignoreFiles = [] } = options;
We begin by accepting a base directory, an expected structure object, and optional settings like a list of files or folders to ignore. This makes the function flexible enough for most testing scenarios.
for (const [name, value] of Object.entries(expected)) {
const fullPath = path.join(basePath, name);
if (ignoreFiles.includes(name) || ignoreFiles.includes(fullPath)) {
continue;
}
The function then iterates over each item in the expected structure. It joins the name with the base path to compute the full path. If the path or filename is in the ignore list, it's skipped.
if (!fs.existsSync(fullPath)) {
return {
pass: false,
message: () => `Expected path "${fullPath}" to exist, but it doesn't`,
};
}
It checks if the expected file or folder exists. If not, it returns early with a descriptive failure message.
if (
typeof value === "object" &&
value !== null &&
!(value instanceof RegExp)
) {
const stats = fs.statSync(fullPath);
if (!stats.isDirectory()) {
return {
pass: false,
message: () =>
`Expected "${fullPath}" to be a directory, but it's not`,
};
}
const result = checkFileStructure(fullPath, value, options);
if (!result.pass) {
return result;
}
}
If the value in the structure is an object, we treat it as a subdirectory. We confirm that the path is a directory and recursively check its contents. This lets us handle nested structures naturally.
else if (value !== true) {
const stats = fs.statSync(fullPath);
if (!stats.isFile()) {
return {
pass: false,
message: () => `Expected "${fullPath}" to be a file, but it's not`,
};
}
If the value isn't true, we expect this to be a file with content validation. First, we confirm it's a file.
const content = fs.readFileSync(fullPath, "utf8");
if (value instanceof RegExp) {
if (!value.test(content)) {
return {
pass: false,
message: () =>
`File "${fullPath}" content doesn't match regex pattern`,
};
}
} else if (typeof value === "function") {
const isValid = value(content);
if (!isValid) {
return {
pass: false,
message: () =>
`File "${fullPath}" content failed custom validation function`,
};
}
} else if (content !== value) {
return {
pass: false,
message: () =>
`File "${fullPath}" content doesn't match expected string`,
};
}
We support multiple ways to validate file contents. If the value is a regular expression, we test the file content against it. If it's a function, we call it with the content and check its return value. Otherwise, we compare the content to a literal string. This gives you a lot of flexibility in how strict or loose your tests are.
return {
pass: true,
message: () => `File structure matches expected pattern`,
};
}
If all checks pass, we return a success message.
Now let's integrate this logic into a custom Jest matcher so we can use it like a native expect function.
expect.extend({
toMatchFileStructure(received, expected, options = {}) {
const basePath = typeof received === "string" ? received : received.path;
if (!fs.existsSync(basePath)) {
return {
pass: false,
message: () =>
`Expected base path "${basePath}" to exist, but it doesn't`,
};
}
return checkFileStructure(basePath, expected, options);
},
});
We define a matcher called toMatchFileStructure that accepts a base path and expected structure. It calls the same recursive function under the hood.
Once the toMatchFileStructure matcher is available, using it in a test is straightforward. We describe the expected project structure using a nested object, where each key is a file or folder name, and the value defines how it should be validated.
expect("my-project").toMatchFileStructure(
{
src: {
"index.js": /export default/, // Checks that index.js contains this text
"utils.js": true,
},
"package.json": (content) => JSON.parse(content).name === "my-project",
"README.md": "# My Project\n\nA generated project.",
".git": true, // We just care it exists, don't check contents
},
{
ignoreFiles: ["node_modules"],
}
);
In this example, we check that the my-project folder contains a src directory with an index.js file matching a regex pattern and a utils.js file that simply exists. The package.json file is validated using a custom function to ensure it has the correct project name. For README.md, we compare the file's content to an exact string. The .git folder is checked for existence without looking at its contents. Finally, we tell the matcher to ignore the node_modules folder entirely.
This syntax keeps our test clean and expressive, while allowing precise control over both structure and content validation.
Custom Jest matchers like toMatchFileStructure() can significantly improve our test code's readability and maintainability. This matcher is particularly useful for:
By creating targeted, domain-specific matchers, we make our tests more expressive and our intentions clearer. The matcher we've built is relatively simple but highly useful for a common testing scenario.
Remember, good tests should tell a story about what our code does. Custom matchers help us write that story in clearer, more concise language.
You could extend this matcher further to:
If you'd like to experiment with this matcher or integrate it into your own test setup, the complete code example is available on our GitHub page. Feel free to explore, clone, and modify it to suit your project's needs.