Example code for testing the filesystem in Golang

One important thing you must not forget: you can only mock the file system if the code that interacts with the file system does so via the above presented file system interface (filesystem), using the fs global variable (or some other filesystem value that the test code can change, e.g. a passed fs parameter).

Let’s see such an example function:

func getSize(name string) (int64, error) {
    stat, err := fs.Stat(name)
    if err != nil {
        return 0, err
    }
    return stat.Size(), nil
}

This simple getSize() function returns the size of a file specified by its name, returning the error if filesystem.Stat() fails (returns an error).

And now let’s write some unit tests that fully cover this getSize() function.

What we will need

We need a mocked version of filesystem, mocked so that it does not actually interact with the filesystem, but returns sensible data when methods of filesystem are called (filesystem.Stat() in our case). To easiest mock filesystem (or any interface), we will embed filesystem in our mockedFS, so we “inherit” all its methods, and we will only need to mock what is actually used by the testable code. Note that calling other methods would result in runtime panic, as we won’t really give a sensible, non-nil value to this embedded filesystem, but for the sake of tests it is not needed.

Since filesystem returns a value of os.FileInfo (besides an error), which is an interface (and its implementation is not exported from the os package), we will also need to mock os.FileInfo. This will be mockedFileInfo, and we will do it very similarly to mocking filesystem: we’ll embed the interface type os.FileInfo, so actually we’ll only need to implement FileInfo.Size(), because that is the only method called by the testable getSize() function.

Preparing / Setting up the mocked filesystem

Once we have the mocked types, we have to set them up. Since getSize() uses the global fs variable to interact with the filesystem, we need to assign a value of our mockedFS to this global fs variable. Before doing so it’s recommended to save its old value, and properly restore the old value once we’re done with the test: “cleanup”.

Since we fully want to test getSize() (including the error case), we armour our mockedFS with the ability to control whether it should return an error, and also the ability to tell it what to return in case we don’t want any errors.

When doing the tests, we can manipulate the “state” of the mockedFS to bend its behavior to our needs.

And the test(ing) code

Without further ado, the full testing code:

type mockedFS struct {
    // Embed so we only need to "override" what is used by testable functions
    osFS

    reportErr  bool  // Tells if this mocked FS should return error in our tests
    reportSize int64 // Tells what size should Stat() report in our test
}

type mockedFileInfo struct {
    // Embed this so we only need to add methods used by testable functions
    os.FileInfo
    size int64
}

func (m mockedFileInfo) Size() int64 { return m.size }

func (m mockedFS) Stat(name string) (os.FileInfo, error) {
    if m.reportErr {
        return nil, os.ErrNotExist
    }
    return mockedFileInfo{size: m.reportSize}, nil
}

func TestGetSize(t *testing.T) {
    oldFs := fs
    // Create and "install" mocked fs:
    mfs := &mockedFS{}
    fs = mfs
    // Make sure fs is restored after this test:
    defer func() {
        fs = oldFs
    }()

    // Test when filesystem.Stat() reports error:
    mfs.reportErr = true
    if _, err := getSize("hello.go"); err == nil {
        t.Error("Expected error, but err is nil!")
    }

    // Test when no error and size is returned:
    mfs.reportErr = false
    mfs.reportSize = 123
    if size, err := getSize("hello.go"); err != nil {
        t.Errorf("Expected no error, got: %v", err)
    } else if size != 123 {
        t.Errorf("Expected size %d, got: %d", 123, size)
    }
}

Leave a Comment