samurai

package module
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Mar 2, 2026 License: MIT Imports: 7 Imported by: 0

README

Samurai 侍

Scoped testing for Go.

Go Reference Go Version Zero Dependencies GoLand Plugin

A scoped testing framework for Go with path isolation. You define a test tree using a single Test() method, the framework discovers all leaf paths, then runs each one independently with fresh local variables. Parallel by default via t.Parallel(). Zero dependencies, bring your own assertion library.

Why "samurai"? (as Yoda would say)

No goal, a samurai has. Only the path. Each test -- a samurai it is, following its own path from root to leaf. Cross paths, two samurai never do. Walk in parallel they will, each with its own state, its own setup, its own cleanup. Shared mutable ground? Exists not. The path, there is only.

What it does

You write a builder function that describes a tree of tests. Samurai runs the builder once in discovery mode to collect all the paths from root to leaf. Then, for each path, it runs the builder again from scratch in execution mode. Because the builder re-runs per path, local variables (var db *DB, var user *User, etc.) are fresh allocations every time. Paths can't interfere with each other.

All paths call t.Parallel() by default, so they run concurrently. There's no goroutine-local storage or global state involved. You bring your own assertion library (testify, is, plain t.Error, whatever). Cleanups registered via w.Cleanup() run in LIFO order even if the test panics.

Install

go get github.com/zerosixty/samurai

Requires Go 1.24+.

Quick Start

samurai.Run takes a builder function. Variables declared in the builder are fresh per path because the builder re-runs for each leaf:

package db_test

import (
    "context"
    "database/sql"
    "testing"

    "github.com/zerosixty/samurai"
)

func TestDatabase(t *testing.T) {
    samurai.Run(t, func(s *samurai.Scope) {
        var db *sql.DB  // fresh allocation for every path

        s.Test("with database", func(ctx context.Context, w samurai.W) {
            db = openTestDB(ctx)
            w.Cleanup(func() { db.Close() })
        }, func(s *samurai.Scope) {
            s.Test("can ping", func(ctx context.Context, w samurai.W) {
                // this db belongs only to this path
                if err := db.PingContext(ctx); err != nil {
                    w.Testing().Fatal(err)
                }
            })

            s.Test("can query", func(ctx context.Context, w samurai.W) {
                // different path, different db instance
                rows, err := db.QueryContext(ctx, "SELECT 1")
                if err != nil {
                    w.Testing().Fatal(err)
                }
                rows.Close()
            })
        })
    })
}

Two paths get discovered: with database/can ping and with database/can query. The builder runs fresh for each, so db is a new variable both times. Both paths run in parallel.

go test -v
=== RUN   TestDatabase
=== RUN   TestDatabase/with_database
=== RUN   TestDatabase/with_database/can_ping
=== RUN   TestDatabase/with_database/can_query
--- PASS: TestDatabase (0.00s)
    --- PASS: TestDatabase/with_database (0.00s)
        --- PASS: TestDatabase/with_database/can_ping (0.00s)
        --- PASS: TestDatabase/with_database/can_query (0.00s)

AI-assisted development

If you use Claude Code, run this once in your project root to install a samurai skill:

go run github.com/zerosixty/samurai/cmd/claude-setup@latest

This creates .claude/skills/samurai/SKILL.md. Claude Code will use it as a reference when writing or modifying samurai tests. You can also invoke /samurai to load the reference manually.

How it works

Database testing

This is where path isolation pays off. Provision a fresh database in the parent Test callback — every leaf gets its own isolated instance automatically:

func newPool(ctx context.Context, t *testing.T) *pgxpool.Pool {
    t.Helper()
    conf := pgtestdb.Custom(t, dbConf, migrator) // fresh DB with migrations applied
    pool, err := pgxpool.New(ctx, conf.URL())
    if err != nil {
        t.Fatal(err)
    }
    t.Cleanup(pool.Close)
    return pool
}

func TestTodoRepo(t *testing.T) {
    samurai.Run(t, func(s *samurai.Scope) {
        var repo *todo.Repo

        s.Test("with fresh database", func(ctx context.Context, w samurai.W) {
            pool := newPool(ctx, w.Testing())
            repo = todo.NewRepo(pool)
        }, func(s *samurai.Scope) {

            s.Test("Add", func(ctx context.Context, w samurai.W) {
                id, err := repo.Add(ctx, "buy milk")
                assert.NoError(w.Testing(), err)
                assert.Positive(w.Testing(), id)
            })

            s.Test("Get returns the created todo", func(ctx context.Context, w samurai.W) {
                id, _ := repo.Add(ctx, "test get")
                got, err := repo.Get(ctx, id)
                assert.NoError(w.Testing(), err)
                assert.Equal(w.Testing(), "test get", got.Title)
            })
        })
    })
}

