No release in over 3 years
Provides a rich DSL for RSpec to assert on files, directories, and symlinks, including permissions, content (including JSON), and more. Ideal for testing generators and build scripts.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
 Dependencies

Development

~> 1.15
~> 13.2
~> 3.6
~> 3.13
~> 1.74
~> 0.22
~> 0.9, >= 0.9.28
~> 0.9

Runtime

 Project Readme

The rspec-path_matchers gem

Gem Version Build Status MIT License

  • Summary
  • Added Value Over Standard RSpec Matchers
  • Installation
  • Setup
  • Usage And Examples
    • Basic Assertions
    • Negative Assertions (Checking for Absence)
    • File Content Assertions
    • Attribute Assertions
    • Directory Structure Assertions
    • Exact Directory Contents
  • Development
  • Contributing
  • License

Summary

RSpec::PathMatchers provides a comprehensive suite of RSpec matchers for testing file system entries and structures.

Verifying that a generator, build script, or any file-manipulating process has produced the correct output can be tedious and verbose. This gem makes those assertions simple, declarative, and easier to read, allowing you to describe an entire file tree and its properties within your specs. For example:

require 'rspec/path_matchers'

RSpec.describe 'The newly generated project' do
  it "should contain project files" do
    project_dir = Dir.pwd

    expect(project_dir).to(
      be_dir.containing(
        file("README.md", content: include('MyProject'), birthtime: within(10_000).of(Time.now)),
        dir("lib").containing_exactly(
          file("my_project.rb"),
          dir("my_project").containing_exactly(
            file("version.rb", content: include('VERSION = "0.1.0"'), size: be < 1000)
          )
        ),
        no_dir_named('tmp')
      )
    )
  end
end

Added Value Over Standard RSpec Matchers

Here’s a breakdown of the value this API provides over what is available in standard RSpec.

1. Abstraction and Readability: From Imperative to Declarative

Standard RSpec forces you to describe how to test something. This API allows you to declaratively state what the directory structure should look like.

Without this API (Imperative Style):

# This code describes the HOW: stat the file, get the mode, convert to octal...
# It's a script, not a specification.
path = '/var/www/html/index.html'
expect(File.exist?(path)).to be true
expect(File.stat(path).mode.to_s(8)[-4..]).to eq('0644')
expect(File.stat(path).owned?).to be true # This just checks if UID matches script runner

With this API (Declarative Style):

# This code describes the WHAT. The implementation details are hidden.
# It reads like a specification document.
expect('/var/www/html').to have_file('index.html', mode: '0644', owner: 'httpd')

This hides the complex, imperative logic inside the matcher and exposes a clean, readable, domain-specific language (DSL).

2. Conciseness and Cohesion: Grouping Related Assertions

Without this API, testing multiple attributes of a single file requires fragmented, repetitive expect calls. Your API groups these assertions into one cohesive, logical block.

Without this API:

path = '/var/data/status.json'
expect(File.file?(path)).to be true
expect(File.size(path)).to be > 0
expect(File.read(path)).not_to include('error')
expect(JSON.parse(File.read(path))['status']).to eq('complete')

With this API:

expect('/var/data').to have_file('status.json',
    size: be > 0,
    content: not(/error/),
    json_content: include('status' => 'complete')
)

This is far easier to read and maintain because all the assertions about status.json are in one place.

3. The Nested Directory DSL Adds to the Expressive Power

This is where this API provides something that base RSpec simply cannot do elegantly. Describing the state of a directory tree with standard RSpec is incredibly verbose and difficult to read.

Without this API:

# This is hard to read and mentally parse.
dir_path = '/etc/service/nginx'
expect(Dir.exist?(dir_path)).to be true
expect(File.exist?(File.join(dir_path, 'run'))).to be true
expect(Dir.exist?(File.join(dir_path, 'log'))).to be true
expect(File.exist?(File.join(dir_path, 'down'))).to be false

With this API:

# This is a clear, hierarchical specification of the directory's contents.
expect('/etc/service/nginx').to(
  be_dir.containing(
    file('run'),
    dir('log'),
    no_file_named('down')
  )
)

