]> Cypherpunks repositories - gostls13.git/commitdiff
testing/fstest: new package for testing file system code
authorRuss Cox <rsc@golang.org>
Mon, 6 Jul 2020 14:58:05 +0000 (10:58 -0400)
committerRuss Cox <rsc@golang.org>
Tue, 20 Oct 2020 17:52:41 +0000 (17:52 +0000)
This change adds basic test helpers for file system code.

The type MapFS is a simple map-based file system for use when
exercising general file system code.

The func TestFS tests a file system implementation.

For #41190.

Change-Id: I5a2036f57e733915ad508651ad7317749794423c
Reviewed-on: https://go-review.googlesource.com/c/go/+/243910
Trust: Russ Cox <rsc@golang.org>
Reviewed-by: Rob Pike <r@golang.org>
src/go/build/deps_test.go
src/testing/fstest/mapfs.go [new file with mode: 0644]
src/testing/fstest/mapfs_test.go [new file with mode: 0644]
src/testing/fstest/testfs.go [new file with mode: 0644]

index 9e72c8f234f73575fb832006b191ad0ecbe0a6c0..4867a5031ac20f90aecd5819c46c2ec547a51903 100644 (file)
@@ -468,7 +468,8 @@ var depsRules = `
 
        # Test-only
        log
-       < testing/iotest;
+       < testing/iotest
+       < testing/fstest;
 
        FMT, flag, math/rand
        < testing/quick;
diff --git a/src/testing/fstest/mapfs.go b/src/testing/fstest/mapfs.go
new file mode 100644 (file)
index 0000000..84a943f
--- /dev/null
@@ -0,0 +1,204 @@
+// Copyright 2020 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package fstest
+
+import (
+       "io"
+       "io/fs"
+       "path"
+       "sort"
+       "strings"
+       "time"
+)
+
+// A MapFS is a simple in-memory file system for use in tests,
+// represented as a map from path names (arguments to Open)
+// to information about the files or directories they represent.
+//
+// The map need not include parent directories for files contained
+// in the map; those will be synthesized if needed.
+// But a directory can still be included by setting the MapFile.Mode's ModeDir bit;
+// this may be necessary for detailed control over the directory's FileInfo
+// or to create an empty directory.
+//
+// File system operations read directly from the map,
+// so that the file system can be changed by editing the map as needed.
+// An implication is that file system operations must not run concurrently
+// with changes to the map, which would be a race.
+// Another implication is that opening or reading a directory requires
+// iterating over the entire map, so a MapFS should typically be used with not more
+// than a few hundred entries or directory reads.
+type MapFS map[string]*MapFile
+
+// A MapFile describes a single file in a MapFS.
+type MapFile struct {
+       Data    []byte      // file content
+       Mode    fs.FileMode // FileInfo.Mode
+       ModTime time.Time   // FileInfo.ModTime
+       Sys     interface{} // FileInfo.Sys
+}
+
+var _ fs.FS = MapFS(nil)
+var _ fs.File = (*openMapFile)(nil)
+
+// Open opens the named file.
+func (fsys MapFS) Open(name string) (fs.File, error) {
+       if !fs.ValidPath(name) {
+               return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
+       }
+       file := fsys[name]
+       if file != nil && file.Mode&fs.ModeDir == 0 {
+               // Ordinary file
+               return &openMapFile{name, mapFileInfo{path.Base(name), file}, 0}, nil
+       }
+
+       // Directory, possibly synthesized.
+       // Note that file can be nil here: the map need not contain explicit parent directories for all its files.
+       // But file can also be non-nil, in case the user wants to set metadata for the directory explicitly.
+       // Either way, we need to construct the list of children of this directory.
+       var list []mapFileInfo
+       var elem string
+       var need = make(map[string]bool)
+       if name == "." {
+               elem = "."
+               for fname, f := range fsys {
+                       i := strings.Index(fname, "/")
+                       if i < 0 {
+                               list = append(list, mapFileInfo{fname, f})
+                       } else {
+                               need[fname[:i]] = true
+                       }
+               }
+       } else {
+               elem = name[strings.LastIndex(name, "/")+1:]
+               prefix := name + "/"
+               for fname, f := range fsys {
+                       if strings.HasPrefix(fname, prefix) {
+                               felem := fname[len(prefix):]
+                               i := strings.Index(felem, "/")
+                               if i < 0 {
+                                       list = append(list, mapFileInfo{felem, f})
+                               } else {
+                                       need[fname[len(prefix):len(prefix)+i]] = true
+                               }
+                       }
+               }
+               // If the directory name is not in the map,
+               // and there are no children of the name in the map,
+               // then the directory is treated as not existing.
+               if file == nil && list == nil && len(need) == 0 {
+                       return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
+               }
+       }
+       for _, fi := range list {
+               delete(need, fi.name)
+       }
+       for name := range need {
+               list = append(list, mapFileInfo{name, &MapFile{Mode: fs.ModeDir}})
+       }
+       sort.Slice(list, func(i, j int) bool {
+               return list[i].name < list[j].name
+       })
+
+       if file == nil {
+               file = &MapFile{Mode: fs.ModeDir}
+       }
+       return &mapDir{name, mapFileInfo{elem, file}, list, 0}, nil
+}
+
+// A mapFileInfo implements fs.FileInfo and fs.DirEntry for a given map file.
+type mapFileInfo struct {
+       name string
+       f    *MapFile
+}
+
+func (i *mapFileInfo) Name() string               { return i.name }
+func (i *mapFileInfo) Size() int64                { return int64(len(i.f.Data)) }
+func (i *mapFileInfo) Mode() fs.FileMode          { return i.f.Mode }
+func (i *mapFileInfo) Type() fs.FileMode          { return i.f.Mode.Type() }
+func (i *mapFileInfo) ModTime() time.Time         { return i.f.ModTime }
+func (i *mapFileInfo) IsDir() bool                { return i.f.Mode&fs.ModeDir != 0 }
+func (i *mapFileInfo) Sys() interface{}           { return i.f.Sys }
+func (i *mapFileInfo) Info() (fs.FileInfo, error) { return i, nil }
+
+// An openMapFile is a regular (non-directory) fs.File open for reading.
+type openMapFile struct {
+       path string
+       mapFileInfo
+       offset int64
+}
+
+func (f *openMapFile) Stat() (fs.FileInfo, error) { return &f.mapFileInfo, nil }
+
+func (f *openMapFile) Close() error { return nil }
+
+func (f *openMapFile) Read(b []byte) (int, error) {
+       if f.offset >= int64(len(f.f.Data)) {
+               return 0, io.EOF
+       }
+       if f.offset < 0 {
+               return 0, &fs.PathError{Op: "read", Path: f.path, Err: fs.ErrInvalid}
+       }
+       n := copy(b, f.f.Data[f.offset:])
+       f.offset += int64(n)
+       return n, nil
+}
+
+func (f *openMapFile) Seek(offset int64, whence int) (int64, error) {
+       switch whence {
+       case 0:
+               // offset += 0
+       case 1:
+               offset += f.offset
+       case 2:
+               offset += int64(len(f.f.Data))
+       }
+       if offset < 0 || offset > int64(len(f.f.Data)) {
+               return 0, &fs.PathError{Op: "seek", Path: f.path, Err: fs.ErrInvalid}
+       }
+       f.offset = offset
+       return offset, nil
+}
+
+func (f *openMapFile) ReadAt(b []byte, offset int64) (int, error) {
+       if offset < 0 || offset > int64(len(f.f.Data)) {
+               return 0, &fs.PathError{Op: "read", Path: f.path, Err: fs.ErrInvalid}
+       }
+       n := copy(b, f.f.Data[offset:])
+       if n < len(b) {
+               return n, io.EOF
+       }
+       return n, nil
+}
+
+// A mapDir is a directory fs.File (so also an fs.ReadDirFile) open for reading.
+type mapDir struct {
+       path string
+       mapFileInfo
+       entry  []mapFileInfo
+       offset int
+}
+
+func (d *mapDir) Stat() (fs.FileInfo, error) { return &d.mapFileInfo, nil }
+func (d *mapDir) Close() error               { return nil }
+func (d *mapDir) Read(b []byte) (int, error) {
+       return 0, &fs.PathError{Op: "read", Path: d.path, Err: fs.ErrInvalid}
+}
+
+func (d *mapDir) ReadDir(count int) ([]fs.DirEntry, error) {
+       n := len(d.entry) - d.offset
+       if count > 0 && n > count {
+               n = count
+       }
+       if n == 0 && count > 0 {
+               return nil, io.EOF
+       }
+       list := make([]fs.DirEntry, n)
+       for i := range list {
+               list[i] = &d.entry[d.offset+i]
+       }
+       d.offset += n
+       return list, nil
+}
diff --git a/src/testing/fstest/mapfs_test.go b/src/testing/fstest/mapfs_test.go
new file mode 100644 (file)
index 0000000..2abedd6
--- /dev/null
@@ -0,0 +1,19 @@
+// Copyright 2020 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package fstest
+
+import (
+       "testing"
+)
+
+func TestMapFS(t *testing.T) {
+       m := MapFS{
+               "hello":             {Data: []byte("hello, world\n")},
+               "fortune/k/ken.txt": {Data: []byte("If a program is too slow, it must have a loop.\n")},
+       }
+       if err := TestFS(m, "hello", "fortune/k/ken.txt"); err != nil {
+               t.Fatal(err)
+       }
+}
diff --git a/src/testing/fstest/testfs.go b/src/testing/fstest/testfs.go
new file mode 100644 (file)
index 0000000..2bb2120
--- /dev/null
@@ -0,0 +1,364 @@
+// Copyright 2020 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package fstest implements support for testing implementations and users of file systems.
+package fstest
+
+import (
+       "errors"
+       "fmt"
+       "io"
+       "io/fs"
+       "io/ioutil"
+       "sort"
+       "strings"
+       "testing/iotest"
+)
+
+// TestFS tests a file system implementation.
+// It walks the entire tree of files in fsys,
+// opening and checking that each file behaves correctly.
+// It also checks that the file system contains at least the expected files.
+//
+// If TestFS finds any misbehaviors, it returns an error reporting all of them.
+// The error text spans multiple lines, one per detected misbehavior.
+//
+// Typical usage inside a test is:
+//
+//     if err := fstest.TestFS(myFS, "file/that/should/be/present"); err != nil {
+//             t.Fatal(err)
+//     }
+//
+func TestFS(fsys fs.FS, expected ...string) error {
+       t := fsTester{fsys: fsys}
+       t.checkDir(".")
+       t.checkOpen(".")
+       found := make(map[string]bool)
+       for _, dir := range t.dirs {
+               found[dir] = true
+       }
+       for _, file := range t.files {
+               found[file] = true
+       }
+       for _, name := range expected {
+               if !found[name] {
+                       t.errorf("expected but not found: %s", name)
+               }
+       }
+       if len(t.errText) == 0 {
+               return nil
+       }
+       return errors.New("TestFS found errors:\n" + string(t.errText))
+}
+
+// An fsTester holds state for running the test.
+type fsTester struct {
+       fsys    fs.FS
+       errText []byte
+       dirs    []string
+       files   []string
+}
+
+// errorf adds an error line to errText.
+func (t *fsTester) errorf(format string, args ...interface{}) {
+       if len(t.errText) > 0 {
+               t.errText = append(t.errText, '\n')
+       }
+       t.errText = append(t.errText, fmt.Sprintf(format, args...)...)
+}
+
+func (t *fsTester) openDir(dir string) fs.ReadDirFile {
+       f, err := t.fsys.Open(dir)
+       if err != nil {
+               t.errorf("%s: Open: %v", dir, err)
+               return nil
+       }
+       d, ok := f.(fs.ReadDirFile)
+       if !ok {
+               f.Close()
+               t.errorf("%s: Open returned File type %T, not a io.ReadDirFile", dir, f)
+               return nil
+       }
+       return d
+}
+
+// checkDir checks the directory dir, which is expected to exist
+// (it is either the root or was found in a directory listing with IsDir true).
+func (t *fsTester) checkDir(dir string) {
+       // Read entire directory.
+       t.dirs = append(t.dirs, dir)
+       d := t.openDir(dir)
+       if d == nil {
+               return
+       }
+       list, err := d.ReadDir(-1)
+       if err != nil {
+               d.Close()
+               t.errorf("%s: ReadDir(-1): %v", dir, err)
+               return
+       }
+
+       // Check all children.
+       var prefix string
+       if dir == "." {
+               prefix = ""
+       } else {
+               prefix = dir + "/"
+       }
+       for _, info := range list {
+               name := info.Name()
+               switch {
+               case name == ".", name == "..", name == "":
+                       t.errorf("%s: ReadDir: child has invalid name: %#q", dir, name)
+                       continue
+               case strings.Contains(name, "/"):
+                       t.errorf("%s: ReadDir: child name contains slash: %#q", dir, name)
+                       continue
+               case strings.Contains(name, `\`):
+                       t.errorf("%s: ReadDir: child name contains backslash: %#q", dir, name)
+                       continue
+               }
+               path := prefix + name
+               t.checkStat(path, info)
+               t.checkOpen(path)
+               if info.IsDir() {
+                       t.checkDir(path)
+               } else {
+                       t.checkFile(path)
+               }
+       }
+
+       // Check ReadDir(-1) at EOF.
+       list2, err := d.ReadDir(-1)
+       if len(list2) > 0 || err != nil {
+               d.Close()
+               t.errorf("%s: ReadDir(-1) at EOF = %d entries, %v, wanted 0 entries, nil", dir, len(list2), err)
+               return
+       }
+
+       // Check ReadDir(1) at EOF (different results).
+       list2, err = d.ReadDir(1)
+       if len(list2) > 0 || err != io.EOF {
+               d.Close()
+               t.errorf("%s: ReadDir(1) at EOF = %d entries, %v, wanted 0 entries, EOF", dir, len(list2), err)
+               return
+       }
+
+       // Check that close does not report an error.
+       if err := d.Close(); err != nil {
+               t.errorf("%s: Close: %v", dir, err)
+       }
+
+       // Check that closing twice doesn't crash.
+       // The return value doesn't matter.
+       d.Close()
+
+       // Reopen directory, read a second time, make sure contents match.
+       if d = t.openDir(dir); d == nil {
+               return
+       }
+       defer d.Close()
+       list2, err = d.ReadDir(-1)
+       if err != nil {
+               t.errorf("%s: second Open+ReadDir(-1): %v", dir, err)
+               return
+       }
+       t.checkDirList(dir, "first Open+ReadDir(-1) vs second Open+ReadDir(-1)", list, list2)
+
+       // Reopen directory, read a third time in pieces, make sure contents match.
+       if d = t.openDir(dir); d == nil {
+               return
+       }
+       defer d.Close()
+       list2 = nil
+       for {
+               n := 1
+               if len(list2) > 0 {
+                       n = 2
+               }
+               frag, err := d.ReadDir(n)
+               if len(frag) > n {
+                       t.errorf("%s: third Open: ReadDir(%d) after %d: %d entries (too many)", dir, n, len(list2), len(frag))
+                       return
+               }
+               list2 = append(list2, frag...)
+               if err == io.EOF {
+                       break
+               }
+               if err != nil {
+                       t.errorf("%s: third Open: ReadDir(%d) after %d: %v", dir, n, len(list2), err)
+                       return
+               }
+               if n == 0 {
+                       t.errorf("%s: third Open: ReadDir(%d) after %d: 0 entries but nil error", dir, n, len(list2))
+                       return
+               }
+       }
+       t.checkDirList(dir, "first Open+ReadDir(-1) vs third Open+ReadDir(1,2) loop", list, list2)
+}
+
+// formatEntry formats an fs.DirEntry into a string for error messages and comparison.
+func formatEntry(entry fs.DirEntry) string {
+       return fmt.Sprintf("%s IsDir=%v Type=%v", entry.Name(), entry.IsDir(), entry.Type())
+}
+
+// formatInfoEntry formats an fs.FileInfo into a string like the result of formatEntry, for error messages and comparison.
+func formatInfoEntry(info fs.FileInfo) string {
+       return fmt.Sprintf("%s IsDir=%v Type=%v", info.Name(), info.IsDir(), info.Mode().Type())
+}
+
+// formatInfo formats an fs.FileInfo into a string for error messages and comparison.
+func formatInfo(info fs.FileInfo) string {
+       return fmt.Sprintf("%s IsDir=%v Mode=%v Size=%d ModTime=%v", info.Name(), info.IsDir(), info.Mode(), info.Size(), info.ModTime())
+}
+
+// checkStat checks that a direct stat of path matches entry,
+// which was found in the parent's directory listing.
+func (t *fsTester) checkStat(path string, entry fs.DirEntry) {
+       file, err := t.fsys.Open(path)
+       if err != nil {
+               t.errorf("%s: Open: %v", path, err)
+               return
+       }
+       info, err := file.Stat()
+       file.Close()
+       if err != nil {
+               t.errorf("%s: Stat: %v", path, err)
+               return
+       }
+       fentry := formatEntry(entry)
+       finfo := formatInfoEntry(info)
+       if fentry != finfo {
+               t.errorf("%s: mismatch:\n\tentry = %v\n\tfile.Stat() = %v", path, fentry, finfo)
+       }
+}
+
+// checkDirList checks that two directory lists contain the same files and file info.
+// The order of the lists need not match.
+func (t *fsTester) checkDirList(dir, desc string, list1, list2 []fs.DirEntry) {
+       old := make(map[string]fs.DirEntry)
+       checkMode := func(entry fs.DirEntry) {
+               if entry.IsDir() != (entry.Type()&fs.ModeDir != 0) {
+                       if entry.IsDir() {
+                               t.errorf("%s: ReadDir returned %s with IsDir() = true, Type() & ModeDir = 0", dir, entry.Name())
+                       } else {
+                               t.errorf("%s: ReadDir returned %s with IsDir() = false, Type() & ModeDir = ModeDir", dir, entry.Name())
+                       }
+               }
+       }
+
+       for _, entry1 := range list1 {
+               old[entry1.Name()] = entry1
+               checkMode(entry1)
+       }
+
+       var diffs []string
+       for _, entry2 := range list2 {
+               entry1 := old[entry2.Name()]
+               if entry1 == nil {
+                       checkMode(entry2)
+                       diffs = append(diffs, "+ "+formatEntry(entry2))
+                       continue
+               }
+               if formatEntry(entry1) != formatEntry(entry2) {
+                       diffs = append(diffs, "- "+formatEntry(entry1), "+ "+formatEntry(entry2))
+               }
+               delete(old, entry2.Name())
+       }
+       for _, entry1 := range old {
+               diffs = append(diffs, "- "+formatEntry(entry1))
+       }
+
+       if len(diffs) == 0 {
+               return
+       }
+
+       sort.Slice(diffs, func(i, j int) bool {
+               fi := strings.Fields(diffs[i])
+               fj := strings.Fields(diffs[j])
+               // sort by name (i < j) and then +/- (j < i, because + < -)
+               return fi[1]+" "+fj[0] < fj[1]+" "+fi[0]
+       })
+
+       t.errorf("%s: diff %s:\n\t%s", dir, desc, strings.Join(diffs, "\n\t"))
+}
+
+// checkFile checks that basic file reading works correctly.
+func (t *fsTester) checkFile(file string) {
+       t.files = append(t.files, file)
+
+       // Read entire file.
+       f, err := t.fsys.Open(file)
+       if err != nil {
+               t.errorf("%s: Open: %v", file, err)
+               return
+       }
+
+       data, err := ioutil.ReadAll(f)
+       if err != nil {
+               f.Close()
+               t.errorf("%s: Open+ReadAll: %v", file, err)
+               return
+       }
+
+       if err := f.Close(); err != nil {
+               t.errorf("%s: Close: %v", file, err)
+       }
+
+       // Check that closing twice doesn't crash.
+       // The return value doesn't matter.
+       f.Close()
+
+       // Use iotest.TestReader to check small reads, Seek, ReadAt.
+       f, err = t.fsys.Open(file)
+       if err != nil {
+               t.errorf("%s: second Open: %v", file, err)
+               return
+       }
+       defer f.Close()
+       if err := iotest.TestReader(f, data); err != nil {
+               t.errorf("%s: failed TestReader:\n\t%s", file, strings.ReplaceAll(err.Error(), "\n", "\n\t"))
+       }
+}
+
+func (t *fsTester) checkFileRead(file, desc string, data1, data2 []byte) {
+       if string(data1) != string(data2) {
+               t.errorf("%s: %s: different data returned\n\t%q\n\t%q", file, desc, data1, data2)
+               return
+       }
+}
+
+// checkOpen checks that various invalid forms of file's name cannot be opened.
+func (t *fsTester) checkOpen(file string) {
+       bad := []string{
+               "/" + file,
+               file + "/.",
+       }
+       if file == "." {
+               bad = append(bad, "/")
+       }
+       if i := strings.Index(file, "/"); i >= 0 {
+               bad = append(bad,
+                       file[:i]+"//"+file[i+1:],
+                       file[:i]+"/./"+file[i+1:],
+                       file[:i]+`\`+file[i+1:],
+                       file[:i]+"/../"+file,
+               )
+       }
+       if i := strings.LastIndex(file, "/"); i >= 0 {
+               bad = append(bad,
+                       file[:i]+"//"+file[i+1:],
+                       file[:i]+"/./"+file[i+1:],
+                       file[:i]+`\`+file[i+1:],
+                       file+"/../"+file[i+1:],
+               )
+       }
+
+       for _, b := range bad {
+               if f, err := t.fsys.Open(b); err == nil {
+                       f.Close()
+                       t.errorf("%s: Open(%s) succeeded, want error", file, b)
+               }
+       }
+}