Each leaf (Add, Get returns the created todo) gets its own PostgreSQL database — newPool runs fresh for each path. Both tests execute in parallel with zero interference.

This example uses pgtestdb for fast database provisioning via PostgreSQL template databases, but samurai has no dependency on it. The same pattern works with testcontainers-go, custom shell scripts, or any other database provisioning approach.

See examples/pgtestdb for the full working example with docker-compose, Atlas migrations, and pgx.

Test - the only method

Test is the only method on *Scope. Two forms:

Leaf test (no children):

s.Test("check value", func(_ context.Context, w samurai.W) {
    assert.Equal(w.Testing(), expected, actual)
})

Parent test (with children):

s.Test("setup db", func(ctx context.Context, w samurai.W) {
    db = setupDB(ctx)
    w.Cleanup(func() { db.Close() })
}, func(s *samurai.Scope) {
    s.Test("has tables", func(_ context.Context, w samurai.W) { /* ... */ })
    s.Test("has indexes", func(_ context.Context, w samurai.W) { /* ... */ })
})

The first parameter is context.Context (from T.Context()), the second is W (*BaseContext):

Method Returns Purpose
w.Testing() *testing.T The test instance for this path
w.Cleanup(func()) - Register LIFO cleanup, runs even on panic

Callbacks only execute during the execution phase, never during discovery. Multiple Test calls per scope become siblings.

Nesting

The third argument to Test is a builder for the child scope:

samurai.Run(t, func(s *samurai.Scope) {
    var svc *UserService

    s.Test("with service", func(ctx context.Context, w samurai.W) {
        svc = NewUserService(openTestDB(ctx))
        w.Cleanup(func() { /* cleanup */ })
    }, func(s *samurai.Scope) {
        var user *User

        s.Test("create user", func(ctx context.Context, w samurai.W) {
            user, _ = svc.Create(ctx, "test@example.com")
        }, func(s *samurai.Scope) {
            s.Test("has correct email", func(_ context.Context, w samurai.W) {
                assert.Equal(w.Testing(), "test@example.com", user.Email)
            })

            s.Test("has an ID", func(_ context.Context, w samurai.W) {
                assert.NotZero(w.Testing(), user.ID)
            })

            s.Test("then deleting", func(ctx context.Context, w samurai.W) {
                svc.Delete(ctx, user.ID)
            }, func(s *samurai.Scope) {
                s.Test("no longer exists", func(ctx context.Context, w samurai.W) {
                    _, err := svc.Get(ctx, user.ID)
                    assert.ErrorIs(w.Testing(), err, ErrNotFound)
                })
            })
        })

        s.Test("list empty", func(ctx context.Context, w samurai.W) {
            users, err := svc.List(ctx)
            assert.NoError(w.Testing(), err)
            assert.Empty(w.Testing(), users)
        })
    })
})

Each Test name creates a level. The path with service/create user/has correct email runs: "with service" setup, then "create user" setup, then the email assertion.

Two-phase execution

Code inside Test callbacks runs once per path. Code inline in the builder runs during both discovery and execution.

// WRONG - CreateUser runs during discovery AND execution
s.Test("setup", func(_ context.Context, w samurai.W) {}, func(s *samurai.Scope) {
    user := db.CreateUser("test@example.com")  // runs twice!
    s.Test("check", func(_ context.Context, w samurai.W) { /* ... */ })
})

// CORRECT - CreateUser only runs during execution
s.Test("setup", func(_ context.Context, w samurai.W) {}, func(s *samurai.Scope) {
    var user *User
    s.Test("create user", func(_ context.Context, w samurai.W) {
        user = db.CreateUser("test@example.com")  // runs once per path
    }, func(s *samurai.Scope) {
        s.Test("check", func(_ context.Context, w samurai.W) { /* ... */ })
    })
})

Variable declarations (var user *User) are fine inline since they're zero-value allocations. Side effects (database calls, HTTP requests, file I/O) go inside Test callbacks.