The nested containing matchers allows you to write tests that humans can make sense of.

4. Descriptive and Intelligible Failure Messages

When a complex, nested expectation fails, this gem pinpoints the exact failure, saving you valuable debugging time.

Standard RSpec Failure:

expected: true
got: false

This kind of message forces you to manually inspect the directory structure to understand what went wrong.

With this API:

You get a detailed, hierarchical report that shows the full expectation and clearly marks what failed.

For this expectation:

expect('config').to(
  be_dir.containing(
    file('database.xml', owner: 'db_user', mode: '0600')
  )
)
'/tmp/d20250622-12345-abcdef/my-app' was expected to satisfy the following but did not:
- have directory "config" containing:
    - have file "database.yml" with owner "db_user" and mode "0600"
    - expected owner to be "db_user", but was "root"
    - expected mode to be "0600", but was "0644"

Installation

Add this line to your application's Gemfile in the :test or :development group:

group :test, :development do
  gem 'rspec-path_matchers'
end

And then execute:

$ bundle install

Or install it yourself as:

$ gem install rspec-path_matchers

Setup

Require the gem in your spec/spec_helper.rb file:

# spec/spec_helper.rb
require 'rspec/path_matchers'

Usage And Examples

All matchers operate on a base path, making your tests clean and portable.

Basic Assertions

At its simplest, you can check for the existence of files and directories.

it "creates the basic structure" do
  # Setup: Create some files and directories in our temp dir
  FileUtils.mkdir_p(File.join(@tmpdir, "app/models"))
  FileUtils.touch(File.join(@tmpdir, "config.yml"))

  # Assertions
  expect(@tmpdir).to have_file("config.yml")
  expect(@tmpdir).to have_dir("app")
end

Negative Assertions (Checking for Absence)

You can use not_to to ensure that a file, directory, or symlink of a specific type does not exist.

  • not_to have_file passes if the entry is a directory, a symlink, or non-existent.
  • not_to have_dir passes if the entry is a file, a symlink, or non-existent.
  • not_to have_symlink passes if the entry is a file, a directory, or non-existent.

Important: Negative matchers cannot be given options (:mode, :content, etc.) or blocks.

it "can check for the absence of entries" do
  # Setup
  Dir.mkdir(File.join(@tmpdir, "existing_dir"))
  File.write(File.join(@tmpdir, "existing_file.txt"), "content")

  # Assert that a path that doesn't exist fails all checks
  expect(@tmpdir).not_to have_file("non_existent.txt")
  expect(@tmpdir).not_to have_dir("non_existent_dir")
  expect(@tmpdir).not_to have_symlink("non_existent_link")

  # Assert that an existing directory is NOT a file or symlink
  expect(@tmpdir).not_to have_file("existing_dir")
  expect(@tmpdir).not_to have_symlink("existing_dir")
  # expect(@tmpdir).not_to have_dir("existing_dir") # This would fail

  # Assert that an existing file is NOT a directory or symlink
  expect(@tmpdir).not_to have_dir("existing_file.txt")
  expect(@tmpdir).not_to have_symlink("existing_file.txt")
  # expect(@tmpdir).not_to have_file("existing_file.txt") # This would fail
end

File Content Assertions

Go beyond existence and inspect what's inside a file.

before do
  File.write(File.join(@tmpdir, "app.log"), "INFO: User logged in\nWARN: Low disk space")
  File.write(File.join(@tmpdir, "config.json"), '{"theme":"dark","version":2}')
  FileUtils.touch(File.join(@tmpdir, "empty.file"))
end

it "validates file content" do
  # Check for content with a string or regex
  expect(@tmpdir).to have_file("app.log", content: "INFO: User logged in")
  expect(@tmpdir).to have_file("app.log", content: /WARN:.*space/)

  # Check for the absence of content
  expect(@tmpdir).to have_file("app.log", content: not(/ERROR/))

  # Check if a file is empty
  expect(@tmpdir).to have_file("empty.file", size: 0)

  # Check for valid JSON and match its structure
  expect(@tmpdir).to have_file("config.json", json_content: {
    "theme" => "dark",
    "version" => an_instance_of(Integer)
  })
