diff --git a/run.go b/run.go index bcf606c..fc88c46 100644 --- a/run.go +++ b/run.go @@ -1,6 +1,7 @@ package testy import ( + "errors" "fmt" "runtime/debug" "sync" @@ -8,6 +9,9 @@ import ( "time" ) +// ErrPackageNotFound indicates a requested registered package does not exist. +var ErrPackageNotFound = errors.New("package not found") + // RunOptions controls how the non-go-test runner executes registered tests. type RunOptions struct { // PackageConcurrency is the maximum number of packages executed at once. @@ -15,6 +19,14 @@ type RunOptions struct { PackageConcurrency int } +// PackageSpec describes a package registered with testy. +type PackageSpec struct { + // Name is the package import path. + Name string + // Index is the package's deterministic registration-order index. + Index int +} + // RunAsTest runs all registered tests under Go's testing framework. // // To run tests on a per-package basis, put a test file in each package containing a single test that calls this function. @@ -102,13 +114,9 @@ func Run() TestResult { // RunWithOptions runs all registered tests and returns result information about them. func RunWithOptions(opts RunOptions) TestResult { start := time.Now() - results := TestResult{ - Name: "Test Suite", - Started: start, - } pkgs := collectPackages() - results.Subtests = make([]TestResult, len(pkgs)) + packageResults := make([]TestResult, len(pkgs)) packageConcurrency := opts.PackageConcurrency if packageConcurrency < 1 { @@ -117,14 +125,52 @@ func RunWithOptions(opts RunOptions) TestResult { if packageConcurrency == 1 { for i := range pkgs { - results.Subtests[i] = runPackage(pkgs[i].name, pkgs[i].tests) + packageResults[i] = runPackage(pkgs[i].name, pkgs[i].tests) } } else { - runPackagesConcurrently(pkgs, results.Subtests, packageConcurrency) + runPackagesConcurrently(pkgs, packageResults, packageConcurrency) + } + + return BuildSuiteResult(start, packageResults) +} + +// ListPackages returns the registered packages in deterministic execution order. +// +// The returned package list is intentionally independent from any execution +// backend. Callers that want to orchestrate package execution outside this +// process, such as a durable workflow engine, can use the package names with +// RunPackage and then combine the package results with BuildSuiteResult. +func ListPackages() []PackageSpec { + pkgs := collectPackages() + specs := make([]PackageSpec, len(pkgs)) + for i, pkg := range pkgs { + specs[i] = PackageSpec{ + Name: pkg.name, + Index: i, + } + } + return specs +} + +// RunPackage runs a single registered package by import path. +func RunPackage(pkg string) (TestResult, error) { + pkgTests, ok := instance.tests[pkg] + if !ok { + return TestResult{}, fmt.Errorf("%w: %s", ErrPackageNotFound, pkg) + } + return runPackage(pkg, pkgTests), nil +} + +// BuildSuiteResult combines package-level results into a suite-level result. +func BuildSuiteResult(start time.Time, packageResults []TestResult) TestResult { + results := TestResult{ + Name: "Test Suite", + Started: start, + Subtests: packageResults, } r := ResultPassed - for _, pkgResult := range results.Subtests { + for _, pkgResult := range packageResults { if pkgResult.Result == ResultFailed { r = ResultFailed break diff --git a/run_test.go b/run_test.go index 75c9f56..82411b8 100644 --- a/run_test.go +++ b/run_test.go @@ -1,6 +1,7 @@ package testy import ( + "errors" "fmt" "sync/atomic" "testing" @@ -21,6 +22,60 @@ func succeeds(ts *time.Time) Tester { *ts = time.Now() } } + +func TestPackageRunnerAPI(t *testing.T) { + instance = testy{ + tests: orderedmap.OrderedMap[string, *testPkg]{}, + } + defer func() { + instance = testy{} + }() + + for i := 0; i < 2; i++ { + pkg := fmt.Sprintf("github.com/gametimesf/testy/packageapi/pkg%d", i) + testName := fmt.Sprintf("test%d", i) + instance.tests[pkg] = &testPkg{ + name: pkg, + tests: orderedmap.OrderedMap[string, testCase]{}, + } + instance.tests[pkg].tests[testName] = testCase{ + Package: pkg, + Name: testName, + tester: succeeds(&tt), + } + } + + pkgs := ListPackages() + require.Equal(t, []PackageSpec{ + {Name: "github.com/gametimesf/testy/packageapi/pkg0", Index: 0}, + {Name: "github.com/gametimesf/testy/packageapi/pkg1", Index: 1}, + }, pkgs) + + start := time.Now() + packageResults := make([]TestResult, len(pkgs)) + for _, pkg := range pkgs { + res, err := RunPackage(pkg.Name) + require.NoError(t, err) + packageResults[pkg.Index] = res + } + suite := BuildSuiteResult(start, packageResults) + + assert.Equal(t, ResultPassed, suite.Result) + require.Len(t, suite.Subtests, 2) + assert.Equal(t, "github.com/gametimesf/testy/packageapi/pkg0", suite.Subtests[0].Package) + assert.Equal(t, "github.com/gametimesf/testy/packageapi/pkg1", suite.Subtests[1].Package) +} + +func TestRunPackageReturnsErrPackageNotFound(t *testing.T) { + instance = testy{} + defer func() { + instance = testy{} + }() + + _, err := RunPackage("github.com/gametimesf/testy/missing") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrPackageNotFound)) +} func panics(ts *time.Time) Tester { return func(TestingT) { *ts = time.Now()