Execution

Given this builder:

samurai.Run(t, func(s *samurai.Scope) {
    s.Test("with database", func(ctx context.Context, w samurai.W) {
        /* setup DB */
    }, func(s *samurai.Scope) {
        s.Test("users", func(_ context.Context, w samurai.W) {
            /* create user */
        }, func(s *samurai.Scope) {
            s.Test("has email", func(_ context.Context, w samurai.W) { /* assert */ })
            s.Test("has name", func(_ context.Context, w samurai.W) { /* assert */ })
        })

        s.Test("can query", func(_ context.Context, w samurai.W) { /* assert */ })
    })
})

Samurai produces:

Builder tree:                       Discovered paths:

  Root                              1. with database/users/has email
  └── Test "with database"          2. with database/users/has name
      ├── Test "users"              3. with database/can query
      │   ├── Test "has email"
      │   └── Test "has name"       Execution (each path runs fresh):
      └── Test "can query"
                                    Path 1: setup DB → create user → assert email
                                    Path 2: setup DB → create user → assert name
                                    Path 3: setup DB → assert query

These become nested t.Run calls:

t.Run("with database", ...)           // intermediate scope
    t.Run("users", ...)               // intermediate scope
        t.Run("has email", ...)       // leaf - executes Path 1
        t.Run("has name", ...)        // leaf - executes Path 2
    t.Run("can query", ...)           // leaf - executes Path 3

Each path re-executes the full chain from root to leaf.

Skipping tests

s.Skip() marks all tests in the current scope as skipped. Skipped tests appear in go test -v output as SKIP but their callbacks never execute. Skip propagates to all nested scopes.

samurai.Run(t, func(s *samurai.Scope) {
    s.Test("working feature", func(_ context.Context, w samurai.W) {
        // this runs normally
    })

    s.Test("WIP feature", func(_ context.Context, w samurai.W) {
        // setup
    }, func(s *samurai.Scope) {
        s.Skip()
        s.Test("not ready yet", func(_ context.Context, w samurai.W) {
            // never executes
        })
    })
})

Call order doesn't matter — Skip() affects the entire scope regardless of whether Test() was called before or after. No callbacks, cleanups, or factory calls execute for skipped paths.

Options

Parallel (default)

Tests run in parallel via t.Parallel(). Control concurrency with:

go test -parallel 4 ./...
Sequential

Force sequential execution:

samurai.Run(t, func(s *samurai.Scope) {
    s.Test("first", func(_ context.Context, w samurai.W) { /* runs 1st */ })
    s.Test("second", func(_ context.Context, w samurai.W) { /* runs 2nd */ })
    s.Test("third", func(_ context.Context, w samurai.W) { /* runs 3rd */ })
}, samurai.Sequential())

Useful when tests modify global state or hit resources that don't handle concurrent access.

Cleanup

Register cleanup functions with w.Cleanup(). They run in LIFO order, even on panic:

s.Test("with resources", func(ctx context.Context, w samurai.W) {
    db := openDB(ctx)
    w.Cleanup(func() { db.Close() })  // runs second

    tx, _ := db.Begin()
    w.Cleanup(func() { tx.Rollback() })  // runs first
})

Cleanups run even if the test panics. Each scope has its own cleanup chain, and inner scopes clean up before outer scopes. A panicking cleanup doesn't prevent the rest from running.

samurai.Run(t, func(s *samurai.Scope) {
    s.Test("with outer resource", func(_ context.Context, w samurai.W) {
        w.Cleanup(func() { /* outer: runs last */ })
    }, func(s *samurai.Scope) {
        s.Test("with inner resource", func(_ context.Context, w samurai.W) {
            w.Cleanup(func() { /* inner: runs first */ })
        }, func(s *samurai.Scope) {
            s.Test("leaf", func(_ context.Context, w samurai.W) { /* ... */ })
        })
    })
})
// cleanup order: inner → outer

Custom context with RunWith

The boilerplate problem

With assertion libraries like testify you end up writing w.Testing() in every leaf:

s.Test("has email", func(_ context.Context, w samurai.W) {
    assert.Equal(w.Testing(), "test@example.com", user.Email)
})
RunWith

RunWith is the generic version of Run. You give it a factory that builds a custom context type, and that type gets passed to all callbacks instead of W:

package service_test

import (
    "context"
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/zerosixty/samurai"
)