end

Attribute Assertions

Matcher options allow detailed expectations on files, directories, and symlinks. Here are the options available on the three top level matchers:

expect(path).to have_file(
  name, mode:, owner:, group:, ctime:, mtime:, size:, content:, json_content:, yaml_content:,
)

expect(path).to have_dir(
  name, mode:, owner:, group:, ctime:, mtime:, exact:
)

expect(path).to have_symlink(
  name, mode:, owner:, group:, ctime:, mtime:, target:, target_type:, target_exist:
)

Here is a detailed example of using options:

before do
  # Create a script, a secret key, and a symlink
  script_path = File.join(@tmpdir, "deploy.sh")
  File.write(script_path, "#!/bin/bash\n...")
  FileUtils.chmod(0755, script_path)

  key_path = File.join(@tmpdir, "secret.key")
  File.write(key_path, "KEY_DATA")
  FileUtils.chmod(0600, key_path)

  FileUtils.ln_s("deploy.sh", File.join(@tmpdir, "latest_script"))
end

it "validates file attributes" do
  # A single file can have many attributes checked at once
  expect(@tmpdir).to have_file("deploy.sh", mode: "0755", size: be > 10)

  # On Unix systems, you can check ownership
  current_user = Etc.getlogin
  expect(@tmpdir).to have_file("secret.key", owner: current_user, mode: "0600")

  # Check symlinks and their targets
  expect(@tmpdir).to have_symlink("latest_script", target: "deploy.sh")
end

Directory Structure Assertions

The block syntax is the most powerful feature. It allows you to describe and verify an entire file tree, including both the presence and absence of entries using methods like no_dir_named, no_file_named, and no_symlink_named.

before do
  # Generate a complex directory structure
  app_dir = File.join(@tmpdir, "my-app")
  FileUtils.mkdir_p(File.join(app_dir, "bin"))
  FileUtils.mkdir_p(File.join(app_dir, "config"))
  FileUtils.mkdir_p(File.join(app_dir, "log"))

  File.write(File.join(app_dir, "bin/run"), "#!/bin/bash")
  FileUtils.chmod(0755, File.join(app_dir, "bin/run"))

  File.write(File.join(app_dir, "config/database.yml"), "adapter: postgresql")
  FileUtils.ln_s("database.yml", File.join(app_dir, "config/db.yml"))
end

it "validates a nested directory structure" do
  # Note the parentheses around the matcher and its block
  expect(@tmpdir).to(have_dir("my-app") do
    # Assert on the 'bin' directory and its contents
    dir "bin" do
      file "run", mode: "0755", content: /bash/
    end

    # Assert on the 'config' directory and its contents
    dir "config" do
      file "database.yml"
      symlink "db.yml", target: "database.yml"
      no_file "secrets.yml" # Assert that a file is NOT present
    end

    # Assert that the 'log' directory is present and empty
    dir "log"

    # Assert the absence of other entries at the root of 'my-app'
    no_dir_named "tmp"
    no_file "README.md"
  end)
end

Exact Directory Contents

You can enforce that a directory contains only the entries defined in your specification block by using the exact: true option. This is perfect for testing generators or build scripts that should produce a clean, specific output without any extra files.

If any undeclared entries are found on the PathMatchers, the matcher will fail.

it "creates a directory with only the expected files" do
  # Setup: Create a directory with an extra, unexpected file.
  FileUtils.mkdir(File.join(@tmpdir, 'dist'))
  File.write(File.join(@tmpdir, 'dist/app.js'), '// ...')
  File.write(File.join(@tmpdir, 'dist/unexpected.log'), 'debug info')

  # This test will fail because 'unexpected.log' was not declared.
  expect(@tmpdir).to(
    have_dir('dist', exact: true) do
      file 'app.js'
    end
  )
end

# Failure Message:
#
# the entry 'dist' at '...' was expected to satisfy the following but did not:
#   - did not expect entries ["unexpected.log"] to be present

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/main-branch/rspec-path_matchers. This project is intended to be a safe, welcoming space for collaboration.

License

The gem is available as open source under the terms of the MIT License.