Following the Learn Go with Tests guide, exploring file input/output operations in Go.
Build a blog post processor that reads markdown files with structured metadata:
Title: My Blog Post
Description: A great post about Go
Tags: go, programming
---
This is the blog post content.
Multiple paragraphs supported.Goal: Parse these files into Go structs, extracting:
- Title, Description, Tags (metadata)
- Body content (everything after
---)
File System Abstractions #
Go 1.16 introduced io/fs with the fs.FS interface for unified file system access. See the official Go 1.16 release notes.
type FS interface {
Open(name string) (File, error)
}Benefits #
Several types implement fs.FS:
embed.FSzip.Readeros.DirFS()
Using standard library interfaces like fs.FS, io.Reader, and io.Writer creates loosely coupled, reusable packages.
Instead of:
var posts []blogposts.Post
posts = blogposts.NewPostsFromFS("some-folder")Better approach:
var posts []blogposts.Post
posts = blogposts.NewPostsFromFS(someFS) // Accept fs.FS interfaceInterface Flexibility #
Using generic interfaces like fs.FS makes arguments swappable. NewPostsFromFS function works with any implementation:
// Production: read from local files
posts := blogposts.NewPostsFromFS(os.DirFS("./posts"))
// Testing: use in-memory files
posts := blogposts.NewPostsFromFS(fstest.MapFS{...})Read-Only Limitation: The fs.FS interface is read-only by design.
Basic Testing #
For testing, testing/fstest offers implementations of io/FS, similar to tools in net/http/httptest.
func TestNewBlogPosts(t *testing.T) {
fs := fstest.MapFS{
"hello world.md": {Data: []byte("hi")},
"hello-world2.md": {Data: []byte("hola")},
}
posts := blogposts.NewPostsFromFS(fs)
if len(posts) != len(fs) {
t.Errorf("got %d posts, wanted %d posts", len(posts), len(fs))
}
}fstest.MapFS: In-memory file system for tests- Benefits: Faster execution, no test file maintenance
External Testing #
When both blogposts.go and blogposts_test.go are in the same directory, you need to import your own package to test only the external API.
Setup #
Directory structure:
example.com/tests/
├── blogposts.go (package blogposts)
└── blogposts_test.go (package blogposts_test)Test Implementation #
In blogposts_test.go:
package blogposts_test // Different package name
import (
"testing"
"testing/fstest"
"example.com/tests" // Import your own module
)
func TestNewBlogPosts(t *testing.T) {
...
// Access via package qualifier
posts := blogposts.NewPostsFromFS(fs)
...
}- Package name: Use
blogposts_test(with_testsuffix) - Import required: Import your own module:
"example.com/tests" - Access control: Can only use exported (capitalized) functions
- Real consumer: Tests how actual users would use your package, can’t access private functions
Test #
func TestNewBlogPosts(t *testing.T) {
const (
// Sample blog post format with metadata
firstBody = `Title: Post 1
Description: Description 1
Tags: tdd, go
---
Hello
World`
secondBody = `Title: Post 2
Description: Description 2
Tags: rust, borrow-checker
---
B
L
M`
)
// Create test file system
fs := fstest.MapFS{
"hello world.md": {Data: []byte(firstBody)},
"hello-world2.md": {Data: []byte(secondBody)},
}
posts, err := blogposts.NewPostsFromFS(fs)
if err != nil {
t.Fatal(err)
}
if len(posts) != len(fs) {
t.Errorf("got %d posts, wanted %d posts", len(posts), len(fs))
}
assertPost(t, posts[0], blogposts.Post{
Title: "Post 1",
Description: "Description 1",
Tags: []string{"tdd", "go"},
Body: `Hello
World`,
})
}
func assertPost(
t *testing.T,
got blogposts.Post,
want blogposts.Post
) {
t.Helper()
if !reflect.DeepEqual(got, want) {
t.Errorf("got %+v, want %+v", got, want)
}
}Implementation #
File System Operations #
Main function that processes the directory:
func NewPostsFromFS(fileSystem fs.FS) ([]Post, error) {
// Read directory contents
dir, err := fs.ReadDir(fileSystem, ".")
if err != nil {
return nil, err
}
var posts []Post
for _, f := range dir {
// Process each file individually
post, err := getPost(fileSystem, f.Name())
if err != nil {
return nil, err
}
posts = append(posts, post)
}
return posts, nil
}fs.ReadDir- Reads directory contents from anyfs.FS, returning[]DirEntryDirEntry- Represents directory entries with file information
Helper function to process individual files:
func getPost(fileSystem fs.FS, fileName string) (Post, error) {
postFile, err := fileSystem.Open(fileName)
if err != nil {
return Post{}, err
}
defer postFile.Close()
return newPost(postFile) // Parse file content
}Post Structure and Constants #
Define the Post type and parsing constants:
type Post struct {
Title string
Description string
Tags []string
Body string
}
const (
titleSeparator = "Title: "
descriptionSeparator = "Description: "
tagsSeparator = "Tags: "
)Content Parsing with bufio.Scanner #
Main parsing function using bufio.Scanner:
func newPost(postBody io.Reader) (Post, error) {
// Use bufio.Scanner for line-by-line reading
scanner := bufio.NewScanner(postBody)
// Helper to read metadata lines
readMetaLine := func(tagName string) string {
scanner.Scan()
return strings.TrimPrefix(scanner.Text(), tagName)
}
return Post{
Title: readMetaLine(titleSeparator),
Description: readMetaLine(descriptionSeparator),
Tags: strings.Split(readMetaLine(tagsSeparator), ", "),
Body: readBody(scanner), // Read remaining content
}, nil
}io.Reader interface: newPost accepts io.Reader, so parsing works with any readable source:
- Files (from
fs.FS) - Strings (
strings.Reader) - In-memory data (
bytes.Reader)
Body content extraction:
func readBody(scanner *bufio.Scanner) string {
scanner.Scan() // Skip separator line (---)
buf := bytes.Buffer{}
for scanner.Scan() {
fmt.Fprintln(&buf, scanner.Text())
}
return strings.TrimSuffix(buf.String(), "\n")
}scanner.Scan()- Advances to next line, returns false at EOFscanner.Text()- Returns current line as string
Usage #
Real-world usage with local file system:
func main() {
// Use os.DirFS to read from local directory
posts, err := blogposts.NewPostsFromFS(os.DirFS("posts"))
if err != nil {
log.Fatal(err)
}
log.Println(posts)
}os.DirFS("posts")- Createsfs.FSfrom local directory- Same interface - Works identically to test
fstest.MapFS