type MyCtx struct {
    *samurai.BaseContext
    *assert.Assertions
}

type S = samurai.TestScope[*MyCtx]

func TestUserService(t *testing.T) {
    samurai.RunWith(t, func(w samurai.W) *MyCtx {
        return &MyCtx{BaseContext: w, Assertions: assert.New(w.Testing())}
    }, func(s *S) {
        var svc *UserService

        s.Test("with service", func(ctx context.Context, c *MyCtx) {
            db := NewTestDB(ctx)
            svc = NewUserService(db)
            c.Cleanup(func() { db.Close() })
        }, func(s *S) {
            var user *User

            s.Test("create user", func(ctx context.Context, c *MyCtx) {
                var err error
                user, err = svc.Create(ctx, "samurai@example.com")
                c.NoError(err)
            }, func(s *S) {
                s.Test("has the correct email", func(_ context.Context, c *MyCtx) {
                    c.Equal("samurai@example.com", user.Email)
                })

                s.Test("has a non-zero ID", func(_ context.Context, c *MyCtx) {
                    c.NotZero(user.ID)
                })
            })

            s.Test("list empty", func(ctx context.Context, c *MyCtx) {
                users, err := svc.List(ctx)
                c.NoError(err)
                c.Empty(users)
            })
        })
    })
}

A few things to note:

  • The factory func(W) V runs once per scope level with that level's *BaseContext
  • All callbacks receive V instead of W
  • BaseContext uses Testing() instead of T() to avoid conflicts with testify's T() method
  • Scope is a type alias for TestScope[W], so Run just delegates to RunWith[W]
  • Go 1.24+ generic type aliases let you write type S = TestScope[*MyCtx]

The factory can return whatever you want. Here's one that bundles assertions with a database:

type testEnv struct {
    *samurai.BaseContext
    Assert *assert.Assertions
    DB     *sql.DB
}

type S = samurai.TestScope[*testEnv]

samurai.RunWith(t, func(w samurai.W) *testEnv {
    return &testEnv{
        BaseContext: w,
        Assert:     assert.New(w.Testing()),
        DB:         openTestDB(w.Testing()),
    }
}, func(s *S) {
    s.Test("db is alive", func(ctx context.Context, env *testEnv) {
        env.Assert.NoError(env.DB.PingContext(ctx))
    })
})

IDE support

Samurai emits nested t.Run calls, so IDE test runners and -run flags work as expected:

go test -run "TestUserService/with_service/create_user/has_the_correct_email" -v
GoLand plugin

Install the Samurai Test Runner plugin for click-to-navigate from test results to s.Test() source locations, and gutter run icons with pass/fail status.

Screenshots

GoLand and VS Code show the green play button next to test functions. Test output shows the full path:

=== RUN   TestUserService
=== RUN   TestUserService/with_service
=== RUN   TestUserService/with_service/create_user
=== RUN   TestUserService/with_service/create_user/has_the_correct_email
=== RUN   TestUserService/with_service/create_user/has_a_non-zero_ID
=== RUN   TestUserService/with_service/list_empty
--- PASS: TestUserService (0.00s)
    --- PASS: TestUserService/with_service (0.00s)
        --- PASS: TestUserService/with_service/create_user (0.00s)
            --- PASS: TestUserService/with_service/create_user/has_the_correct_email (0.00s)
            --- PASS: TestUserService/with_service/create_user/has_a_non-zero_ID (0.00s)
        --- PASS: TestUserService/with_service/list_empty (0.00s)

API

// Entry points
func Run(t *testing.T, builder func(*Scope), opts ...Option)
func RunWith[V Context](t *testing.T, factory func(W) V, builder func(*TestScope[V]), opts ...Option)

// Scope types
type TestScope[V Context] struct{ /* unexported */ }
type Scope = TestScope[W]

// Scope methods:
func (s *TestScope[V]) Test(name string, fn func(context.Context, V))                       // leaf
func (s *TestScope[V]) Test(name string, fn func(context.Context, V), func(*TestScope[V]))  // parent
func (s *TestScope[V]) Skip()                                                                // skip all tests in scope

// Context constraint
type Context interface {
    Testing() *testing.T
    Cleanup(func())
}

// Default test context
type BaseContext struct{ /* unexported */ }
func (b *BaseContext) Testing() *testing.T
func (b *BaseContext) Cleanup(fn func())

