Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ linters:
cyclop:
max-complexity: 15
funlen:
lines: 80
lines: 90
gosec:
excludes:
- G304
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

### Changed

- Panic failure messages now name the phase that panicked (test, sub-test, or the specific suite/plugin hook).
- Panics in `BeforeAll` & `AfterAll` hooks (both suite and plugin) are now caught and recorded as test failures; a panic in `BeforeAll` still prevents the suite tests from running.
- `t.Setenv` and `t.Chdir` now enforce the standard library's conflict guards against `t.Parallel`.
- The error for a non-pointer exported field now names the owning type and field.
- Better wording for a warning when `CasesXxx` returns empty slice.

### Fixed

- Panics in tests now record the actual recovered value, are observed by after-hooks as a failed test.
- Plugin hooks and overrides with equal priority now run deterministically, in the declaration order of plugins.
- Overrides are now applied in the documented priority order, consistent with hooks.
- `t.Error` called after `t.Fatal` (e.g. from a cleanup) no longer downgrades the recorded fatal failure kind.
- Annotations no longer leak between instantiations of a generic suite.
- Persistent cache reads no longer follow a symlink swapped in after validation.

## [1.6.0] - 2026-07-17

### Added
Expand Down
20 changes: 19 additions & 1 deletion annotations.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,5 +86,23 @@ func getID[Suite any](test reflect.Value) testID {

name = strings.ReplaceAll(name, "(*"+suiteName+")", suiteName)

return testID(name)
// Runtime erases generic type arguments to "[...]" in function names:
//
// KVSuite[int].TestGet -> "pkg.KVSuite[...].TestGet"
// KVSuite[string].TestGet -> "pkg.KVSuite[...].TestGet" (same!)
//
// while suiteName keeps them ("KVSuite[int]").
//
// Code below gets us:
//
// KVSuite[int]|pkg.KVSuite[...].TestGet
// KVSuite[string]|pkg.KVSuite[...].TestGet

if base, _, isGeneric := strings.Cut(suiteName, "["); isGeneric {
erased := base + "[...]"

name = strings.ReplaceAll(name, "(*"+erased+")", erased)
}

return testID(suiteName + "|" + name)
}
2 changes: 1 addition & 1 deletion collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@ func (tc *testsCollector[Suite, T]) newParametrizedTest(

warnf(
tb,
"(%[1]s).Cases%[2]s provides zero values, (%[1]s).%[3]s won't run",
"(%[1]s).Cases%[2]s returned empty slice, (%[1]s).%[3]s will not run",
structName,
caseName,
method.Name,
Expand Down
111 changes: 53 additions & 58 deletions construct.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package testo
import (
"fmt"
"reflect"
"slices"

"github.com/ozontech/testo/internal/reflectutil"
"github.com/ozontech/testo/internal/stack"
Expand Down Expand Up @@ -61,66 +62,48 @@ func construct[T CommonT](
))
}

//nolint:nestif // TODO: factor out common logic
if parent == nil {
pluginTypes := make(map[reflect.Type]struct{})

collectPlugins(realType, pluginTypes)

delete(pluginTypes, realType)

plugins := make(map[reflect.Type]testoplugin.Plugin, len(pluginTypes))

for pluginType := range pluginTypes {
var child testoplugin.Plugin

if pluginType == reflect.TypeFor[*testoT]() {
child = &seed
} else {
v := reflect.New(pluginType.Elem())

reflectutil.Fill(v)

child = v.Interface().(testoplugin.Plugin)
}

plugins[pluginType] = child
}

seed.plugins = plugins
if parent != nil {
seed.pluginOrder = (*parent).unwrap().pluginOrder
} else {
parentUnwrapped := (*parent).unwrap()
seen := make(map[reflect.Type]struct{})

plugins := make(map[reflect.Type]testoplugin.Plugin, len(parentUnwrapped.plugins))
collectPlugins(realType, seen, &seed.pluginOrder)

for pluginType := range parentUnwrapped.plugins {
var child testoplugin.Plugin
// the T type itself is not a plugin on its own.
seed.pluginOrder = slices.DeleteFunc(seed.pluginOrder, func(typ reflect.Type) bool {
return typ == realType
})
}

if pluginType == reflect.TypeFor[*testoT]() {
child = &seed
} else {
v := reflect.New(pluginType.Elem())
plugins := make(map[reflect.Type]testoplugin.Plugin, len(seed.pluginOrder))

reflectutil.Fill(v)
for _, pluginType := range seed.pluginOrder {
var child testoplugin.Plugin

child = v.Interface().(testoplugin.Plugin)
}
if pluginType == reflect.TypeFor[*testoT]() {
child = &seed
} else {
v := reflect.New(pluginType.Elem())

plugins[pluginType] = child
reflectutil.Fill(v)

child = v.Interface().(testoplugin.Plugin)
}

seed.plugins = plugins
plugins[pluginType] = child
}

seed.plugins = plugins

specsStack := stack.New[typedPlugin]()

for pluginType, pluginValue := range seed.plugins {
for _, pluginType := range seed.pluginOrder {
specsStack.Push(typedPlugin{
Plugin: pluginValue,
Plugin: seed.plugins[pluginType],
Type: pluginType,
})

setPlugins(reflect.ValueOf(pluginValue), seed.plugins, &specsStack)
setPlugins(reflect.ValueOf(seed.plugins[pluginType]), seed.plugins, &specsStack)
}

setPlugins(value, seed.plugins, &specsStack)
Expand All @@ -137,6 +120,10 @@ func construct[T CommonT](
continue
}

// For top-level tests the parent is a typed-nil instance of the
// plugin's own type: the interface is non-nil, so unconditional
// parent.(*MyPlugin) assertions keep working, and the concrete
// pointer is nil. See [testoplugin.Plugin].
var parentPlugin testoplugin.Plugin

if parent != nil {
Expand All @@ -148,7 +135,17 @@ func construct[T CommonT](
specs[p.Type] = p.Plugin.Plugin(parentPlugin, seed.options()...)
}

seed.spec = mergeSpecs(t, mapValues(specs)...)
// merge specs in the declaration order of plugins inside T so that
// equal-priority hooks and overrides run deterministically.
orderedSpecs := make([]testoplugin.Spec, 0, len(specs))

for _, pluginType := range seed.pluginOrder {
if spec, ok := specs[pluginType]; ok {
orderedSpecs = append(orderedSpecs, spec)
}
}

seed.spec = mergeSpecs(t, orderedSpecs...)

return value.Elem().Interface().(T)
}
Expand Down Expand Up @@ -205,7 +202,9 @@ func setPlugins(
if field.Kind() != reflect.Pointer {
panic(
fmt.Sprintf(
"testo: all exported fields in T must be pointers, got: %s",
"testo: all exported fields in T and plugins must be pointers: field %s.%s has non-pointer type %s",
v.Type(),
v.Type().Field(i).Name,
field.Type(),
),
)
Expand All @@ -217,9 +216,15 @@ func setPlugins(

var pluginInterfaceType = reflect.TypeFor[testoplugin.Plugin]()

func collectPlugins(typ reflect.Type, plugins map[reflect.Type]struct{}) {
// collectPlugins gathers plugin types in their declaration order:
// a depth-first walk over the exported fields of typ.
func collectPlugins(typ reflect.Type, seen map[reflect.Type]struct{}, order *[]reflect.Type) {
if typ.Implements(pluginInterfaceType) {
plugins[typ] = struct{}{}
if _, ok := seen[typ]; !ok {
seen[typ] = struct{}{}

*order = append(*order, typ)
}
}

typ = reflectutil.Elem(typ)
Expand All @@ -232,17 +237,7 @@ func collectPlugins(typ reflect.Type, plugins map[reflect.Type]struct{}) {
field := typ.Field(i)

if field.IsExported() {
collectPlugins(field.Type, plugins)
collectPlugins(field.Type, seen, order)
}
}
}

func mapValues[K comparable, V any](m map[K]V) []V {
values := make([]V, 0, len(m))

for _, v := range m {
values = append(values, v)
}

return values
}
Loading
Loading