storage

Testing

The storagetest conformance suite, and what a custom Driver needs to implement.

storage/storagetest is a shared conformance test suite: the same set of behavioral tests run against every Driver implementation, built-in or your own, so "does this driver actually behave like the others" is a question you answer by running one function, not by hand-writing a parallel test file per driver.

Running it against a Driver

import (
    "testing"

    "github.com/gp-system/storage/storagetest"
)

func TestMyDriver(t *testing.T) {
    storagetest.Run(t, func(t *testing.T) storage.Driver {
        dir := t.TempDir()
        driver, err := local.New(local.Config{Root: dir, Secret: "test-secret"})
        if err != nil {
            t.Fatal(err)
        }
        return driver
    }, storagetest.Options{})
}

Run(t, factory, opts) takes a fresh-driver factory, called once per subtest so tests never share state, and a storagetest.Options. It then exercises the full Driver surface: put and read round-trips, range reads, stat, list and pagination, copy, delete idempotency, ValidPath rejection of traversal attempts, and, where the driver supports it, temporary URLs. A driver that returns ErrUnsupported for an optional capability is treated as a valid, if less capable, implementation, not a failure: storagetest checks that the error is the right one, not that every driver supports everything.

Options for drivers that don't preserve everything

type Options struct {
    PersistsContentType bool
    PersistsMetadata    bool
}

Not every backend round-trips every piece of metadata; a minimal driver (say, one backed by a bare key/value store with no attribute support) might not preserve PutOptions.ContentType or other metadata across a Put/Stat cycle. Rather than forcing every driver to fake support it doesn't have, storagetest.Options lets you say so up front: leave PersistsContentType/PersistsMetadata at their zero value (false) and the suite skips the assertions that would otherwise fail on a driver that's honest about its limits.

Writing a custom Driver

The Driver interface (see Overview) is the whole surface a new backend needs to cover. A minimal skeleton:

type myDriver struct {
    name string
    // backend-specific fields
}

func (d *myDriver) Name() string { return d.name }

func (d *myDriver) Put(ctx context.Context, path string, r io.Reader, opts storage.PutOptions) error {
    if !storage.ValidPath(path) {
        return storage.ErrInvalidPath
    }
    // write r to the backend at path
    return nil
}

func (d *myDriver) Reader(ctx context.Context, path string) (io.ReadCloser, error) {
    // return the object at path, or storage.ErrNotExist if it's missing
}

func (d *myDriver) RangeReader(ctx context.Context, path string, offset, length int64) (io.ReadCloser, error) {
    // a byte-range read; length < 0 means "to the end"
}

func (d *myDriver) Stat(ctx context.Context, path string) (storage.FileInfo, error) { /* ... */ }
func (d *myDriver) List(ctx context.Context, opts storage.ListOptions) (storage.ListPage, error) { /* ... */ }
func (d *myDriver) Copy(ctx context.Context, src, dst string) error { /* ... */ }
func (d *myDriver) Delete(ctx context.Context, path string) error { /* idempotent */ }

func (d *myDriver) URL(ctx context.Context, path string) (string, error) { /* ... */ }

func (d *myDriver) TemporaryURL(ctx context.Context, path string, opts storage.TemporaryURLOptions) (string, error) {
    return "", storage.ErrUnsupported // if the backend can't sign URLs
}

func (d *myDriver) TemporaryUploadURL(ctx context.Context, path string, opts storage.TemporaryUploadURLOptions) (storage.UploadURL, error) {
    return storage.UploadURL{}, storage.ErrUnsupported
}

A few things worth calling out while writing one:

  • Call storage.ValidPath at the top of every method that takes a path and touches the backend; it's the one thing every driver is expected to share.
  • Delete must be idempotent: deleting a path that's already gone is not an error.
  • If a capability genuinely doesn't apply to the backend, return storage.ErrUnsupported rather than a half-working approximation; callers are expected to handle it (see Disk.ProvidesTemporaryURLs).
  • Implement the optional Mover interface only if the backend has a native, non-copy move; otherwise leave it unimplemented and Disk.Move falls back to Copy + Delete on your driver's behalf.
  • Run it through storagetest.Run before wiring it into anything real. It's the fastest way to find a corner (an off-by-one in a range read, a List page that doesn't paginate correctly) that a hand-written smoke test wouldn't catch.
  • Overview: the full Driver contract and the Mover interface.
  • Disk API: the methods that end up calling into your driver.
  • Memory and local disk: read these two drivers' source for a worked example of the contract.
  • S3: the third built-in driver, and the one with the most backend-specific logic.
Copyright © 2026