// Type aliases
type W = *BaseContext

// Options
func Sequential() Option
func Parallel() Option    // default

License

MIT

Documentation

Overview

Package samurai provides a scoped testing framework with path isolation for Go.

Samurai is inspired by GoConvey but modernized with:

  • Explicit context passing (no GLS/goroutine-local-storage)
  • Standard context.Context support for cancellation/timeouts
  • Assertion-library agnostic (users bring their own)
  • Zero external dependencies
  • Parallel execution by default
  • No double execution - code runs exactly once per test path

Usage

func TestDatabase(t *testing.T) {
    samurai.Run(t, func(s *samurai.Scope) {
        var db *DB

        s.Test("with database", func(ctx context.Context, w samurai.W) {
            db = setupDB(ctx)
            w.Cleanup(func() { db.Close() })
        }, func(s *samurai.Scope) {
            var user *User

            s.Test("create user", func(_ context.Context, w samurai.W) {
                user = db.CreateUser("test@example.com")
            }, func(s *samurai.Scope) {
                s.Test("has email", func(_ context.Context, w samurai.W) {
                    assert.Equal(w.Testing(), "test@example.com", user.Email)
                })

                s.Test("has name", func(_ context.Context, w samurai.W) {
                    assert.NotEmpty(w.Testing(), user.Name)
                })
            })

            s.Test("can query all", func(_ context.Context, w samurai.W) {
                _, err := db.QueryAll()
                assert.NoError(w.Testing(), err)
            })
        })
    })
}

RunWith — Generic Custom Context

RunWith lets you provide a factory that creates a custom context for all callbacks. Embed *BaseContext in your struct to get Testing() and Cleanup() for free:

type MyCtx struct {
    *samurai.BaseContext
    *assert.Assertions
}
type S = samurai.TestScope[*MyCtx]

func TestDatabase(t *testing.T) {
    samurai.RunWith(t, func(w samurai.W) *MyCtx {
        return &MyCtx{BaseContext: w, Assertions: assert.New(w.Testing())}
    }, func(s *S) {
        var count int
        s.Test("setup", func(_ context.Context, c *MyCtx) {
            count = 42
        }, func(s *S) {
            s.Test("check", func(_ context.Context, c *MyCtx) {
                c.Equal(42, count)
            })
        })
    })
}

Execution Model

Samurai uses a two-phase execution model:

  1. Discovery — the builder runs to collect test structure (no test code executes)
  2. Execution — the builder runs fresh per leaf path (test code executes)

This means:

  • w.Testing() always returns a valid *testing.T (never nil)
  • Each path's code runs exactly once
  • Variables declared in builders are isolated per path

Thread Safety

