// 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 ( ) // 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 []string for , := range { if strings.HasPrefix(, ) { = append(, [len():]) } } , := fs.Sub(, ) if != nil { return } if := testFS(, ...); != nil { return fmt.Errorf("testing fs.Sub(fsys, %s): %w", , ) } break // one sub-test is enough } } return nil } func testFS( fs.FS, ...string) error { := fsTester{fsys: } .checkDir(".") .checkOpen(".") := make(map[string]bool) for , := range .dirs { [] = true } for , := range .files { [] = true } delete(, ".") if len() == 0 && len() > 0 { := slices.Sorted(maps.Keys()) if len() > 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", ) } } if len(.errors) == 0 { return nil } return fmt.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", , ) return nil } , := .(fs.ReadDirFile) if ! { .Close() .errorf("%s: Open returned File type %T, not a fs.ReadDirFile", , ) return nil } 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. var string if == "." { = "" } else { = + "/" } for , := range { := .Name() switch { case == ".", == "..", == "": .errorf("%s: ReadDir: child has invalid name: %#q", , ) continue case strings.Contains(, "/"): .errorf("%s: ReadDir: child name contains slash: %#q", , ) continue case strings.Contains(, `\`): .errorf("%s: ReadDir: child name contains backslash: %#q", , ) continue } := + .checkStat(, ) .checkOpen() if .IsDir() { .() } else { .checkFile() } } // Check ReadDir(-1) at EOF. , := .ReadDir(-1) if len() > 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) if len() > 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() = nil for { := 1 if len() > 0 { = 2 } , := .ReadDir() if len() > { .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 { return fmt.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 { return fmt.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 { return fmt.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. var string if != "." { := strings.Split(, "/") for , := range { var []rune for , := range { if == '*' || == '?' || == '\\' || == '[' || == '-' { = append(, '\\', ) continue } switch ( + ) % 5 { case 0: = append(, ) case 1: = append(, '[', , ']') case 2: = append(, '[', , '-', , ']') case 3: = append(, '[', '\\', , ']') case 4: = 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, false for , := range { if strings.ContainsRune(.Name(), ) { = true } else { = true } } if && { break } } if > 'z' { = 'a' } += "*" + string() + "*" var []string for , := range { if strings.ContainsRune(.Name(), ) { = append(, path.Join(, .Name())) } } , := .fsys.(fs.GlobFS).Glob() if != nil { .errorf("%s: Glob(%#q): %w", , , ) return } if slices.Equal(, ) { return } if !slices.IsSorted() { .errorf("%s: Glob(%#q): unsorted output:\n%s", , , strings.Join(, "\n")) slices.Sort() } var []string for len() > 0 || len() > 0 { switch { case len() > 0 && len() > 0 && [0] == [0]: , = [1:], [1:] case len() > 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 []string for , := range { := [.Name()] if == nil { () = append(, "+ "+formatEntry()) continue } if formatEntry() != formatEntry() { = append(, "- "+formatEntry(), "+ "+formatEntry()) } delete(, .Name()) } for , := range { = append(, "- "+formatEntry()) } if len() == 0 { return } slices.SortFunc(, func(, string) int { := strings.Fields() := strings.Fields() // sort by name (i < j) and then +/- (j < i, because + < -) return strings.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) { if string() != 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", , , ) } } }