// 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 fstestimport ()// 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.// As a special case, if no expected files are listed, fsys must be empty.// Otherwise, fsys must contain at least the listed files; it can also contain others.// The contents of fsys must not change concurrently with TestFS.//// If TestFS finds any misbehaviors, it returns either the first error or a// list of errors. Use [errors.Is] or [errors.As] to inspect.//// Typical usage inside a test is://// if err := fstest.TestFS(myFS, "file/that/should/be/present"); err != nil {// t.Fatal(err)// }func ( fs.FS, ...string) error {if := testFS(, ...); != nil {return }for , := range {if := strings.Index(, "/"); >= 0 { , := [:], [:+1]var []stringfor , := range {ifstrings.HasPrefix(, ) { = append(, [len():]) } } , := fs.Sub(, )if != nil {return }if := testFS(, ...); != nil {returnfmt.Errorf("testing fs.Sub(fsys, %s): %w", , ) }break// one sub-test is enough } }returnnil}func testFS( fs.FS, ...string) error { := fsTester{fsys: } .checkDir(".") .checkOpen(".") := make(map[string]bool)for , := range .dirs { [] = true }for , := range .files { [] = true }delete(, ".")iflen() == 0 && len() > 0 { := slices.Sorted(maps.Keys())iflen() > 15 { = append([:10], "...") } .errorf("expected empty file system but found files:\n%s", strings.Join(, "\n")) }for , := range {if ![] { .errorf("expected but not found: %s", ) } }iflen(.errors) == 0 {returnnil }returnfmt.Errorf("TestFS found errors:\n%w", errors.Join(.errors...))}// An fsTester holds state for running the test.type fsTester struct { fsys fs.FS errors []error dirs []string files []string}// errorf adds an error to the list of errors.func ( *fsTester) ( string, ...any) { .errors = append(.errors, fmt.Errorf(, ...))}func ( *fsTester) ( string) fs.ReadDirFile { , := .fsys.Open()if != nil { .errorf("%s: Open: %w", , )returnnil } , := .(fs.ReadDirFile)if ! { .Close() .errorf("%s: Open returned File type %T, not a fs.ReadDirFile", , )returnnil }return}// 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 ( *fsTester) ( string) {// Read entire directory. .dirs = append(.dirs, ) := .openDir()if == nil {return } , := .ReadDir(-1)if != nil { .Close() .errorf("%s: ReadDir(-1): %w", , )return }// Check all children.varstringif == "." { = "" } else { = + "/" }for , := range { := .Name()switch {case == ".", == "..", == "": .errorf("%s: ReadDir: child has invalid name: %#q", , )continuecasestrings.Contains(, "/"): .errorf("%s: ReadDir: child name contains slash: %#q", , )continuecasestrings.Contains(, `\`): .errorf("%s: ReadDir: child name contains backslash: %#q", , )continue } := + .checkStat(, ) .checkOpen()if .IsDir() { .() } else { .checkFile() } }// Check ReadDir(-1) at EOF. , := .ReadDir(-1)iflen() > 0 || != nil { .Close() .errorf("%s: ReadDir(-1) at EOF = %d entries, %w, wanted 0 entries, nil", , len(), )return }// Check ReadDir(1) at EOF (different results). , = .ReadDir(1)iflen() > 0 || != io.EOF { .Close() .errorf("%s: ReadDir(1) at EOF = %d entries, %w, wanted 0 entries, EOF", , len(), )return }// Check that close does not report an error.if := .Close(); != nil { .errorf("%s: Close: %w", , ) }// Check that closing twice doesn't crash. // The return value doesn't matter. .Close()// Reopen directory, read a second time, make sure contents match.if = .openDir(); == nil {return }defer .Close() , = .ReadDir(-1)if != nil { .errorf("%s: second Open+ReadDir(-1): %w", , )return } .checkDirList(, "first Open+ReadDir(-1) vs second Open+ReadDir(-1)", , )// Reopen directory, read a third time in pieces, make sure contents match.if = .openDir(); == nil {return }defer .Close() = nilfor { := 1iflen() > 0 { = 2 } , := .ReadDir()iflen() > { .errorf("%s: third Open: ReadDir(%d) after %d: %d entries (too many)", , , len(), len())return } = append(, ...)if == io.EOF {break }if != nil { .errorf("%s: third Open: ReadDir(%d) after %d: %w", , , len(), )return }if == 0 { .errorf("%s: third Open: ReadDir(%d) after %d: 0 entries but nil error", , , len())return } } .checkDirList(, "first Open+ReadDir(-1) vs third Open+ReadDir(1,2) loop", , )// If fsys has ReadDir, check that it matches and is sorted.if , := .fsys.(fs.ReadDirFS); { , := .ReadDir()if != nil { .errorf("%s: fsys.ReadDir: %w", , )return } .checkDirList(, "first Open+ReadDir(-1) vs fsys.ReadDir", , )for := 0; +1 < len(); ++ {if [].Name() >= [+1].Name() { .errorf("%s: fsys.ReadDir: list not sorted: %s before %s", , [].Name(), [+1].Name()) } } }// Check fs.ReadDir as well. , = fs.ReadDir(.fsys, )if != nil { .errorf("%s: fs.ReadDir: %w", , )return } .checkDirList(, "first Open+ReadDir(-1) vs fs.ReadDir", , )for := 0; +1 < len(); ++ {if [].Name() >= [+1].Name() { .errorf("%s: fs.ReadDir: list not sorted: %s before %s", , [].Name(), [+1].Name()) } } .checkGlob(, )}// formatEntry formats an fs.DirEntry into a string for error messages and comparison.func formatEntry( fs.DirEntry) string {returnfmt.Sprintf("%s IsDir=%v Type=%v", .Name(), .IsDir(), .Type())}// formatInfoEntry formats an fs.FileInfo into a string like the result of formatEntry, for error messages and comparison.func formatInfoEntry( fs.FileInfo) string {returnfmt.Sprintf("%s IsDir=%v Type=%v", .Name(), .IsDir(), .Mode().Type())}// formatInfo formats an fs.FileInfo into a string for error messages and comparison.func formatInfo( fs.FileInfo) string {returnfmt.Sprintf("%s IsDir=%v Mode=%v Size=%d ModTime=%v", .Name(), .IsDir(), .Mode(), .Size(), .ModTime())}// checkGlob checks that various glob patterns work if the file system implements GlobFS.func ( *fsTester) ( string, []fs.DirEntry) {if , := .fsys.(fs.GlobFS); ! {return }// Make a complex glob pattern prefix that only matches dir.varstringif != "." { := strings.Split(, "/")for , := range {var []runefor , := range {if == '*' || == '?' || == '\\' || == '[' || == '-' { = append(, '\\', )continue }switch ( + ) % 5 {case0: = append(, )case1: = append(, '[', , ']')case2: = append(, '[', , '-', , ']')case3: = append(, '[', '\\', , ']')case4: = append(, '[', '\\', , '-', '\\', , ']') } } [] = string() } = strings.Join(, "/") + "/" }// Test that malformed patterns are detected. // The error is likely path.ErrBadPattern but need not be.if , := .fsys.(fs.GlobFS).Glob( + "nonexist/[]"); == nil { .errorf("%s: Glob(%#q): bad pattern not detected", , +"nonexist/[]") }// Try to find a letter that appears in only some of the final names. := rune('a')for ; <= 'z'; ++ { , := false, falsefor , := range {ifstrings.ContainsRune(.Name(), ) { = true } else { = true } }if && {break } }if > 'z' { = 'a' } += "*" + string() + "*"var []stringfor , := range {ifstrings.ContainsRune(.Name(), ) { = append(, path.Join(, .Name())) } } , := .fsys.(fs.GlobFS).Glob()if != nil { .errorf("%s: Glob(%#q): %w", , , )return }ifslices.Equal(, ) {return }if !slices.IsSorted() { .errorf("%s: Glob(%#q): unsorted output:\n%s", , , strings.Join(, "\n"))slices.Sort() }var []stringforlen() > 0 || len() > 0 {switch {caselen() > 0 && len() > 0 && [0] == [0]: , = [1:], [1:]caselen() > 0 && (len() == 0 || [0] < [0]): = append(, "missing: "+[0]) = [1:]default: = append(, "extra: "+[0]) = [1:] } } .errorf("%s: Glob(%#q): wrong output:\n%s", , , strings.Join(, "\n"))}// checkStat checks that a direct stat of path matches entry,// which was found in the parent's directory listing.func ( *fsTester) ( string, fs.DirEntry) { , := .fsys.Open()if != nil { .errorf("%s: Open: %w", , )return } , := .Stat() .Close()if != nil { .errorf("%s: Stat: %w", , )return } := formatEntry() := formatInfoEntry()// Note: mismatch here is OK for symlink, because Open dereferences symlink.if != && .Type()&fs.ModeSymlink == 0 { .errorf("%s: mismatch:\n\tentry = %s\n\tfile.Stat() = %s", , , ) } , := .Info()if != nil { .errorf("%s: entry.Info: %w", , )return } := formatInfo()if .Type()&fs.ModeSymlink != 0 {// For symlink, just check that entry.Info matches entry on common fields. // Open deferences symlink, so info itself may differ. := formatInfoEntry()if != { .errorf("%s: mismatch\n\tentry = %s\n\tentry.Info() = %s\n", , , ) } } else { := formatInfo()if != { .errorf("%s: mismatch:\n\tentry.Info() = %s\n\tfile.Stat() = %s\n", , , ) } }// Stat should be the same as Open+Stat, even for symlinks. , := fs.Stat(.fsys, )if != nil { .errorf("%s: fs.Stat: %w", , )return } := formatInfo()if != { .errorf("%s: fs.Stat(...) = %s\n\twant %s", , , ) }if , := .fsys.(fs.StatFS); { , := .Stat()if != nil { .errorf("%s: fsys.Stat: %w", , )return } := formatInfo()if != { .errorf("%s: fsys.Stat(...) = %s\n\twant %s", , , ) } }}// checkDirList checks that two directory lists contain the same files and file info.// The order of the lists need not match.func ( *fsTester) (, string, , []fs.DirEntry) { := make(map[string]fs.DirEntry) := func( fs.DirEntry) {if .IsDir() != (.Type()&fs.ModeDir != 0) {if .IsDir() { .errorf("%s: ReadDir returned %s with IsDir() = true, Type() & ModeDir = 0", , .Name()) } else { .errorf("%s: ReadDir returned %s with IsDir() = false, Type() & ModeDir = ModeDir", , .Name()) } } }for , := range { [.Name()] = () }var []stringfor , := range { := [.Name()]if == nil { () = append(, "+ "+formatEntry())continue }ifformatEntry() != formatEntry() { = append(, "- "+formatEntry(), "+ "+formatEntry()) }delete(, .Name()) }for , := range { = append(, "- "+formatEntry()) }iflen() == 0 {return }slices.SortFunc(, func(, string) int { := strings.Fields() := strings.Fields()// sort by name (i < j) and then +/- (j < i, because + < -)returnstrings.Compare([1]+" "+[0], [1]+" "+[0]) }) .errorf("%s: diff %s:\n\t%s", , , strings.Join(, "\n\t"))}// checkFile checks that basic file reading works correctly.func ( *fsTester) ( string) { .files = append(.files, )// Read entire file. , := .fsys.Open()if != nil { .errorf("%s: Open: %w", , )return } , := io.ReadAll()if != nil { .Close() .errorf("%s: Open+ReadAll: %w", , )return }if := .Close(); != nil { .errorf("%s: Close: %w", , ) }// Check that closing twice doesn't crash. // The return value doesn't matter. .Close()// Check that ReadFile works if present.if , := .fsys.(fs.ReadFileFS); { , := .ReadFile()if != nil { .errorf("%s: fsys.ReadFile: %w", , )return } .checkFileRead(, "ReadAll vs fsys.ReadFile", , )// Modify the data and check it again. Modifying the // returned byte slice should not affect the next call.for := range { []++ } , = .ReadFile()if != nil { .errorf("%s: second call to fsys.ReadFile: %w", , )return } .checkFileRead(, "Readall vs second fsys.ReadFile", , ) .checkBadPath(, "ReadFile",func( string) error { , := .ReadFile(); return }) }// Check that fs.ReadFile works with t.fsys. , := fs.ReadFile(.fsys, )if != nil { .errorf("%s: fs.ReadFile: %w", , )return } .checkFileRead(, "ReadAll vs fs.ReadFile", , )// Use iotest.TestReader to check small reads, Seek, ReadAt. , = .fsys.Open()if != nil { .errorf("%s: second Open: %w", , )return }defer .Close()if := iotest.TestReader(, ); != nil { .errorf("%s: failed TestReader:\n\t%s", , strings.ReplaceAll(.Error(), "\n", "\n\t")) }}func ( *fsTester) (, string, , []byte) {ifstring() != string() { .errorf("%s: %s: different data returned\n\t%q\n\t%q", , , , )return }}// checkBadPath checks that various invalid forms of file's name cannot be opened using t.fsys.Open.func ( *fsTester) ( string) { .checkBadPath(, "Open", func( string) error { , := .fsys.Open()if == nil { .Close() }return })}// checkBadPath checks that various invalid forms of file's name cannot be opened using open.func ( *fsTester) ( string, string, func(string) error) { := []string{"/" + , + "/.", }if == "." { = append(, "/") }if := strings.Index(, "/"); >= 0 { = append(, [:]+"//"+[+1:], [:]+"/./"+[+1:], [:]+`\`+[+1:], [:]+"/../"+, ) }if := strings.LastIndex(, "/"); >= 0 { = append(, [:]+"//"+[+1:], [:]+"/./"+[+1:], [:]+`\`+[+1:], +"/../"+[+1:], ) }for , := range {if := (); == nil { .errorf("%s: %s(%s) succeeded, want error", , , ) } }}
The pages are generated with Goldsv0.7.3-preview. (GOOS=linux GOARCH=amd64)
Golds is a Go 101 project developed by Tapir Liu.
PR and bug reports are welcome and can be submitted to the issue list.
Please follow @zigo_101 (reachable from the left QR code) to get the latest news of Golds.