Mocking a file system in Go
- src
- config
- config.go
- config_test.go
- reloader.go
- dir
- dir.go
- dir_test.go
- dirCompare.go
- dirCompare_test.go
- hash.go
- listener.go
- loop
- mainLoop.go
- main.go
- config
- readme.md
Intro
In this post we’ll try to mock some features of a file system in Go. We’ll focus mainly on file tree structure instead on IO operations on files itself. If you are interested in full abstract file system for Go check out afero package.
If your application uses a file system (FS) a bit beyond just reading a single file then you should probably want to mock a FS in order to test it. Without mocking it only way to write unit tests is to actually create files, performs tests and clean up afterwards. It’s slow (reaching to hard drive) and ineffective.
Let’s say we want to implement reading a file tree structure. For me natural representation of such a tree is the following:
type Dir struct {
Path string,
Files []os.FileInfo,
SubDirs map[string]Dir
}
Structure Dir
have a Path
path to root catalog, Files
which is a list of meta
information of files and a dictionary which maps a sub catalog name into actual
sub catalog - another Dir
. Which gives us recursive tree-like structure
representing a file tree. I called it “natural” representation because it’s
usually the case shown in file explorers. Current catalog with files and other
sub catalogs. Another representation could be just an array of os.FileInfo
extended by full path but it less natural for most people. It’s more natural
for computers though.
Now I’d like to have some functionality which scans actual OS file system to
provide Dir
representation. On other hand I want to prepare mock FS in
order to test that functionality without manual manipulation of actual FS.
func Scan(...) (Dir, error) {
...
}
Interface
I have not stated Scan
function arguments yet. I’d like to use this function
both on actual FS and also on mocked FS, so what should be an argument for
that function? Yep, an interface. We should define minimal interface which
would let us write recursive algorithm for scanning file tree.
For given directory this interface should have functionalities to give us a list of files and sub directories, path of current location and something to reproduce or move itself for sub catalogs.
type DirReader interface {
DirPath() string
Readdir() ([]os.FileInfo, error)
New(string) DirReader
}
The DirReader
interface is my proposition to meet those requirements. It’s
composed of three methods - DirPath
returns current location, Readdir
reads content of current catalog and New
produce another DirReader
(assumable) for sub catalogs. Furthermore let’s also note that type os.FileInfo
from the standard library is an interface. Details can be found
here.
Now we can finally pronounce signature of Scan
function
func Scan(reader DirReader) (Dir, error) {
...
}
At this point we could even provide a full implementation of Scan
function
using only DirReader
interface definition. I’ll leave this implementation in
the Appendix to not distract us from main goal which is mocking a FS.
In the next two sections we’ll focus on implementing DirReader
interface for actual
OS file system using cross-platform os package
and for mocked in-memory Go objects. This pattern, having an interface
depending on interface rather then concrete type, is very broadly used despite
particular programming language. I believe it originated in Simula with virtual
functions and was later adopted by Bjarne Stroustrup in C++ in early 1980s. In
C++ we could define abstract base class with methods similar to DirReader
.
Actual implementation
The actual implementation of DirReader
which would use an actual OS file system
is rather straightforward. Let’s start from defining a new type which will
represent single (non-recursive) directory - FlatDir
.
type FlatDir struct {
Path string
}
To satisfy DirReader
interface we’ve got to implement required methods.
Getting directory’s path and create a new FlatDir
are trivial methods in this
case:
func (fd FlatDir) DirPath() string {
return fd.Path
}
func (fd FlatDir) New(path string) DirReader {
return FlatDir{Path: path}
}
All of the work is done in Readdir
method. We’ll use functionalities from Go
os
package to implement reading content of OS file system directory.
func (fd FlatDir) Readdir() ([]os.FileInfo, error) {
currentDir, err := os.Open(fd.Path)
if err != nil {
return nil, err
}
fileInfos, err := currentDir.Readdir(-1)
if err != nil {
return nil, err
}
return fileInfos, nil
}
That method is really just a wrapper on Readdir method which is cross-platform and do the heavy lifting for us.
After that type FlatDir
satisfies DirReader
interface. Now, in order to
scan file tree for given root path, we could run tree, err := Scan(FlatDir{path})
.
Mock
In order to prepare a mock for a file system we have to define an object which
behaves like a file system and at the same time satisfies DirReader
interface.
As I stated at the beginning Dir
is natural representation of a file system
for me. Because of that my mock object will be very similar to Dir
:
type MockDir struct {
Path string
Files []os.FileInfo
SubDirs map[string]*MockDir
}
Defined MockDir
represents our mock FS. Using map[string]*MockDir
instead of map[string]MockDir
is just for my convenience while building mock
trees. In this version I can modify sub trees of the tree which was already
initialized.
To satisfy DirReader
interface we, once again, have to provide implementation
of methods DirPath
, Readdir
and New
. The first one in this case also just
returns MockDir.Path
. Method Readdir
returns all files from MockDir.Files
and based on SubDirs
keys - sub catalog names also list of catalogs as
[]os.FileInfo
. Method New
is a bit tricky in context of the
mock object. This method suppose to return a new DirReader
for a given path.
In this case we can do that only for sub catalogs of current tree, because
there isn’t any other valid path outside sub trees. Implementation of those
three methods can be found in the Appendix.
Wait but what about MockDir.Files
? As we saw earlier os.FileInfo
is an
interface from Go standard library. We also have to mock this interface in
order to produce MockDir
objects. Let’s define MockFileInfo
type which
would satisfy os.FileInfo
interface:
type MockFileInfo struct {
FileName string
IsDirectory bool
}
func (mfi MockFileInfo) Name() string { return mfi.FileName }
func (mfi MockFileInfo) Size() int64 { return int64(8) }
func (mfi MockFileInfo) Mode() os.FileMode { return os.ModePerm }
func (mfi MockFileInfo) ModTime() time.Time { return time.Now() }
func (mfi MockFileInfo) IsDir() bool { return mfi.IsDirectory }
func (mfi MockFileInfo) Sys() interface{} { return nil }
The actual object contains only information about file name and flag stating whenever this file is a catalog. In this particular case I don’t need other file information like size or last modification date.
Up to this point we’ve defined function Scan
which scans a file tree based on
DirReader
interface. We’ve got implementation of DirReader
for actual OS
file system and also implementation of mock DirReader
. Hence we can use both
FlatDir
and MockDir
in Scan
function.
One last piece left is convenient building MockDir
object for unit testing.
I’ve found very useful to have a
variadic function to produce my
mock file trees. In this situation we have a plenty of options but my first
attempt was implement a helper function with the following signature
func NewMockDir(rootPath string, files ...string) MockDir {
...
}
This function have an assumption about files
. If single file name contains
_
then it would be put inside sub catalog. For example
mockTree := NewMockDir("~/Downloads/", "f1.go", "f2.cpp",
"sub1_g.txt", "sub2_h.html")
would create a file tree which looks like
- ~/Downloads
- f1.go
- f2.cpp
- sub1
- g.txt
- sub2
- h.html
In case when I need deeper trees I would use NewMockDir
functions to create
sub catalogs (and sub catalogs of sub catalogs…).
Summary
Created mockTree
is of type MockDir
which satisfies DirReader
interface.
Hence we could use Scan(mockTree)
, to produce actual file tree representation - Dir
.
That means now we are able to test every functionality which depends
on Dir
object. And that’s great! We can easily and safely test manipulations
of a file system. In praticular now we can setup scenarios which could be very
difficult to create on actual OS file system like creating 1 << 30
files or
tree with very large depth.
The idea of mocking a file system using an “interface” can be used in most of
programming languages. What Go saves us is having already defined interface
os.FileInfo
representing meta file information. In other languages we usually
have to wrap it to abstract class or an interface and mock these afterwards.
References
Appendix
Source code of Scan
function:
func Scan(reader DirReader) (Dir, error) {
fileInfos, err := reader.Readdir()
if err != nil {
return Dir{}, err
}
dir := New(reader.DirPath(), 100) // Produces a new empty Dir
for _, fileInfo := range fileInfos {
name := fileInfo.Name()
if !fileInfo.IsDir() {
dir.Files = append(dir.Files, fileInfo)
continue
}
newPath := filepath.Join(reader.DirPath(), name)
newReader := reader.New(newPath)
subDir, err := Scan(newReader)
if err != nil {
return Dir{}, err
}
dir.SubDirs[name] = subDir
}
return dir, nil
}
MockDir
methods
func (md MockDir) DirPath() string {
return md.Path
}
func (md MockDir) Readdir() ([]os.FileInfo, error) {
files := make([]os.FileInfo, 0, 10)
for _, file := range md.Files {
files = append(files, file)
}
for dirName := range md.SubDirs {
files = append(files, NewMockFileInfo(dirName, true))
}
return files, nil
}
func (md MockDir) New(path string) DirReader {
for _, dir := range md.SubDirs {
if dir.Path == path {
return dir
}
}
emptyDirs := make(map[string]*MockDir)
return MockDir{path, make([]os.FileInfo, 0), emptyDirs}
}
MockFileInfo
func NewMockFileInfo(name string, isDir bool) MockFileInfo {
return MockFileInfo{
FileName: name,
IsDirectory: isDir,
}
}
func NewMockDir(rootPath string, files ...string) MockDir {
topFiles := make([]os.FileInfo, 0, 10)
subDirs := make(map[string]*MockDir)
for _, file := range files {
if strings.Contains(file, "_") {
parts := strings.Split(file, "_")
dirName := parts[0]
subFile := NewMockFileInfo(parts[1], false)
if _, exist := subDirs[dirName]; exist {
(*subDirs[dirName]).Files = append((*subDirs[dirName]).Files, subFile)
continue
}
subDirs[dirName] = &MockDir{filepath.Join(rootPath, dirName), []os.FileInfo{subFile}, nil}
continue
}
topFiles = append(topFiles, NewMockFileInfo(file, false))
}
return MockDir{rootPath, topFiles, subDirs}
}