Samurai achieves thread safety through isolation by design:

  • Each test path gets its own execution context
  • The testing.T instance is per-path (Go's t.Run creates subtests)
  • context.Context is shared within a path but immutable

IDE Integration

Paths are emitted as nested t.Run calls mirroring the test tree structure. Each Test name creates a level in the tree. This enables:

  • Running individual paths via `go test -run "TestName/Parent/Child"`
  • Clicking on specific paths in GoLand/VS Code to run or debug them
  • Proper test reporting per path

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func Run

func Run(t *testing.T, builder func(*Scope), opts ...Option)

Run executes a test using the scoped builder API. Variables declared in the builder function are allocated fresh per path, making them parallel-safe without any special framework types.

The only scope method is Test:

s.Test(name, func(context.Context, W))                // leaf test
s.Test(name, func(context.Context, W), func(*Scope))  // parent with children

Example:

func TestDatabase(t *testing.T) {
    samurai.Run(t, func(s *samurai.Scope) {
        var db *DB

        s.Test("with database", func(ctx context.Context, w samurai.W) {
            db = setupDB(ctx)
            w.Cleanup(func() { db.Close() })
        }, func(s *samurai.Scope) {
            s.Test("can query", func(_ context.Context, w samurai.W) {
                _, err := db.QueryAll()
                assert.NoError(w.Testing(), err)
            })
        })
    })
}
Example

ExampleRun demonstrates the test tree structure built by Run. Run is called inside a Go test function: func TestXxx(t *testing.T). This example shows path discovery — the first phase of the two-phase model. In real tests, Run handles both discovery and execution automatically.

paths, _ := collectScopedPaths(func(s *Scope) {
	s.Test("with database", func(_ context.Context, _ W) {
		// setup: create DB, register cleanup
	}, func(s *Scope) {
		s.Test("can query", func(_ context.Context, _ W) {})
		s.Test("can insert", func(_ context.Context, _ W) {})
	})
})
for _, p := range paths {
	fmt.Println(strings.Join(p.segments, "/"))
}
Output:

with database/can query
with database/can insert
Example (Nested)

ExampleRun_nested demonstrates deeply nested test paths with multiple branches. Each leaf path executes independently with fresh parent setup.

paths, _ := collectScopedPaths(func(s *Scope) {
	s.Test("database", func(_ context.Context, _ W) {}, func(s *Scope) {
		s.Test("users", func(_ context.Context, _ W) {}, func(s *Scope) {
			s.Test("create", func(_ context.Context, _ W) {})
			s.Test("delete", func(_ context.Context, _ W) {})
		})
		s.Test("posts", func(_ context.Context, _ W) {}, func(s *Scope) {
			s.Test("list", func(_ context.Context, _ W) {})
		})
	})
})
for _, p := range paths {
	fmt.Println(strings.Join(p.segments, "/"))
}
Output:

database/users/create
database/users/delete
database/posts/list
Example (Skip)

ExampleRun_skip demonstrates the Skip method. Skip marks all tests in a scope as skipped — their callbacks never execute. Skip propagates to nested scopes. Call order relative to Test does not matter.

paths, _ := collectScopedPaths(func(s *Scope) {
	s.Test("stable feature", func(_ context.Context, _ W) {}, func(s *Scope) {
		s.Test("works", func(_ context.Context, _ W) {})
	})

	s.Test("WIP feature", func(_ context.Context, _ W) {}, func(s *Scope) {
		s.Skip()
		s.Test("not ready", func(_ context.Context, _ W) {})
	})
})
for _, p := range paths {
	path := strings.Join(p.segments, "/")
	if p.skipped {
		fmt.Println(path + " (skipped)")
	} else {
		fmt.Println(path)
	}
}
Output:

stable feature/works
WIP feature/not ready (skipped)

func RunWith

func RunWith[V Context](t *testing.T, factory func(W) V, builder func(*TestScope[V]), opts ...Option)

RunWith executes a test using the scoped builder API with a custom factory. The factory creates a value of type V from *BaseContext for each scope level. This value is passed as the second argument to all Test callbacks.

Use RunWith when you want callbacks to receive a custom type (e.g. an assertion helper). Embed *BaseContext in your custom type to satisfy the Context constraint.

Example:

type MyCtx struct {
    *samurai.BaseContext
    *assert.Assertions
}
type S = samurai.TestScope[*MyCtx]

func TestDatabase(t *testing.T) {
    samurai.RunWith(t, func(w samurai.W) *MyCtx {
        return &MyCtx{BaseContext: w, Assertions: assert.New(w.Testing())}
    }, func(s *S) {
        var count int
        s.Test("setup", func(_ context.Context, c *MyCtx) {
            count = 42
        }, func(s *S) {
            s.Test("check", func(_ context.Context, c *MyCtx) {
                c.Equal(42, count)
            })
        })
    })
}
Example

ExampleRunWith demonstrates the generic RunWith variant with a custom context. RunWith lets callbacks receive a custom type (e.g. an assertion helper) instead of the default *BaseContext.

type myCtx struct{ *BaseContext }

paths, _ := collectScopedPaths(func(s *TestScope[*myCtx]) {
	s.Test("setup", func(_ context.Context, _ *myCtx) {
		// factory creates *myCtx from *BaseContext per scope level
	}, func(s *TestScope[*myCtx]) {
		s.Test("check A", func(_ context.Context, _ *myCtx) {})
		s.Test("check B", func(_ context.Context, _ *myCtx) {})
	})
})
for _, p := range paths {
	fmt.Println(strings.Join(p.segments, "/"))
}
Output:

setup/check A
setup/check B

Types

type BaseContext

type BaseContext struct {
	// contains filtered or unexported fields
}

BaseContext is the framework-provided test context. It implements the Context interface, providing Testing() and Cleanup().

Users can embed *BaseContext in their own struct to create custom test contexts for use with RunWith. The embedded methods (Testing, Cleanup) are promoted, so the custom type satisfies Context automatically.

Example:

type MyCtx struct {
    *samurai.BaseContext
    *assert.Assertions
}

func (*BaseContext) Cleanup

func (b *BaseContext) Cleanup(fn func())

Cleanup registers a cleanup function that runs after the current scope completes. Multiple cleanups execute in LIFO order (last registered runs first), matching the behavior of Go's t.Cleanup and defer. Cleanups run even if the step panics.

func (*BaseContext) Testing

func (b *BaseContext) Testing() *testing.T

Testing returns the underlying *testing.T for use with any assertion library. Always returns a valid *testing.T (never nil).

type Context

type Context interface {
	Testing() *testing.T
	Cleanup(func())
}

Context is the constraint for custom test context types used with RunWith. Any type satisfying this interface can be used as a test context. The simplest way to satisfy it is to embed *BaseContext.

type Option

type Option func(*runConfig)

Option configures the behavior of Run.

func Parallel

func Parallel() Option

Parallel explicitly enables parallel execution using t.Parallel(). This is the default behavior, so Parallel() is only needed to override a previous Sequential() call or for documentation purposes.

To control the number of parallel tests, use the standard Go test flag:

go test -parallel N ./...

The default parallelism is GOMAXPROCS.

func Sequential

func Sequential() Option

Sequential forces sequential execution. Use this when tests require deterministic execution order. By default, tests run in parallel using t.Parallel().

type Scope

type Scope = TestScope[W]

Scope is the default non-generic scope used by Run. It is a type alias for TestScope[W], where W = *BaseContext.

type TestScope

type TestScope[V Context] struct {
	// contains filtered or unexported fields
}

TestScope is the generic scope builder for defining test structure with shared state. The type parameter V determines the test context type passed to all callbacks. Variables declared in the builder function are allocated fresh per path, making them parallel-safe without any special framework types.

Methods:

s.Test(name, fn)          — leaf test (no children)
s.Test(name, fn, builder) — parent test with children
s.Skip()                  — skip all tests in this scope and descendants

Multiple Test calls per scope are allowed — they become siblings in the test tree.

Example with RunWith:

type MyCtx struct {
    *samurai.BaseContext
    *assert.Assertions
}
type S = samurai.TestScope[*MyCtx]

samurai.RunWith(t, func(w samurai.W) *MyCtx {
    return &MyCtx{BaseContext: w, Assertions: assert.New(w.Testing())}
}, func(s *S) {
    var db *DB

    s.Test("with database", func(ctx context.Context, w *MyCtx) {
        db = setupDB(ctx)
        w.Cleanup(func() { db.Close() })
    }, func(s *S) {
        s.Test("has tables", func(_ context.Context, c *MyCtx) {
            c.NotEmpty(db.Tables())
        })
    })
})

func (*TestScope[V]) Skip

func (s *TestScope[V]) Skip()

Skip marks all tests in this scope as skipped. Skipped tests appear in output as SKIP but their callbacks never execute. The call order relative to Test does not matter — Skip affects the entire scope.

Skip propagates to all nested scopes: if a parent scope is skipped, all descendants are skipped regardless of whether they call Skip themselves.

s.Test("WIP feature", fn, func(s *Scope) {
    s.Skip()
    s.Test("todo", fn) // skipped
})

func (*TestScope[V]) Test

func (s *TestScope[V]) Test(name string, fn func(context.Context, V), builders ...func(*TestScope[V]))

Test registers a named test in this scope. The name appears in the test tree (go test -v output and IDE).

Without a builder argument, Test creates a leaf test:

s.Test("check value", func(_ context.Context, w W) {
    assert.Equal(w.Testing(), expected, actual)
})

With a builder argument, Test creates a parent with children:

s.Test("setup db", func(ctx context.Context, w W) {
    db = setupDB(ctx)
    w.Cleanup(func() { db.Close() })
}, func(s *Scope) {
    s.Test("has tables", func(_ context.Context, w W) { ... })
})

Multiple Test calls per scope are allowed — they become siblings. In discovery mode: records the test without executing fn. In execution mode: records the test; fn is executed later by the execution engine.

type W

type W = *BaseContext

W is the default test context — a pointer to BaseContext. It is used as the context type for the non-generic Run entry point.

Directories

Path Synopsis
cmd
claude-setup command
Command claude-setup creates a Claude Code skill for the Samurai testing framework.
Command claude-setup creates a Claude Code skill for the Samurai testing framework.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL