Our very own Dan Mayer will be at Lone Star Ruby Conf Friday and Saturday! Be sure to talk to Dan about Devver, testing, or anything else!
-
Devver is headed to Lone Star Ruby Conf!
by Ben
-
Unit Testing Filesystem Interaction
by Ben
Like most Rubyists, I write unit tests to verify the non-trivial parts of my code. I also try to use mocks and stubs to stub out interactions with systems external to my code, like network services.
For the most part, this works fine. But I've always struggled to find a good way to test interaction with the filesystem (which can often be non-trivial and therefore should be tested). On the one hand, the filesystem could be considered "external" and mocked out. But on the other hand, the filesystem is accessible when the tests run. In this way, the filesystem is sort of like a local database - it could be mocked out, but it doesn't have to be, and there are tradeoffs to both approaches.
Over the past year or so, I've tried out a few approaches for testing interactions with the filesystem, each of which I'll explain below. Since none of the approaches met my needs, Avdi and I built a new testing library, which I'll introduce below.
Mocking the file system.
Sometimes, it is simplest to just mock the interaction with the filesystem. This works well for single calls to methods like
File#readorFile#exist?(these examples use Mocha):File.stubs(:read).returns("file contents") File.stubs(:exist?).returns(true)However, this approach breaks down when you want to test more complex code, which, of course, is the code you're more likely to want to test thoroughly. For instance, imagine trying to set up mocks/stubs for the following method (which atomically rewrites the contents of a file):
require 'tempfile' class Rewriter def rewrite_file!(target_path) backup_path = target_path + '.bak' FileUtils.mv(target_path, backup_path) Tempfile.open(File.basename(target_path)) do |outfile| File.open(backup_path) do |infile| infile.each_line do |line| outfile.write(yield(line)) end end outfile.close FileUtils.cp(outfile.path, target_path) end rescue Exception if File.exist?(backup_path) FileUtils.mv(backup_path, target_path) end raise end endNow imagine setting up those same mocks/stubs for each of the five or so tests you'd want to test that method. It gets messy.
Even more importantly, mocking/stubbing out methods ties your tests to a specific implementation. For instance, if you use the above stub (
File.stubs(:read).returns("file contents")) in your test and then refactor your implementation to use, say,File.readlines, you'll have to update your tests. No good.MockFS
MockFS is a library that mocks out the entire filesystem. It allows you write test code like this:
require 'test/unit' require 'mockfs' class TestMoveLog < Test::Unit::TestCase def test_move_log # Set MockFS to use the mock file system MockFS.mock = true # Fill certain directories MockFS.fill_path '/var/log/httpd/' MockFS.fill_path '/home/francis/logs/' # Create the access log MockFS.file.open( '/var/log/httpd/access_log', File::CREAT ) do |f| f.puts "line 1 of the access log" end # Run the method under test move_log # Test that it was moved, along with its contents assert( MockFS.file.exist?( '/home/francis/logs/access_log' ) ) assert( !MockFS.file.exist?( '/var/log/httpd/access_log' ) ) contents = MockFS.file.open( '/home/francis/logs/access_log' ) do |f| f.gets( nil ) end assert_equal( "line 1 of the access log\n", contents ) end endAlthough I suspect MockFS would be a great fit for some projects, I ended up running into issues.
First of all, it depends on a library (extensions) that can have strange monkey-patching conflicts with other libraries. For example, compare this:
require 'faker' puts [].respond_to?(:shuffle) # true
to this:
require 'extensions/all' require 'faker' puts [].respond_to?(:shuffle) # false
Secondly, as you'll notice in the above example, using MockFS requires you to use methods like
MockFS.file.exist?instead of justFile.exist?. This works fine if you're only testing your own code. However, if your code calls any libraries that use filesystem methods, MockFS won't work.(Note: There is a way to mock out the default filesystem methods, but it's experimental. From the MockFS documentation:
"Reading the testing example above, you may be struck by one thing: Using MockFS requires you to remember to reference it everywhere, making calls such as MockFS.file_utils.mv instead of just FileUtils.mv. As another option, you can use File, FileUtils, and Dir directly, and then in your tests, substitute them by including mockfs/override.rb. I'd recommend using these with caution; substituting these low-level classes can have unpredictable results. ")
All that said, MockFS is probably your best option if you're only testing your code and you want to mock out files that you can't actually interact with - for instance, if you need to test that a method reads/writes a file in
/etc(although for the sake of testability, it's generally good to avoid hardcoding fully-qualified paths in your code).FakeFS is another library that uses this approach. I haven't used it personally, but it looks quite nice.
Creating temp files and directories (with Construct)
Besides mocking the filesystem, another option is to have tests interact with actual files and directories on disk. The advantages are that the test code can be simpler to write and you don't have to use any special filesystem methods.
Of course, as always, you want the test itself to contain all the relevant setup and teardown - you don't want your tests to depend upon some set of files that have no explicit connection to the test itself (or create files that aren't cleaned up).
To make this easy, we created a new library called Construct. Construct makes test setup simple by providing helpers to create temporary files and directories. It takes care of the cleanup by automatically deleting the directories and files that are created within the test. And because it creates regular files and directories, you can use plain old Ruby filesystem methods in your code and tests.
To install Construct, simply run:
# gem install devver-construct --source http://gems.github.com
Using Construct, you can write code like this:
require 'construct' class ExampleTest < Test::Unit::TestCase include Construct::Helpers def test_example within_construct do |construct| construct.directory 'alice/rabbithole' do |dir| dir.file 'white_rabbit.txt', "I'm late!" assert_equal "I'm late!", File.read('white_rabbit.txt') end end end endLet's look at each line in more detail.
within_construct do |construct|
When you call
within_construct, a temporary directory is created. All files and directories are, by default, created within that temporary directory and the temporary directory is always deleted beforewithin_constructcompletes.The block argument (
construct) is a Pathname object with some additional methods (#directoryand#file, which I'll explain below). You can use this object to get the path to the temporary directory created by Construct and easily create files and directories.Note that, by default, the working directory is changed to the temp dir within the block provided to
within_construct.construct.directory 'alice/rabbithole' do |dir|
Here we are using the
constructobject to create a new directory within the temp directory. As you can see, you can create nested directories likealice/rabbitholein one step. The block argument (dir) is again a Pathname object with the same added functionality noted above.Just like before, the working directory is changed to the newly created directory (in this case,
alice/rabbithole) within the block.dir.file 'white_rabbit.txt', "I'm late!"
Here we use the
dirobject to create a file. In this case, the file will be empty. However, it's easy to provide file contents using either an optional parameter or the return value of the supplied block:within_construct do |construct| construct.file('foo.txt','Here is some content') construct.file('bar.txt') do <<-EOS The block will return this string, which will be used as the content. EOS end endAs a more real-world example, here's how you could use Construct to start testing the
#rewrite_file!method we looked at before:require 'test/unit' require 'construct' require 'shoulda' class RewriterTest < Test::Unit::TestCase include Construct::Helpers context "#rewrite_file!" do should "alter each line in file" do within_construct do |c| c.file('bar/foo.txt',"a\nb\nc\n") Rewriter.new.rewrite_file!('bar/foo.txt') do |line| line.upcase end assert_equal "A\nB\nC\n", File.read('bar/foo.txt') end end should "not alter file if exception is raised" do within_construct do |c| c.file('foo.txt', "1\n2\nX\n") assert_raises ArgumentError do Rewriter.new.rewrite_file!('foo.txt') do |line| Integer(line)*2 end end assert_equal "1\n2\nX\n", File.read('foo.txt') end end end endYou can learn more at the project page (both the README and the tests have more examples).
(As an aside, since Construct changes the working directory, it doesn't play nicely with
ruby-debug. Specifically, if you place a breakpoint within a block, you'll see the message "No sourcefile available for test/unit/foo_test.rb" and you won't be able to view the source. If anyone knows an easy way to makeDir.chdirwork withruby-debug, I'd very much appreciate some help!)Conclusion
We've been moving our filesystem tests over to using Construct and so far have found it to be very useful. How do you test interactions with the filesystem? Do you use one of the above approaches, or something else? Or do you skip testing the filesystem altogether?
-
Devver is now in public beta!
by Ben
We're very happy to announce that today we released our public beta!
This is a big step forward for us and we're extremely excited about this release. Please try it out and give us feedback on our support site!
