diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..af1ceaa --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,98 @@ +# .github/workflows/build.yml +name: build + +on: + push: + branches: ["**"] + pull_request: + +permissions: + contents: read + +jobs: + # Discover all Go modules (monorepo with multiple go.mod) OR just one (repo root). + discover: + runs-on: ubuntu-latest + outputs: + modules: ${{ steps.mods.outputs.modules }} + steps: + - uses: actions/checkout@v4 + + - id: mods + shell: bash + run: | + set -euo pipefail + + # Find all directories containing go.mod (excluding vendor and hidden dirs). + mapfile -t mods < <(find . -name go.mod -not -path "*/vendor/*" -not -path "*/.*/*" -print0 \ + | xargs -0 -n1 dirname \ + | sed 's|^\./||' \ + | sort -u) + + # If no go.mod found (rare), fail early. + if [ "${#mods[@]}" -eq 0 ]; then + echo "No go.mod found." + exit 1 + fi + + # Convert to JSON array for matrix (compact format for GitHub Actions). + json="$(printf '%s\n' "${mods[@]}" | jq -R . | jq -sc .)" + echo "modules=$json" >> "$GITHUB_OUTPUT" + echo "Discovered modules: $json" + + lint: + needs: discover + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + module: ${{ fromJson(needs.discover.outputs.modules) }} + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: ${{ matrix.module }}/go.mod + cache: true + cache-dependency-path: | + ${{ matrix.module }}/go.sum + + # Uses .golangci.yml if present at repo root (recommended). + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest + working-directory: ${{ matrix.module }} + args: --timeout=5m + + test: + needs: discover + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + module: ${{ fromJson(needs.discover.outputs.modules) }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: ${{ matrix.module }}/go.mod + cache: true + cache-dependency-path: | + ${{ matrix.module }}/go.sum + + - name: Build + working-directory: ${{ matrix.module }} + shell: bash + run: | + set -euo pipefail + go build ./... + + - name: Test + working-directory: ${{ matrix.module }} + shell: bash + run: | + set -euo pipefail + go test -race -count=1 ./... diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a3e77db --- /dev/null +++ b/Makefile @@ -0,0 +1,174 @@ +# Tideland Go Asserts - Makefile +# +# Copyright (C) 2024-2025 Frank Mueller / Tideland / Germany +# +# All rights reserved. Use of this source code is governed +# by the new BSD license. + +# Variables +GO := go +GOLANGCI_LINT := golangci-lint +GOLANGCI_LINT_VERSION := v2.7.2 +COVERAGE_FILE := coverage.out +COVERAGE_HTML := coverage.html + +# Package selection +PACKAGE ?= all +PACKAGES := verify capture generators + +# Validate package selection +ifeq ($(PACKAGE),all) + TARGET_PACKAGES := $(PACKAGES) +else + ifneq ($(filter $(PACKAGE),$(PACKAGES)),) + TARGET_PACKAGES := $(PACKAGE) + else + $(error Invalid package '$(PACKAGE)'. Valid options: all, verify, capture, generators) + endif +endif + +# Colors for output +COLOR_RESET := \033[0m +COLOR_BOLD := \033[1m +COLOR_GREEN := \033[32m +COLOR_YELLOW := \033[33m +COLOR_BLUE := \033[34m +COLOR_CYAN := \033[36m + +# Default target +.DEFAULT_GOAL := all + +# Phony targets +.PHONY: all help fmt tidy lint build test bench coverage clean install-tools check-tools ci + +## all: Run complete build process for selected package(s) (fmt, tidy, lint, build, test) +all: + @echo "$(COLOR_CYAN)$(COLOR_BOLD)Running all tasks for: $(TARGET_PACKAGES)$(COLOR_RESET)" + @$(MAKE) --no-print-directory fmt + @$(MAKE) --no-print-directory tidy + @$(MAKE) --no-print-directory lint + @$(MAKE) --no-print-directory build + @$(MAKE) --no-print-directory test + @echo "$(COLOR_GREEN)$(COLOR_BOLD)✓ All tasks completed successfully for: $(TARGET_PACKAGES)$(COLOR_RESET)" + +## help: Display this help message +help: + @echo "$(COLOR_BOLD)Tideland Go Asserts - Available Targets:$(COLOR_RESET)" + @echo "" + @sed -n 's/^##//p' $(MAKEFILE_LIST) | column -t -s ':' | sed -e 's/^/ /' + @echo "" + @echo "$(COLOR_BLUE)Usage:$(COLOR_RESET)" + @echo " make [target] # Run for all packages" + @echo " make [target] PACKAGE=verify # Run for verify package only" + @echo " make [target] PACKAGE=capture # Run for capture package only" + @echo " make [target] PACKAGE=generators # Run for generators package only" + @echo "" + @echo "$(COLOR_BLUE)Available packages:$(COLOR_RESET) $(PACKAGES)" + @echo "" + +## fmt: Format Go source files in selected package(s) +fmt: + @for pkg in $(TARGET_PACKAGES); do \ + echo "$(COLOR_YELLOW)→ Formatting $$pkg...$(COLOR_RESET)"; \ + (cd $$pkg && gofmt -s -w .) || exit 1; \ + done + @echo "$(COLOR_GREEN)✓ Code formatting completed$(COLOR_RESET)" + +## tidy: Update go.mod and go.sum files for selected package(s) +tidy: + @for pkg in $(TARGET_PACKAGES); do \ + echo "$(COLOR_YELLOW)→ Tidying $$pkg modules...$(COLOR_RESET)"; \ + (cd $$pkg && $(GO) mod tidy && $(GO) mod verify) || exit 1; \ + done + @echo "$(COLOR_GREEN)✓ Module dependencies updated$(COLOR_RESET)" + +## lint: Run golangci-lint on selected package(s) +lint: + @for pkg in $(TARGET_PACKAGES); do \ + echo "$(COLOR_YELLOW)→ Linting $$pkg...$(COLOR_RESET)"; \ + (cd $$pkg && $(GOLANGCI_LINT) run --timeout=5m) || exit 1; \ + done + @echo "$(COLOR_GREEN)✓ Linting completed$(COLOR_RESET)" + +## build: Build selected package(s) (verify compilation) +build: + @for pkg in $(TARGET_PACKAGES); do \ + echo "$(COLOR_YELLOW)→ Building $$pkg...$(COLOR_RESET)"; \ + (cd $$pkg && $(GO) build -v ./...) || exit 1; \ + done + @echo "$(COLOR_GREEN)✓ Build successful$(COLOR_RESET)" + +## test: Run tests for selected package(s) +test: + @for pkg in $(TARGET_PACKAGES); do \ + echo "$(COLOR_YELLOW)→ Testing $$pkg...$(COLOR_RESET)"; \ + (cd $$pkg && $(GO) test -v -race ./...) || exit 1; \ + done + @echo "$(COLOR_GREEN)✓ Tests passed$(COLOR_RESET)" + +## bench: Run benchmarks for selected package(s) +bench: + @for pkg in $(TARGET_PACKAGES); do \ + echo "$(COLOR_YELLOW)→ Benchmarking $$pkg...$(COLOR_RESET)"; \ + (cd $$pkg && $(GO) test -bench=. -benchmem -run=^$$ ./...) || exit 1; \ + done + @echo "$(COLOR_GREEN)✓ Benchmarks completed$(COLOR_RESET)" + +## coverage: Generate test coverage reports for selected package(s) +coverage: + @for pkg in $(TARGET_PACKAGES); do \ + echo "$(COLOR_YELLOW)→ Generating coverage for $$pkg...$(COLOR_RESET)"; \ + (cd $$pkg && \ + $(GO) test -coverprofile=$(COVERAGE_FILE) -covermode=atomic ./... && \ + $(GO) tool cover -html=$(COVERAGE_FILE) -o $(COVERAGE_HTML) && \ + $(GO) tool cover -func=$(COVERAGE_FILE) | grep total | awk '{print "Coverage: " $$3}') || exit 1; \ + echo "$(COLOR_GREEN)✓ Coverage report: $$pkg/$(COVERAGE_HTML)$(COLOR_RESET)"; \ + done + @echo "$(COLOR_GREEN)✓ Coverage reports generated$(COLOR_RESET)" + +## clean: Remove build artifacts and coverage files from selected package(s) +clean: + @for pkg in $(TARGET_PACKAGES); do \ + echo "$(COLOR_YELLOW)→ Cleaning $$pkg...$(COLOR_RESET)"; \ + (cd $$pkg && rm -f $(COVERAGE_FILE) $(COVERAGE_HTML)) || exit 1; \ + done + @echo "$(COLOR_YELLOW)→ Cleaning Go caches...$(COLOR_RESET)" + @$(GO) clean -cache -testcache -modcache + @echo "$(COLOR_GREEN)✓ Clean completed$(COLOR_RESET)" + +## install-tools: Install required development tools +install-tools: + @echo "$(COLOR_YELLOW)→ Installing development tools...$(COLOR_RESET)" + @which $(GOLANGCI_LINT) > /dev/null 2>&1 || \ + (echo "Installing golangci-lint $(GOLANGCI_LINT_VERSION)..." && \ + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $$(go env GOPATH)/bin $(GOLANGCI_LINT_VERSION)) + @$(GOLANGCI_LINT) version | grep -q "has version $(shell echo $(GOLANGCI_LINT_VERSION) | sed 's/v//')" || \ + (echo "Updating golangci-lint to $(GOLANGCI_LINT_VERSION)..." && \ + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $$(go env GOPATH)/bin $(GOLANGCI_LINT_VERSION)) + @echo "$(COLOR_GREEN)✓ Tools installed$(COLOR_RESET)" + +## check-tools: Check installed tool versions and compatibility +check-tools: + @echo "$(COLOR_YELLOW)→ Checking tool versions...$(COLOR_RESET)" + @echo "$(COLOR_BLUE)Go version:$(COLOR_RESET)" + @$(GO) version + @echo "$(COLOR_BLUE)golangci-lint version:$(COLOR_RESET)" + @$(GOLANGCI_LINT) version || echo "$(COLOR_YELLOW)golangci-lint not found - run 'make install-tools'$(COLOR_RESET)" + @echo "$(COLOR_GREEN)✓ Tool version check completed$(COLOR_RESET)" + +## ci: Run CI pipeline for selected package(s) (used by GitHub Actions) +ci: + @echo "$(COLOR_CYAN)$(COLOR_BOLD)Running CI pipeline for: $(TARGET_PACKAGES)$(COLOR_RESET)" + @$(MAKE) --no-print-directory fmt + @$(MAKE) --no-print-directory tidy + @$(MAKE) --no-print-directory lint + @$(MAKE) --no-print-directory build + @$(MAKE) --no-print-directory test + @echo "$(COLOR_GREEN)$(COLOR_BOLD)✓ CI pipeline completed for: $(TARGET_PACKAGES)$(COLOR_RESET)" + +## list-packages: List all available packages +list-packages: + @echo "$(COLOR_BLUE)Available packages:$(COLOR_RESET)" + @for pkg in $(PACKAGES); do \ + echo " - $$pkg"; \ + done diff --git a/README.md b/README.md index 23dd47e..49d97be 100644 --- a/README.md +++ b/README.md @@ -3,16 +3,164 @@ [![GitHub release](https://img.shields.io/github/release/tideland/go-asserts.svg)](https://github.com/tideland/go-asserts) [![GitHub license](https://img.shields.io/badge/license-New%20BSD-blue.svg)](https://raw.githubusercontent.com/tideland/go-asserts/main/LICENSE) [![Go Module](https://img.shields.io/github/go-mod/go-version/tideland/go-asserts)](https://github.com/tideland/go-asserts/blob/main/go.mod) -[![GoDoc](https://godoc.org/tideland.dev/go/worker?status.svg)](https://pkg.go.dev/mod/tideland.dev/go/worker?tab=packages) +[![GoDoc](https://godoc.org/tideland.dev/go/asserts?status.svg)](https://pkg.go.dev/tideland.dev/go/asserts) ## Description -Extensions to standard testing for easier assertions. +**Tideland Go Asserts** provides comprehensive testing utilities for Go, consisting of three specialized packages that work together to make testing easier, more readable, and more maintainable. -- `verify` provides different tests usable with the standard testing package. - Additionally a continued testing can easily test verifications without immediate failure. -- `capture` allows to capture stdout and stderr for verifications. -- `generate` allows to generate test data. +## Packages + +### verify - Human-Readable Assertions + +The `verify` package provides a rich set of assertion functions for Go's standard testing package. It supports continued testing mode where assertions report failures without halting execution, allowing multiple checks in a single test. + +**Key Features:** +- Type assertions: `Nil`, `NotNil`, `Equal`, `NotEqual`, etc. +- Comparison assertions: `Less`, `Greater`, `InRange`, `About`, etc. +- String assertions: `Contains`, `HasPrefix`, `Match`, etc. +- Slice and map assertions: `Length`, `Empty`, `NotEmpty`, etc. +- Error assertions: `NoError`, `ErrorContains`, `ErrorMatches`, etc. +- Channel assertions: `Readable`, `NotReadable`, `Closed`, etc. +- Predicate assertions: `True`, `False`, `Predicate`, etc. +- Continued testing mode with failure count tracking + +**Example:** + +```go +import "tideland.dev/go/asserts/verify" + +func TestMyFunction(t *testing.T) { + ct := verify.ContinuedTesting(t) + + result, err := myFunction("input") + verify.NoError(ct, err) + verify.Length(ct, result, 7) + verify.Match(ct, result, "^success:") + + verify.FailureCount(ct, 0) // Verify no failures occurred +} +``` + +### capture - Output Capture for Testing + +The `capture` package provides utilities for capturing stdout and stderr during test execution, enabling tests for functions that write to standard streams. + +**Key Features:** +- Capture stdout only +- Capture stderr only +- Capture both stdout and stderr simultaneously +- Panic-safe restoration of streams +- Simple API with byte and string access + +**Example:** + +```go +import "tideland.dev/go/asserts/capture" + +func TestPrintFunction(t *testing.T) { + captured := capture.Stdout(func() { + fmt.Println("Hello, World!") + }) + + verify.Equal(t, "Hello, World!\n", captured.String()) + verify.Equal(t, 14, captured.Len()) +} +``` + +### generators - Random Test Data Generation + +The `generators` package provides utilities for generating random test data in a controlled and reproducible manner. All generation is based on a `rand.Rand` instance, allowing deterministic test data when using a fixed seed. + +**Key Features:** +- Basic types: bytes, ints, durations, times, UUIDs +- Selection helpers: `OneOf`, `FlipCoin`, etc. +- Text generation: words, sentences, paragraphs +- Pattern-based generation with escape sequences +- Identity generation: names, emails, URLs, domains +- Concurrent-safe with internal mutex +- Fixed and simple random generators + +**Example:** + +```go +import "tideland.dev/go/asserts/generators" + +func TestWithRandomData(t *testing.T) { + gen := generators.New(generators.FixedRand()) + + email := gen.EMail() + verify.Match(t, email, `^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]*$`) + + // Pattern-based generation: (XXX) XXX-XXXX + phone := gen.Pattern("(^1^0^0) ^1^0^0-^0^0^0^0") + verify.Match(t, phone, `^\(\d{3}\) \d{3}-\d{4}$`) +} +``` + +## Installation + +```bash +go get tideland.dev/go/asserts +``` + +## Usage + +Import the packages you need: + +```go +import ( + "tideland.dev/go/asserts/verify" + "tideland.dev/go/asserts/capture" + "tideland.dev/go/asserts/generators" +) +``` + +The packages work well together: + +```go +func TestComplexScenario(t *testing.T) { + gen := generators.New(generators.FixedRand()) + ct := verify.ContinuedTesting(t) + + // Generate test data + testName := gen.Word() + testEmail := gen.EMail() + + // Capture output + output := capture.Stdout(func() { + processUser(testName, testEmail) + }) + + // Verify results + verify.Contains(ct, output.String(), testName) + verify.Contains(ct, output.String(), testEmail) + verify.FailureCount(ct, 0) +} +``` + +## Development + +Each package includes a Makefile for common development tasks: + +```bash +# Run all checks for a specific package +cd verify && make all + +# Run tests with race detection +cd capture && make test + +# Generate coverage report +cd generators && make coverage + +# Or use the root Makefile to target specific packages +make test --package=verify +make all --package=capture +make lint --package=generators + +# Run for all packages +make all +``` ## Contributors diff --git a/capture/Makefile b/capture/Makefile new file mode 100644 index 0000000..16de47b --- /dev/null +++ b/capture/Makefile @@ -0,0 +1,115 @@ +# Tideland Go Asserts / Capture - Makefile +# +# Copyright (C) 2024-2025 Frank Mueller / Tideland / Germany +# +# All rights reserved. Use of this source code is governed +# by the new BSD license. + +# Variables +GO := go +GOLANGCI_LINT := golangci-lint +GOLANGCI_LINT_VERSION := v2.7.2 +COVERAGE_FILE := coverage.out +COVERAGE_HTML := coverage.html + +# Colors for output +COLOR_RESET := \033[0m +COLOR_BOLD := \033[1m +COLOR_GREEN := \033[32m +COLOR_YELLOW := \033[33m +COLOR_BLUE := \033[34m + +# Default target +.DEFAULT_GOAL := all + +# Phony targets +.PHONY: all help fmt tidy lint build test bench coverage clean install-tools check-tools + +## all: Run complete build process (fmt, tidy, lint, build, test) +all: fmt tidy lint build test + @echo "$(COLOR_GREEN)$(COLOR_BOLD)✓ All tasks completed successfully$(COLOR_RESET)" + +## help: Display this help message +help: + @echo "$(COLOR_BOLD)Tideland Go Asserts / Capture - Available Targets:$(COLOR_RESET)" + @echo "" + @sed -n 's/^##//p' $(MAKEFILE_LIST) | column -t -s ':' | sed -e 's/^/ /' + @echo "" + @echo "$(COLOR_BLUE)Usage: make [target]$(COLOR_RESET)" + @echo "" + +## fmt: Format Go source files +fmt: + @echo "$(COLOR_YELLOW)→ Formatting Go source files...$(COLOR_RESET)" + @gofmt -s -w . + @echo "$(COLOR_GREEN)✓ Code formatting completed$(COLOR_RESET)" + +## tidy: Update go.mod and go.sum files +tidy: + @echo "$(COLOR_YELLOW)→ Tidying Go modules...$(COLOR_RESET)" + @$(GO) mod tidy + @$(GO) mod verify + @echo "$(COLOR_GREEN)✓ Module dependencies updated$(COLOR_RESET)" + +## lint: Run golangci-lint on source code +lint: + @echo "$(COLOR_YELLOW)→ Running golangci-lint...$(COLOR_RESET)" + @$(GOLANGCI_LINT) run --timeout=5m + @echo "$(COLOR_GREEN)✓ Linting completed$(COLOR_RESET)" + +## build: Build the package (verify compilation) +build: + @echo "$(COLOR_YELLOW)→ Building package...$(COLOR_RESET)" + @$(GO) build -v ./... + @echo "$(COLOR_GREEN)✓ Build successful$(COLOR_RESET)" + +## test: Run all tests +test: + @echo "$(COLOR_YELLOW)→ Running tests...$(COLOR_RESET)" + @$(GO) test -v -race ./... + @echo "$(COLOR_GREEN)✓ Tests passed$(COLOR_RESET)" + +## bench: Run benchmarks +bench: + @echo "$(COLOR_YELLOW)→ Running benchmarks...$(COLOR_RESET)" + @$(GO) test -bench=. -benchmem -run=^$$ ./... + @echo "$(COLOR_GREEN)✓ Benchmarks completed$(COLOR_RESET)" + +## coverage: Generate test coverage report +coverage: + @echo "$(COLOR_YELLOW)→ Generating coverage report...$(COLOR_RESET)" + @$(GO) test -coverprofile=$(COVERAGE_FILE) -covermode=atomic ./... + @$(GO) tool cover -html=$(COVERAGE_FILE) -o $(COVERAGE_HTML) + @$(GO) tool cover -func=$(COVERAGE_FILE) | grep total | awk '{print "Coverage: " $$3}' + @echo "$(COLOR_GREEN)✓ Coverage report generated: $(COVERAGE_HTML)$(COLOR_RESET)" + +## clean: Remove build artifacts and coverage files +clean: + @echo "$(COLOR_YELLOW)→ Cleaning build artifacts...$(COLOR_RESET)" + @rm -f $(COVERAGE_FILE) $(COVERAGE_HTML) + @$(GO) clean -cache -testcache -modcache + @echo "$(COLOR_GREEN)✓ Clean completed$(COLOR_RESET)" + +## install-tools: Install required development tools +install-tools: + @echo "$(COLOR_YELLOW)→ Installing development tools...$(COLOR_RESET)" + @which $(GOLANGCI_LINT) > /dev/null 2>&1 || \ + (echo "Installing golangci-lint $(GOLANGCI_LINT_VERSION)..." && \ + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $$(go env GOPATH)/bin $(GOLANGCI_LINT_VERSION)) + @$(GOLANGCI_LINT) version | grep -q "has version $(shell echo $(GOLANGCI_LINT_VERSION) | sed 's/v//')" || \ + (echo "Updating golangci-lint to $(GOLANGCI_LINT_VERSION)..." && \ + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $$(go env GOPATH)/bin $(GOLANGCI_LINT_VERSION)) + @echo "$(COLOR_GREEN)✓ Tools installed$(COLOR_RESET)" + +## check-tools: Check installed tool versions and compatibility +check-tools: + @echo "$(COLOR_YELLOW)→ Checking tool versions...$(COLOR_RESET)" + @echo "$(COLOR_BLUE)Go version:$(COLOR_RESET)" + @$(GO) version + @echo "$(COLOR_BLUE)golangci-lint version:$(COLOR_RESET)" + @$(GOLANGCI_LINT) version || echo "$(COLOR_YELLOW)golangci-lint not found - run 'make install-tools'$(COLOR_RESET)" + @echo "$(COLOR_GREEN)✓ Tool version check completed$(COLOR_RESET)" + +## ci: Run CI pipeline (used by GitHub Actions) +ci: fmt tidy lint build test + @echo "$(COLOR_GREEN)$(COLOR_BOLD)✓ CI pipeline completed$(COLOR_RESET)" diff --git a/capture/capture.go b/capture/capture.go index 5f716df..a59fcd9 100644 --- a/capture/capture.go +++ b/capture/capture.go @@ -10,7 +10,6 @@ package capture import ( "bytes" "io" - "log" "os" ) @@ -22,9 +21,7 @@ type Captured struct { // Bytes returns the captured content as bytes. func (c Captured) Bytes() []byte { - buf := make([]byte, c.Len()) - copy(buf, c.buffer) - return buf + return c.buffer } // String implements fmt.Stringer. @@ -39,54 +36,69 @@ func (c Captured) Len() int { // Stdout allows to capture Stdout by the given function. // The result is stored in Captured and can be retrieved as -// []byte or string for aseertions. +// []byte or string for assertions. func Stdout(f func()) Captured { old := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - f() + r, w, err := os.Pipe() + if err != nil { + panic("failed to create pipe: " + err.Error()) + } + os.Stdout = w outC := make(chan []byte) + // Start goroutine to read from pipe go func() { var buf bytes.Buffer - if _, err := io.Copy(&buf, r); err != nil { - log.Fatalf("error capturing stdout: %v", err) - } + _, _ = io.Copy(&buf, r) outC <- buf.Bytes() }() - w.Close() - os.Stdout = old + // Ensure restoration even if f() panics + defer func() { + os.Stdout = old + }() + + f() + if err := w.Close(); err != nil { + panic("failed to close pipe: " + err.Error()) + } + return Captured{ buffer: <-outC, } } -// CaptureStdout allows to capture Stderr by the given function. - +// Stderr allows to capture Stderr by the given function. // The result is stored in Captured and can be retrieved as -// []byte or string for aseertions. +// []byte or string for assertions. func Stderr(f func()) Captured { old := os.Stderr - r, w, _ := os.Pipe() - os.Stderr = w - - f() + r, w, err := os.Pipe() + if err != nil { + panic("failed to create pipe: " + err.Error()) + } + os.Stderr = w outC := make(chan []byte) + // Start goroutine to read from pipe go func() { var buf bytes.Buffer - if _, err := io.Copy(&buf, r); err != nil { - log.Fatalf("error capturing stderr: %v", err) - } + _, _ = io.Copy(&buf, r) outC <- buf.Bytes() }() - w.Close() - os.Stderr = old + // Ensure restoration even if f() panics + defer func() { + os.Stderr = old + }() + + f() + if err := w.Close(); err != nil { + panic("failed to close pipe: " + err.Error()) + } + return Captured{ buffer: <-outC, } @@ -94,7 +106,7 @@ func Stderr(f func()) Captured { // Both allows to capture Stdout and Stderr by the given // function. The result is stored in two Captureds for each and can -// be retrieved as []byte or string for aseertions. +// be retrieved as []byte or string for assertions. func Both(f func()) (Captured, Captured) { var cerr Captured ff := func() { @@ -103,4 +115,3 @@ func Both(f func()) (Captured, Captured) { cout := Stdout(ff) return cout, cerr } - diff --git a/capture/capture_test.go b/capture/capture_test.go index 8162542..27b3667 100644 --- a/capture/capture_test.go +++ b/capture/capture_test.go @@ -44,8 +44,10 @@ func TestBoth(t *testing.T) { hello := "Hello, World!" ouch := "ouch" cout, cerr := capture.Both(func() { - fmt.Fprint(os.Stdout, hello) - fmt.Fprint(os.Stderr, ouch) + _, err := fmt.Fprint(os.Stdout, hello) + verify.NoError(t, err) + _, err = fmt.Fprint(os.Stderr, ouch) + verify.NoError(t, err) }) verify.Equal(t, hello, cout.String()) verify.Equal(t, len(hello), cout.Len()) @@ -58,8 +60,10 @@ func TestBytes(t *testing.T) { foo := "foo" boo := []byte(foo) cout, cerr := capture.Both(func() { - fmt.Fprint(os.Stdout, foo) - fmt.Fprint(os.Stderr, foo) + _, err := fmt.Fprint(os.Stdout, foo) + verify.NoError(t, err) + _, err = fmt.Fprint(os.Stderr, foo) + verify.NoError(t, err) }) verify.True(t, bytes.Equal(cout.Bytes(), boo)) verify.True(t, bytes.Equal(cerr.Bytes(), boo)) @@ -72,8 +76,12 @@ func TestRestore(t *testing.T) { oldOut := os.Stdout oldErr := os.Stderr cout, cerr := capture.Both(func() { - fmt.Fprint(os.Stdout, foo) - fmt.Fprint(os.Stderr, foo) + if _, err := fmt.Fprint(os.Stdout, foo); err != nil { + panic("failed to write to stdout: " + err.Error()) + } + if _, err := fmt.Fprint(os.Stderr, foo); err != nil { + panic("failed to write to stderr: " + err.Error()) + } }) verify.Equal(t, foo, cout.String()) verify.Equal(t, len(foo), cout.Len()) @@ -82,4 +90,3 @@ func TestRestore(t *testing.T) { verify.Equal(t, oldOut, os.Stdout) verify.Equal(t, oldErr, os.Stderr) } - diff --git a/capture/doc.go b/capture/doc.go new file mode 100644 index 0000000..bbeabb2 --- /dev/null +++ b/capture/doc.go @@ -0,0 +1,54 @@ +// Tideland Go Asserts - Capture +// +// Copyright (C) 2024-2025 Frank Mueller / Tideland / Germany +// +// All rights reserved. Use of this source code is governed +// by the new BSD license. + +/* +Package capture provides utilities for capturing stdout and stderr output +during test execution. This is useful for testing functions that write to +standard output or error streams. + +The package provides three main capture functions: + + - Stdout: Captures only stdout + - Stderr: Captures only stderr + - Both: Captures both stdout and stderr simultaneously + +Each function accepts a function to execute and returns a Captured result +that can be inspected as bytes or string. + +Example for capturing stdout: + + func TestPrintFunction(t *testing.T) { + captured := capture.Stdout(func() { + fmt.Println("Hello, World!") + }) + + verify.Equal(t, "Hello, World!\n", captured.String()) + verify.Equal(t, 14, captured.Len()) + } + +Example for capturing both stdout and stderr: + + func TestOutputs(t *testing.T) { + cout, cerr := capture.Both(func() { + fmt.Fprintln(os.Stdout, "standard output") + fmt.Fprintln(os.Stderr, "standard error") + }) + + verify.Contains(t, cout.String(), "standard output") + verify.Contains(t, cerr.String(), "standard error") + } + +The captured output is stored in a Captured struct that provides: + + - Bytes(): Returns the captured content as a byte slice + - String(): Returns the captured content as a string + - Len(): Returns the number of bytes captured + +All capture functions ensure that stdout/stderr are properly restored even +if the captured function panics, preventing test pollution. +*/ +package capture diff --git a/doc.go b/doc.go index 3e05864..f8748d0 100644 --- a/doc.go +++ b/doc.go @@ -5,9 +5,127 @@ // All rights reserved. Use of this source code is governed // by the new BSD license. -// Package asserts provides a simple and lightweight support for unit testing. Here -// the package verify allows human readable verifications. An option allows to -// continue the verification even when an expected failure happens. In the end the -// number of expected fails may be checked. -package asserts // import "tideland.dev/go/asserts" +/* +Package asserts provides comprehensive testing utilities for Go, consisting of +three specialized sub-packages that work together to make testing easier, more +readable, and more maintainable. + +# Sub-packages + +# Verify - Human-Readable Assertions + +The verify package provides a rich set of assertion functions for Go's standard +testing package. It supports continued testing mode where assertions report +failures without halting execution, allowing multiple checks in a single test. + +Key features: + - Type assertions (Nil, NotNil, Equal, NotEqual, etc.) + - Comparison assertions (Less, Greater, InRange, About, etc.) + - String assertions (Contains, HasPrefix, Match, etc.) + - Slice and map assertions (Length, Empty, NotEmpty, etc.) + - Error assertions (NoError, ErrorContains, ErrorMatches, etc.) + - Channel assertions (Readable, NotReadable, Closed, etc.) + - Predicate assertions (True, False, Predicate, etc.) + - Continued testing mode with failure count tracking + +Example: + + func TestMyFunction(t *testing.T) { + ct := verify.ContinuedTesting(t) + + result, err := myFunction("input") + verify.NoError(ct, err) + verify.Length(ct, result, 7) + verify.Match(ct, result, "^success:") + + verify.FailureCount(ct, 0) // Verify no failures occurred + } + +See: tideland.dev/go/asserts/verify + +# Capture - Output Capture for Testing + +The capture package provides utilities for capturing stdout and stderr during +test execution, enabling tests for functions that write to standard streams. + +Key features: + - Capture stdout only + - Capture stderr only + - Capture both stdout and stderr simultaneously + - Panic-safe restoration of streams + - Simple API with byte and string access + +Example: + + func TestPrintFunction(t *testing.T) { + captured := capture.Stdout(func() { + fmt.Println("Hello, World!") + }) + + verify.Equal(t, "Hello, World!\n", captured.String()) + verify.Equal(t, 14, captured.Len()) + } + +See: tideland.dev/go/asserts/capture +# Generators - Random Test Data Generation + +The generators package provides utilities for generating random test data in a +controlled and reproducible manner. All generation is based on a rand.Rand +instance, allowing deterministic test data when using a fixed seed. + +Key features: + - Basic types (bytes, ints, durations, times, UUIDs) + - Selection helpers (OneOf, FlipCoin, etc.) + - Text generation (words, sentences, paragraphs) + - Pattern-based generation with escape sequences + - Identity generation (names, emails, URLs, domains) + - Concurrent-safe with internal mutex + - Fixed and simple random generators + +Example: + + func TestWithRandomData(t *testing.T) { + gen := generators.New(generators.FixedRand()) + + email := gen.EMail() + verify.Match(t, email, `^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]*$`) + + phone := gen.Pattern("(^1^0^0) ^1^0^0-^0^0^0^0") + verify.Match(t, phone, `^\(\d{3}\) \d{3}-\d{4}$`) + } + +See: tideland.dev/go/asserts/generators + +# Usage + +Import the packages you need: + + import ( + "tideland.dev/go/asserts/verify" + "tideland.dev/go/asserts/capture" + "tideland.dev/go/asserts/generators" + ) + +The packages work well together: + + func TestComplexScenario(t *testing.T) { + gen := generators.New(generators.FixedRand()) + ct := verify.ContinuedTesting(t) + + // Generate test data + testName := gen.Word() + testEmail := gen.EMail() + + // Capture output + output := capture.Stdout(func() { + processUser(testName, testEmail) + }) + + // Verify results + verify.Contains(ct, output.String(), testName) + verify.Contains(ct, output.String(), testEmail) + verify.FailureCount(ct, 0) + } +*/ +package asserts // import "tideland.dev/go/asserts" diff --git a/generators/Makefile b/generators/Makefile new file mode 100644 index 0000000..ef70c23 --- /dev/null +++ b/generators/Makefile @@ -0,0 +1,115 @@ +# Tideland Go Asserts / Generators - Makefile +# +# Copyright (C) 2024-2025 Frank Mueller / Tideland / Germany +# +# All rights reserved. Use of this source code is governed +# by the new BSD license. + +# Variables +GO := go +GOLANGCI_LINT := golangci-lint +GOLANGCI_LINT_VERSION := v2.7.2 +COVERAGE_FILE := coverage.out +COVERAGE_HTML := coverage.html + +# Colors for output +COLOR_RESET := \033[0m +COLOR_BOLD := \033[1m +COLOR_GREEN := \033[32m +COLOR_YELLOW := \033[33m +COLOR_BLUE := \033[34m + +# Default target +.DEFAULT_GOAL := all + +# Phony targets +.PHONY: all help fmt tidy lint build test bench coverage clean install-tools check-tools + +## all: Run complete build process (fmt, tidy, lint, build, test) +all: fmt tidy lint build test + @echo "$(COLOR_GREEN)$(COLOR_BOLD)✓ All tasks completed successfully$(COLOR_RESET)" + +## help: Display this help message +help: + @echo "$(COLOR_BOLD)Tideland Go Asserts / Generators - Available Targets:$(COLOR_RESET)" + @echo "" + @sed -n 's/^##//p' $(MAKEFILE_LIST) | column -t -s ':' | sed -e 's/^/ /' + @echo "" + @echo "$(COLOR_BLUE)Usage: make [target]$(COLOR_RESET)" + @echo "" + +## fmt: Format Go source files +fmt: + @echo "$(COLOR_YELLOW)→ Formatting Go source files...$(COLOR_RESET)" + @gofmt -s -w . + @echo "$(COLOR_GREEN)✓ Code formatting completed$(COLOR_RESET)" + +## tidy: Update go.mod and go.sum files +tidy: + @echo "$(COLOR_YELLOW)→ Tidying Go modules...$(COLOR_RESET)" + @$(GO) mod tidy + @$(GO) mod verify + @echo "$(COLOR_GREEN)✓ Module dependencies updated$(COLOR_RESET)" + +## lint: Run golangci-lint on source code +lint: + @echo "$(COLOR_YELLOW)→ Running golangci-lint...$(COLOR_RESET)" + @$(GOLANGCI_LINT) run --timeout=5m + @echo "$(COLOR_GREEN)✓ Linting completed$(COLOR_RESET)" + +## build: Build the package (verify compilation) +build: + @echo "$(COLOR_YELLOW)→ Building package...$(COLOR_RESET)" + @$(GO) build -v ./... + @echo "$(COLOR_GREEN)✓ Build successful$(COLOR_RESET)" + +## test: Run all tests +test: + @echo "$(COLOR_YELLOW)→ Running tests...$(COLOR_RESET)" + @$(GO) test -v -race ./... + @echo "$(COLOR_GREEN)✓ Tests passed$(COLOR_RESET)" + +## bench: Run benchmarks +bench: + @echo "$(COLOR_YELLOW)→ Running benchmarks...$(COLOR_RESET)" + @$(GO) test -bench=. -benchmem -run=^$$ ./... + @echo "$(COLOR_GREEN)✓ Benchmarks completed$(COLOR_RESET)" + +## coverage: Generate test coverage report +coverage: + @echo "$(COLOR_YELLOW)→ Generating coverage report...$(COLOR_RESET)" + @$(GO) test -coverprofile=$(COVERAGE_FILE) -covermode=atomic ./... + @$(GO) tool cover -html=$(COVERAGE_FILE) -o $(COVERAGE_HTML) + @$(GO) tool cover -func=$(COVERAGE_FILE) | grep total | awk '{print "Coverage: " $$3}' + @echo "$(COLOR_GREEN)✓ Coverage report generated: $(COVERAGE_HTML)$(COLOR_RESET)" + +## clean: Remove build artifacts and coverage files +clean: + @echo "$(COLOR_YELLOW)→ Cleaning build artifacts...$(COLOR_RESET)" + @rm -f $(COVERAGE_FILE) $(COVERAGE_HTML) + @$(GO) clean -cache -testcache -modcache + @echo "$(COLOR_GREEN)✓ Clean completed$(COLOR_RESET)" + +## install-tools: Install required development tools +install-tools: + @echo "$(COLOR_YELLOW)→ Installing development tools...$(COLOR_RESET)" + @which $(GOLANGCI_LINT) > /dev/null 2>&1 || \ + (echo "Installing golangci-lint $(GOLANGCI_LINT_VERSION)..." && \ + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $$(go env GOPATH)/bin $(GOLANGCI_LINT_VERSION)) + @$(GOLANGCI_LINT) version | grep -q "has version $(shell echo $(GOLANGCI_LINT_VERSION) | sed 's/v//')" || \ + (echo "Updating golangci-lint to $(GOLANGCI_LINT_VERSION)..." && \ + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $$(go env GOPATH)/bin $(GOLANGCI_LINT_VERSION)) + @echo "$(COLOR_GREEN)✓ Tools installed$(COLOR_RESET)" + +## check-tools: Check installed tool versions and compatibility +check-tools: + @echo "$(COLOR_YELLOW)→ Checking tool versions...$(COLOR_RESET)" + @echo "$(COLOR_BLUE)Go version:$(COLOR_RESET)" + @$(GO) version + @echo "$(COLOR_BLUE)golangci-lint version:$(COLOR_RESET)" + @$(GOLANGCI_LINT) version || echo "$(COLOR_YELLOW)golangci-lint not found - run 'make install-tools'$(COLOR_RESET)" + @echo "$(COLOR_GREEN)✓ Tool version check completed$(COLOR_RESET)" + +## ci: Run CI pipeline (used by GitHub Actions) +ci: fmt tidy lint build test + @echo "$(COLOR_GREEN)$(COLOR_BOLD)✓ CI pipeline completed$(COLOR_RESET)" diff --git a/generators/doc.go b/generators/doc.go index 8bdaddf..a7dc7db 100644 --- a/generators/doc.go +++ b/generators/doc.go @@ -1,13 +1,88 @@ -// Tideland Go Audit - Generators +// Tideland Go Asserts - Generators // // Copyright (C) 2024-2025 Frank Mueller / Tideland / Germany // // All rights reserved. Use of this source code is governed // by the new BSD license. -// Package generators helps to quickly generate data needed for unit tests. -// The generation of all supported different types is based on a passed rand.Rand. -// When using the same value here the generated data will be the same when repeating -// tests. So generators.FixedRand() delivers such a fixed value. -package generators +/* +Package generators provides utilities for generating random test data in a +controlled and reproducible manner. All data generation is based on a +rand.Rand instance, allowing for deterministic test data when using a fixed +seed. + +The package offers two convenience functions for creating random generators: + + - SimpleRand(): Creates a generator with a time-based seed (non-deterministic) + - FixedRand(): Creates a generator with a fixed seed of 42 (deterministic) + +The Generator type provides methods for generating various types of data: + +Basic Types: + + - Byte, Bytes: Generate random bytes within a range + - Int, Ints: Generate random integers within a range + - Percent: Generate a random percentage (0-100) + - Duration: Generate random time.Duration values + - Time: Generate random time.Time values + - UUID: Generate pseudo-UUIDs + +Selection: + + - FlipCoin: Random boolean based on percentage + - OneOf, OneByteOf, OneRuneOf, OneIntOf, OneStringOf, OneDurationOf: Select random elements + +Text Generation: + + - Word, Words, LimitedWord: Generate random words + - Pattern: Generate strings based on escape patterns + - Sentence, SentenceWithNames: Generate random sentences + - Paragraph, ParagraphWithNames: Generate random paragraphs + +Identity Generation: + + - Name, MaleName, FemaleName, Names: Generate random names + - Domain: Generate random domain names + - URL: Generate random URLs + - EMail: Generate random email addresses +Helper Functions: + + - ToUpperFirst: Capitalize the first rune of a string + - BuildEMail: Construct an email address from name parts and domain + - BuildTime: Generate formatted time strings with offsets + - UUIDString: Convert a UUID byte array to string format + +Example with fixed seed for reproducible tests: + + func TestWithFixedData(t *testing.T) { + gen := generators.New(generators.FixedRand()) + + // These will always generate the same values + name := gen.Word() + number := gen.Int(1, 100) + email := gen.EMail() + + verify.NotEmpty(t, name) + verify.InRange(t, number, 1, 100) + verify.Match(t, email, `^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]*$`) + } + +Example with pattern-based generation: + + func TestPatternGeneration(t *testing.T) { + gen := generators.New(generators.FixedRand()) + + // Generate a phone number: (XXX) XXX-XXXX + phone := gen.Pattern("(^1^0^0) ^1^0^0-^0^0^0^0") + verify.Match(t, phone, `^\(\d{3}\) \d{3}-\d{4}$`) + + // Generate a product code: ABC-12345 + code := gen.Pattern("^A^A^A-^1^0^0^0^0") + verify.Match(t, code, `^[A-Z]{3}-\d{5}$`) + } + +The Generator type is safe for concurrent use as it protects its internal +random number generator with a mutex. +*/ +package generators diff --git a/generators/generators.go b/generators/generators.go index 4e16932..769afcf 100644 --- a/generators/generators.go +++ b/generators/generators.go @@ -95,6 +95,8 @@ func BuildTime(layout string, offset time.Duration) (string, time.Time) { return ts, tp } +// UUIDString converts a 16-byte UUID to its string representation +// in the format "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx". func UUIDString(uuid [16]byte) string { return fmt.Sprintf("%x-%x-%x-%x-%x", uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:16]) } @@ -161,17 +163,18 @@ func (g *Generator) Int(lo, hi int) int { // Ints generates a slice of random ints. func (g *Generator) Ints(lo, hi, count int) []int { ints := make([]int, count) - for i := range count { + for i := 0; i < count; i++ { ints[i] = g.Int(lo, hi) } return ints } -// no real UUID, even no v4, but it looks like. +// UUID generates a pseudo-UUID (not a real UUID, not even v4, but looks like one). func (g *Generator) UUID() [16]byte { var uuid [16]byte - bytes := g.Bytes(0, 255, 16) - copy(uuid[:], bytes) + for i := 0; i < 16; i++ { + uuid[i] = g.Byte(0, 255) + } return uuid } @@ -249,10 +252,10 @@ func (g *Generator) Word() string { return g.OneStringOf(words...) } -// Words generates a slice of random words +// Words generates a slice of random words. func (g *Generator) Words(count int) []string { words := make([]string, count) - for i := range count { + for i := 0; i < count; i++ { words[i] = g.Word() } return words @@ -360,7 +363,7 @@ func (g *Generator) Paragraph() string { return strings.Join(sentences, " ") } -// ParagraphWithNames workes like Paragraph but inserts randomly +// ParagraphWithNames works like Paragraph but inserts randomly // names of the passed set. They can be generated by NameSet(). func (g *Generator) ParagraphWithNames(names []string) string { count := g.Int(2, 10) @@ -382,7 +385,7 @@ func (g *Generator) Name() (first, middle, last string) { // Names generates a set of names to be used in other generators. func (g *Generator) Names(count int) []string { - var names []string + names := make([]string, 0, count) for range count { first, middle, last := g.Name() if g.FlipCoin(50) { @@ -431,7 +434,7 @@ func (g *Generator) Domain() string { return g.LimitedWord(3, 10) + "." + tld } -// to a file. +// URL generates a random URL. func (g *Generator) URL() string { part := func() string { return g.LimitedWord(2, 8) @@ -449,6 +452,7 @@ func (g *Generator) URL() string { } } +// EMail generates a random email address. func (g *Generator) EMail() string { if g.FlipCoin(50) { first, _, last := g.MaleName() diff --git a/verify/Makefile b/verify/Makefile new file mode 100644 index 0000000..901356d --- /dev/null +++ b/verify/Makefile @@ -0,0 +1,122 @@ +# Tideland Go Asserts / Verify - Makefile +# +# Copyright (C) 2024-2025 Frank Mueller / Tideland / Germany +# +# All rights reserved. Use of this source code is governed +# by the new BSD license. + +# Variables +GO := go +GOLANGCI_LINT := golangci-lint +GOLANGCI_LINT_VERSION := v2.7.2 +COVERAGE_FILE := coverage.out +COVERAGE_HTML := coverage.html + +# Colors for output +COLOR_RESET := \033[0m +COLOR_BOLD := \033[1m +COLOR_GREEN := \033[32m +COLOR_YELLOW := \033[33m +COLOR_BLUE := \033[34m + +# Default target +.DEFAULT_GOAL := all + +# Phony targets +.PHONY: all help fmt tidy lint build test bench fuzz coverage clean install-tools check-tools + +## all: Run complete build process (fmt, tidy, lint, build, test) +all: fmt tidy lint build test + @echo "$(COLOR_GREEN)$(COLOR_BOLD)✓ All tasks completed successfully$(COLOR_RESET)" + +## help: Display this help message +help: + @echo "$(COLOR_BOLD)Tideland Go Asserts / Verify - Available Targets:$(COLOR_RESET)" + @echo "" + @sed -n 's/^##//p' $(MAKEFILE_LIST) | column -t -s ':' | sed -e 's/^/ /' + @echo "" + @echo "$(COLOR_BLUE)Usage: make [target]$(COLOR_RESET)" + @echo "" + +## fmt: Format Go source files +fmt: + @echo "$(COLOR_YELLOW)→ Formatting Go source files...$(COLOR_RESET)" + @gofmt -s -w . + @echo "$(COLOR_GREEN)✓ Code formatting completed$(COLOR_RESET)" + +## tidy: Update go.mod and go.sum files +tidy: + @echo "$(COLOR_YELLOW)→ Tidying Go modules...$(COLOR_RESET)" + @$(GO) mod tidy + @$(GO) mod verify + @echo "$(COLOR_GREEN)✓ Module dependencies updated$(COLOR_RESET)" + +## lint: Run golangci-lint on source code +lint: + @echo "$(COLOR_YELLOW)→ Running golangci-lint...$(COLOR_RESET)" + @$(GOLANGCI_LINT) run --timeout=5m + @echo "$(COLOR_GREEN)✓ Linting completed$(COLOR_RESET)" + +## build: Build the package (verify compilation) +build: + @echo "$(COLOR_YELLOW)→ Building package...$(COLOR_RESET)" + @$(GO) build -v ./... + @echo "$(COLOR_GREEN)✓ Build successful$(COLOR_RESET)" + +## test: Run all tests +test: + @echo "$(COLOR_YELLOW)→ Running tests...$(COLOR_RESET)" + @$(GO) test -v -race ./... + @echo "$(COLOR_GREEN)✓ Tests passed$(COLOR_RESET)" + +## bench: Run benchmarks +bench: + @echo "$(COLOR_YELLOW)→ Running benchmarks...$(COLOR_RESET)" + @$(GO) test -bench=. -benchmem -run=^$$ ./... + @echo "$(COLOR_GREEN)✓ Benchmarks completed$(COLOR_RESET)" + +## fuzz: Run fuzz tests (requires Go 1.18+) +fuzz: + @echo "$(COLOR_YELLOW)→ Running fuzz tests...$(COLOR_RESET)" + @echo "$(COLOR_BLUE)Note: Fuzz tests run for 30 seconds each$(COLOR_RESET)" + @$(GO) test -fuzz=FuzzAction -fuzztime=30s -run=^$$ ./... + @echo "$(COLOR_GREEN)✓ Fuzz tests completed$(COLOR_RESET)" + +## coverage: Generate test coverage report +coverage: + @echo "$(COLOR_YELLOW)→ Generating coverage report...$(COLOR_RESET)" + @$(GO) test -coverprofile=$(COVERAGE_FILE) -covermode=atomic ./... + @$(GO) tool cover -html=$(COVERAGE_FILE) -o $(COVERAGE_HTML) + @$(GO) tool cover -func=$(COVERAGE_FILE) | grep total | awk '{print "Coverage: " $$3}' + @echo "$(COLOR_GREEN)✓ Coverage report generated: $(COVERAGE_HTML)$(COLOR_RESET)" + +## clean: Remove build artifacts and coverage files +clean: + @echo "$(COLOR_YELLOW)→ Cleaning build artifacts...$(COLOR_RESET)" + @rm -f $(COVERAGE_FILE) $(COVERAGE_HTML) + @$(GO) clean -cache -testcache -modcache + @echo "$(COLOR_GREEN)✓ Clean completed$(COLOR_RESET)" + +## install-tools: Install required development tools +install-tools: + @echo "$(COLOR_YELLOW)→ Installing development tools...$(COLOR_RESET)" + @which $(GOLANGCI_LINT) > /dev/null 2>&1 || \ + (echo "Installing golangci-lint $(GOLANGCI_LINT_VERSION)..." && \ + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $$(go env GOPATH)/bin $(GOLANGCI_LINT_VERSION)) + @$(GOLANGCI_LINT) version | grep -q "has version $(shell echo $(GOLANGCI_LINT_VERSION) | sed 's/v//')" || \ + (echo "Updating golangci-lint to $(GOLANGCI_LINT_VERSION)..." && \ + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $$(go env GOPATH)/bin $(GOLANGCI_LINT_VERSION)) + @echo "$(COLOR_GREEN)✓ Tools installed$(COLOR_RESET)" + +## check-tools: Check installed tool versions and compatibility +check-tools: + @echo "$(COLOR_YELLOW)→ Checking tool versions...$(COLOR_RESET)" + @echo "$(COLOR_BLUE)Go version:$(COLOR_RESET)" + @$(GO) version + @echo "$(COLOR_BLUE)golangci-lint version:$(COLOR_RESET)" + @$(GOLANGCI_LINT) version || echo "$(COLOR_YELLOW)golangci-lint not found - run 'make install-tools'$(COLOR_RESET)" + @echo "$(COLOR_GREEN)✓ Tool version check completed$(COLOR_RESET)" + +## ci: Run CI pipeline (used by GitHub Actions) +ci: fmt tidy lint build test + @echo "$(COLOR_GREEN)$(COLOR_BOLD)✓ CI pipeline completed$(COLOR_RESET)" diff --git a/verify/doc.go b/verify/doc.go new file mode 100644 index 0000000..97c3a03 --- /dev/null +++ b/verify/doc.go @@ -0,0 +1,41 @@ +// Tideland Go Asserts - Verify +// +// Copyright (C) 2024-2025 Frank Mueller / Tideland / Germany +// +// All rights reserved. Use of this source code is governed +// by the new BSD license. + +/* +Package verify provides a comprehensive and flexible set of assertion functions +for Go's standard testing package. It is designed to write clear, readable, +and expressive tests. + +A key feature is the ability to perform continued testing. By wrapping the +standard *testing.T with verify.ContinuedTesting(), assertions will report +failures without halting the test execution. This allows for multiple +independent checks within a single test function, reporting all failures +at the end. The number of expected failures can be asserted using +verify.FailureCount(). + +Example for a test using continued testing: + + func TestMyFunction(t *testing.T) { + // Wrap testing.T for continued testing. + ct := verify.ContinuedTesting(t) + + result, err := myFunction("input") + verify.NoError(ct, err, "myFunction should not produce an error") + verify.Length(ct, result, 7, "result should have a length of 7") + verify.Match(ct, result, "^success:") + + value := 42 + verify.Positive(ct, value) + verify.Even(ct, value) + + // At the end check the number of failures. Here we expect zero, + // but if any of the above checks failed, this will fail and + // report the number of found failures. + verify.FailureCount(ct, 0) + } +*/ +package verify diff --git a/verify/t.go b/verify/t.go index 4f3bff8..9e1c485 100644 --- a/verify/t.go +++ b/verify/t.go @@ -10,21 +10,27 @@ package verify import ( "fmt" "strings" + "sync" "testing" ) // testing.T Replacement -// T replaces testing.T for tests. Missing methods are handled internally. +// T is an interface that abstracts the standard *testing.T. It allows +// verify functions to work with both standard tests and the continued +// testing wrapper. type T interface { Errorf(format string, args ...any) } -// continuedTesting is a wrapper around *testing.T that -// indicates the test should continue running even after -// a verification failure +// continuedTesting is a wrapper around *testing.T that allows test +// verifications to continue even after a failure. It collects all +// failure messages and logs them without immediately calling t.FailNow(). +// The total number of failures can be asserted at the end of the test +// using FailureCount. type continuedTesting struct { *testing.T + mu sync.Mutex failed int msgs []string } @@ -35,33 +41,45 @@ var _ T = (*continuedTesting)(nil) func (ct *continuedTesting) Errorf(format string, args ...any) { ct.Helper() + ct.mu.Lock() ct.failed++ - ct.msgs = append(ct.msgs, fmt.Sprintf(format, args...)) + msgs := make([]string, len(ct.msgs)) + copy(msgs, ct.msgs) + ct.msgs = nil + ct.mu.Unlock() - for _, msg := range ct.msgs { - ct.T.Log(msg) + for _, msg := range msgs { + ct.Log(msg) } - ct.msgs = nil } // Library API -// ContinuedTesting creates a new T instance that continues after -// testing failures. +// ContinuedTesting wraps a *testing.T to create a T instance that allows +// verifications to continue after failures. This is useful for checking +// multiple independent conditions and reporting all failures at once. func ContinuedTesting(t *testing.T) T { - ct := &continuedTesting{t, 0, nil} + ct := &continuedTesting{ + T: t, + mu: sync.Mutex{}, + failed: 0, + msgs: nil, + } return ct } -// IsContinued checks if a testing.T is a continueTesting type. +// IsContinued checks if a T is a *continuedTesting instance. This can be +// useful for conditional logic in tests. func IsContinued(t T) bool { _, ok := t.(*continuedTesting) return ok } -// FailureCount validates how many tests failed during continued -// test to verify the expected number. +// FailureCount asserts that the number of failures recorded by a +// *continuedTesting instance matches the expected count. It fails +// the test if the counts do not match. This must be called at the +// end of a test using ContinuedTesting. func FailureCount(t T, expected int) bool { var ct *continuedTesting var ok bool @@ -71,10 +89,14 @@ func FailureCount(t T, expected int) bool { return false } - if ct.failed != expected { - failed := ct.failed + ct.mu.Lock() + failed := ct.failed + ct.mu.Unlock() + + if failed != expected { verificationFailure(t, "failure count", expected, failed) - ct.T.Fail() + ct.Fail() + return false } return true } @@ -101,4 +123,3 @@ func verificationFailure(t T, verification string, expected, got any, infos ...s } t.Errorf("%s", msg) } - diff --git a/verify/verify.go b/verify/verify.go index aec7ebc..d812f82 100644 --- a/verify/verify.go +++ b/verify/verify.go @@ -8,16 +8,7 @@ package verify import ( - "errors" - "fmt" - "reflect" - "regexp" - "slices" - "strings" "testing" - "time" - - "golang.org/x/exp/constraints" ) // Verifications @@ -40,7 +31,7 @@ func False(t T, gotten bool, infos ...string) bool { if ht, ok := t.(testing.TB); ok { ht.Helper() } - verificationFailure(t, "is false", false, gotten) + verificationFailure(t, "is false", false, gotten, infos...) return false } return true @@ -95,546 +86,3 @@ func Different[C comparable](t T, gotten, expected C, infos ...string) bool { } return true } - -// Length checks if the given value has the expected length. This only -// works for the according types for len(). All others fail. -func Length(t T, gotten any, expected int, infos ...string) bool { - if expected < 0 { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "has length", expected, "not quantifiable", infos...) - return false - } - gottenLen := flexlen(gotten) - if gottenLen < 0 { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "has length", expected, "gotten not quantifiable", infos...) - return false - } - if gottenLen != expected { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "has length", expected, gottenLen, infos...) - return false - } - return true -} - -// Empty checks if the given value is empty. This only works for the according types for len(). -// All others fail. -func Empty(t T, gotten any, infos ...string) bool { - gottenLen := flexlen(gotten) - if gottenLen < 0 { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "empty", 0, "gotten not quantifiable", infos...) - return false - } - if gottenLen != 0 { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "empty", 0, gottenLen, infos...) - return false - } - return true -} - -// NotEmpty checks if the given value is not empty. This only works for the according types for len(). -// All others fail. -func NotEmpty(t T, gotten any, infos ...string) bool { - gottenLen := flexlen(gotten) - if gottenLen < 0 { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "not empty", 0, "gotten not quantifiable", infos...) - return false - } - if gottenLen == 0 { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "not empty", "> 0", gottenLen, infos...) - return false - } - return true -} - -// Less checks if the gotten value is less than the expected one. -// Supports integers, floats, and time.Duration. -func Less[C constraints.Integer | constraints.Float](t T, gotten, expected C, infos ...string) bool { - if gotten >= expected { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "is less", expected, gotten, infos...) - return false - } - return true -} - -// More checks if the gotten value is more than the expected one. -// Supports integers, floats, and time.Duration. -func More[C constraints.Integer | constraints.Float](t T, gotten, expected C, infos ...string) bool { - if gotten <= expected { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "is more", expected, gotten, infos...) - return false - } - return true -} - -// About checks if the gotten values equal within a expected delta. Possible -// values are integers, floats, and time.Duration. -func About[C constraints.Integer | constraints.Float](t T, gotten, expected, tolerance C, infos ...string) bool { - if gotten < expected-tolerance || gotten > expected+tolerance { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - expectedDescr := fmt.Sprintf("%v' +/- '%v'", expected, tolerance) - verificationFailure(t, "is about equal", expectedDescr, gotten, infos...) - return false - } - return true -} - -// Substring checks if the gotten string is a substring of the expected string. -func Substring(t T, gotten, expected string, infos ...string) bool { - if !strings.Contains(expected, gotten) { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "substring", expected, gotten, infos...) - return false - } - return true -} - -// Contains checks if the slice contains the expected element. -func Contains[S ~[]E, E comparable](t T, gotten E, expected S, infos ...string) bool { - if !slices.Contains(expected, gotten) { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "contains", expected, gotten, infos...) - return false - } - return true -} - -// Match checks if the gotten string matches the expected regular expression. -func Match(t T, gotten, expected string, infos ...string) bool { - re, err := regexp.Compile(expected) - if err != nil { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "matches", expected, err.Error(), infos...) - return false - } - if !re.MatchString(gotten) { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "matches", expected, gotten, infos...) - return false - } - return true -} - -// Simultaneous checks if the gotten time is simultaneous with the expected time. -func Simultaneous(t T, gotten, expected time.Time, infos ...string) bool { - if !gotten.Equal(expected) { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "is time simultaneous", ftim(expected), ftim(gotten), infos...) - return false - } - return true -} - -// Before checks if the gotten time is before the expected time. -func Before(t T, gotten, expected time.Time, infos ...string) bool { - if !gotten.Before(expected) { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "is time before", ftim(expected), ftim(gotten), infos...) - return false - } - return true -} - -// After checks if the gotten time is after the expected time. -func After(t T, gotten, expected time.Time, infos ...string) bool { - if !gotten.After(expected) { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "is time after", ftim(expected), ftim(gotten), infos...) - return false - } - return true -} - -// Between checks if the gotten time is between the expected start and end times. -func Between(t T, gotten, expectedBegin, expectedEnd time.Time, infos ...string) bool { - expstr := "" - if expectedBegin.After(expectedEnd) { - expectedBegin, expectedEnd = expectedEnd, expectedBegin - } - if gotten.Before(expectedBegin) || gotten.After(expectedEnd) { - expstr = fmt.Sprintf("'%s' and '%s'", ftim(expectedBegin), ftim(expectedEnd)) - } - if expstr != "" { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "is between", expstr, ftim(gotten), infos...) - return false - } - return true -} - -// Shorter checks if the gotten duration is shorter than the expected duration. -func Shorter(t T, gotten, expected time.Duration, infos ...string) bool { - if gotten > expected { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "duration is shorter", expected, gotten, infos...) - return false - } - return true -} - -// Longer checks if the gotten duration is longer than the expected duration. -func Longer(t T, gotten, expected time.Duration, infos ...string) bool { - if gotten < expected { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "duration is longer", expected, gotten, infos...) - return false - } - return true -} - -// InRange checks if the given value is within lower and upper bounds. Possible -// values are integers, floats, and time.Duration. -func InRange[C constraints.Integer | constraints.Float](t T, gotten, expectedLower, expectedUpper C, infos ...string) bool { - if expectedLower > expectedUpper { - expectedLower, expectedUpper = expectedUpper, expectedLower - } - if gotten < expectedLower || gotten > expectedUpper { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - expectedDescr := fmt.Sprintf("'%v' to '%v'", expectedLower, expectedUpper) - verificationFailure(t, "is in range", expectedDescr, gotten, infos...) - return false - } - return true -} - -// OutOfRange checks if the given value is outside lower and upper bounds. It's the -// opposite of InRange. -func OutOfRange[C constraints.Integer | constraints.Float](t T, gotten, expectedLower, expectedUpper C, infos ...string) bool { - if expectedLower > expectedUpper { - expectedLower, expectedUpper = expectedUpper, expectedLower - } - if gotten >= expectedLower && gotten <= expectedUpper { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - expectedDescr := fmt.Sprintf("'%v' to '%v'", expectedLower, expectedUpper) - verificationFailure(t, "is out of range", expectedDescr, gotten, infos...) - return false - } - return true -} - -// Error checks if the given error is not nil. -func Error(t T, err error) bool { - if err == nil { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "is error", "error", nil) - return false - } - return true -} - -// NoError checks if the given error is nil. -// It's the opposite of Error. -func NoError(t T, gotten error) bool { - if gotten != nil { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "is no error", nil, gotten) - return false - } - return true -} - -// IsError checks if the given error is not nil and of the expected type. -// It uses the errors.Is() function. -func IsError(t T, gotten, expected error) bool { - if !errors.Is(gotten, expected) { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "is expected error", expected, gotten) - return false - } - return true -} - -// AsError checks if the given error can be unwrapped to the expected error type. -// It uses the errors.As() function. The expected parameter should be a pointer -// to the error type you want to check for. -func AsError(t T, gotten error, expected any) bool { - if gotten == nil { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "error as type", expected, gotten) - return false - } - if !errors.As(gotten, expected) { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "error as type", expected, gotten) - return false - } - return true -} - -// UnwrapError checks if the given error unwraps to the expected error. -// It uses the errors.Unwrap() function. -func UnwrapError(t T, gotten, expected error) bool { - if gotten == nil { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "error unwraps to", expected, gotten) - return false - } - unwrapped := errors.Unwrap(gotten) - if !errors.Is(unwrapped, expected) { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "error unwraps to", expected, unwrapped) - return false - } - return true -} - -// ErrorContains check if the given error is not nil and its message -// contains an expected string. -func ErrorContains(t T, gotten error, expected string) bool { - if gotten == nil { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "error contains", expected, gotten) - return false - } - if !strings.Contains(gotten.Error(), expected) { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "error contains", expected, gotten.Error()) - return false - } - return true -} - -// ErrorMatch checks if the gotten error is not nil and its message -// matches the expected regular expression. -func ErrorMatch(t T, gotten error, expected string) bool { - if gotten == nil { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "error does match", expected, gotten) - return false - } - re := regexp.MustCompile(expected) - if !re.MatchString(gotten.Error()) { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "error does match", expected, gotten.Error()) - return false - } - return true -} - -// Implements checks if the gotten instance implements the expected interface. -// The expected parameter has to be an interface type as nil pointer. Hier e.g. -// var stringer fmt.Stinger and then verify.Implements(t, myVar, &fmtStringer). -func Implements(t T, gotten, expected any) bool { - if expected == nil { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "does implement", "expected instance", nil) - return false - } - - if gotten == nil { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "does implement", "actual instance", nil) - return false - } - - expectedType := reflect.TypeOf(expected).Elem() - if expectedType.Kind() != reflect.Interface { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "does implement", "expected interface", nil) - return false - } - - actualType := reflect.TypeOf(gotten) - if !actualType.Implements(expectedType) { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "does implement", expectedType, actualType) - return false - } - return true -} - -// Assignability checks if the actual value can be assigned to the type of the -// expected type. -func Assignability(t T, gotten, expected any) bool { - if expected == nil { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "is assignable to", "expected type", nil) - return false - } - - if gotten == nil { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "is assignable to", "actual type", nil) - return false - } - - expectedType := reflect.TypeOf(expected) - actualType := reflect.TypeOf(gotten) - - if !actualType.AssignableTo(expectedType) { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "is assignable to", expectedType, actualType) - return false - } - return true -} - -// Panics checks if the given functions panics. -func Panics(t T, gotten func()) bool { - if gotten == nil { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "panics", "expected function", nil) - return false - } - - defer func() { - if r := recover(); r == nil { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "panics", "expected function", "actual function") - } - }() - - gotten() - return true -} - -// NotPanics checks if the given functions does not panic. -func NotPanics(t T, gotten func()) bool { - if gotten == nil { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "not panics", "expected function", nil) - return false - } - - defer func() { - if r := recover(); r != nil { - if ht, ok := t.(testing.TB); ok { - ht.Helper() - } - verificationFailure(t, "not panics", "expected function", "actual function") - } - }() - - gotten() - return true -} - -// Helper - -// ftim is a short to format times in test output. -func ftim(t time.Time) string { - return t.Format(time.RFC3339) -} - -type lenner interface { - Len() int -} - -type lengthier interface { - Length() int -} - -// flexlen retruns the length of types avaialbe to return their length. -func flexlen(in any) int { - // Check for possible existing methods - switch in := in.(type) { - case lenner: - return in.Len() - case lengthier: - return in.Length() - default: - // Use reflection - rv := reflect.ValueOf(in) - switch rv.Kind() { - case reflect.Array, reflect.Chan, reflect.Map, reflect.Slice, reflect.String: - return rv.Len() - default: - // Good old -1 is enough here, verification is above - return -1 - } - } -} - diff --git a/verify/verify_channels.go b/verify/verify_channels.go new file mode 100644 index 0000000..fd45583 --- /dev/null +++ b/verify/verify_channels.go @@ -0,0 +1,98 @@ +// Tideland Go Asserts - Verify - Channels +// +// Copyright (C) 2024-2025 Frank Mueller / Tideland / Germany +// +// All rights reserved. Use of this source code is governed +// by the new BSD license. + +package verify + +import ( + "testing" + "time" +) + +// ChannelClosed checks if a channel is closed. It performs the check +// without blocking. +func ChannelClosed(t T, gotten <-chan any, infos ...string) bool { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + + if gotten == nil { + verificationFailure(t, "channel is closed", "valid channel", "nil channel", infos...) + return false + } + + // Try to receive from the channel with a select and default + select { + case _, ok := <-gotten: + if ok { + // Channel is open and received a value + verificationFailure(t, "channel is closed", "closed channel", "open channel with value", infos...) + return false + } + // Channel is closed (ok == false) + return true + default: + // Channel is open but has no value ready + verificationFailure(t, "channel is closed", "closed channel", "open channel", infos...) + return false + } +} + +// ChannelReceives checks if a value is received on a channel within a +// given timeout. It fails if the timeout is exceeded or the channel is +// closed before a value is received. +func ChannelReceives[E any](t T, gotten <-chan E, timeout time.Duration, infos ...string) bool { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + + if gotten == nil { + verificationFailure(t, "channel receives", "valid channel", "nil channel", infos...) + return false + } + + select { + case _, ok := <-gotten: + if !ok { + verificationFailure(t, "channel receives", "open channel", "closed channel", infos...) + return false + } + return true + case <-time.After(timeout): + verificationFailure(t, "channel receives", "value within timeout", "timeout exceeded", infos...) + return false + } +} + +// ChannelReceivesValue checks if a specific expected value is received on a +// channel within a given timeout. It fails if the timeout is exceeded, the +// channel is closed, or a different value is received. +func ChannelReceivesValue[E comparable](t T, gotten <-chan E, expected E, timeout time.Duration, infos ...string) bool { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + + if gotten == nil { + verificationFailure(t, "channel receives value", "valid channel", "nil channel", infos...) + return false + } + + select { + case val, ok := <-gotten: + if !ok { + verificationFailure(t, "channel receives value", expected, "closed channel", infos...) + return false + } + if val != expected { + verificationFailure(t, "channel receives value", expected, val, infos...) + return false + } + return true + case <-time.After(timeout): + verificationFailure(t, "channel receives value", expected, "timeout exceeded", infos...) + return false + } +} diff --git a/verify/verify_channels_test.go b/verify/verify_channels_test.go new file mode 100644 index 0000000..26f57ce --- /dev/null +++ b/verify/verify_channels_test.go @@ -0,0 +1,116 @@ +// Tideland Go Asserts - Verify - Channel Tests +// +// Copyright (C) 2024-2025 Frank Mueller / Tideland / Germany +// +// All rights reserved. Use of this source code is governed +// by the new BSD license. + +package verify_test + +import ( + "testing" + "time" + + "tideland.dev/go/asserts/verify" +) + +// TestChannelClosed tests the ChannelClosed verification function. +func TestChannelClosed(t *testing.T) { + // Create a closed channel + closedCh := make(chan any) + close(closedCh) + + // Positive: channel is closed + verify.ChannelClosed(t, closedCh) + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: channel is open with no value + openCh := make(chan any) + verify.ChannelClosed(ct, openCh) + + // Negative: channel is open with a value + bufferedCh := make(chan any, 1) + bufferedCh <- "value" + verify.ChannelClosed(ct, bufferedCh) + + // Negative: nil channel + var nilCh chan any + verify.ChannelClosed(ct, nilCh) + + verify.FailureCount(ct, 3) +} + +// TestChannelReceives tests the ChannelReceives verification function. +func TestChannelReceives(t *testing.T) { + // Positive: channel receives value + ch := make(chan int, 1) + ch <- 42 + verify.ChannelReceives(t, ch, 100*time.Millisecond) + + // Positive: channel receives value from goroutine + ch2 := make(chan string) + go func() { + time.Sleep(10 * time.Millisecond) + ch2 <- "hello" + }() + verify.ChannelReceives(t, ch2, 100*time.Millisecond) + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: timeout (no value sent) + ch3 := make(chan int) + verify.ChannelReceives(ct, ch3, 10*time.Millisecond) + + // Negative: closed channel + closedCh := make(chan int) + close(closedCh) + verify.ChannelReceives(ct, closedCh, 100*time.Millisecond) + + // Negative: nil channel + var nilCh chan int + verify.ChannelReceives(ct, nilCh, 100*time.Millisecond) + + verify.FailureCount(ct, 3) +} + +// TestChannelReceivesValue tests the ChannelReceivesValue verification function. +func TestChannelReceivesValue(t *testing.T) { + // Positive: channel receives expected value + ch := make(chan int, 1) + ch <- 42 + verify.ChannelReceivesValue(t, ch, 42, 100*time.Millisecond) + + // Positive: channel receives value from goroutine + ch2 := make(chan string) + go func() { + time.Sleep(10 * time.Millisecond) + ch2 <- "expected" + }() + verify.ChannelReceivesValue(t, ch2, "expected", 100*time.Millisecond) + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: wrong value received + ch3 := make(chan int, 1) + ch3 <- 99 + verify.ChannelReceivesValue(ct, ch3, 42, 100*time.Millisecond) + + // Negative: timeout + ch4 := make(chan int) + verify.ChannelReceivesValue(ct, ch4, 42, 10*time.Millisecond) + + // Negative: closed channel + closedCh := make(chan int) + close(closedCh) + verify.ChannelReceivesValue(ct, closedCh, 42, 100*time.Millisecond) + + // Negative: nil channel + var nilCh chan int + verify.ChannelReceivesValue(ct, nilCh, 42, 100*time.Millisecond) + + verify.FailureCount(ct, 4) +} diff --git a/verify/verify_comparisons.go b/verify/verify_comparisons.go new file mode 100644 index 0000000..f1d4b75 --- /dev/null +++ b/verify/verify_comparisons.go @@ -0,0 +1,135 @@ +// Tideland Go Asserts - Verify - Comparisons +// +// Copyright (C) 2024-2025 Frank Mueller / Tideland / Germany +// +// All rights reserved. Use of this source code is governed +// by the new BSD license. + +package verify + +import ( + "fmt" + "testing" + + "golang.org/x/exp/constraints" +) + +// Less checks if the gotten value is less than the expected one. +// Supports integers, floats, and time.Duration. +func Less[C constraints.Integer | constraints.Float](t T, gotten, expected C, infos ...string) bool { + if gotten >= expected { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "is less", expected, gotten, infos...) + return false + } + return true +} + +// More checks if the gotten value is more than the expected one. +// Supports integers, floats, and time.Duration. +func More[C constraints.Integer | constraints.Float](t T, gotten, expected C, infos ...string) bool { + if gotten <= expected { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "is more", expected, gotten, infos...) + return false + } + return true +} + +// Zero checks if the gotten value equals zero. +func Zero[N constraints.Integer | constraints.Float](t T, gotten N, infos ...string) bool { + if gotten != 0 { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "is zero", 0, gotten, infos...) + return false + } + return true +} + +// NotZero checks if the gotten value is not zero. +func NotZero[N constraints.Integer | constraints.Float](t T, gotten N, infos ...string) bool { + if gotten == 0 { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "is not zero", "non-zero", gotten, infos...) + return false + } + return true +} + +// Positive checks if the gotten value is positive (greater than zero). +func Positive[N constraints.Integer | constraints.Float](t T, gotten N, infos ...string) bool { + if gotten <= 0 { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "is positive", "> 0", gotten, infos...) + return false + } + return true +} + +// Negative checks if the gotten value is negative (less than zero). +func Negative[N constraints.Integer | constraints.Float](t T, gotten N, infos ...string) bool { + if gotten >= 0 { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "is negative", "< 0", gotten, infos...) + return false + } + return true +} + +// Even checks if the gotten integer value is even. +func Even[I constraints.Integer](t T, gotten I, infos ...string) bool { + if gotten%2 != 0 { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "is even", "even number", gotten, infos...) + return false + } + return true +} + +// Odd checks if the gotten integer value is odd. +func Odd[I constraints.Integer](t T, gotten I, infos ...string) bool { + if gotten%2 == 0 { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "is odd", "odd number", gotten, infos...) + return false + } + return true +} + +// About checks if the gotten value is within a certain tolerance of the +// expected value. The check is inclusive (`expected ± tolerance`). The +// tolerance must be non-negative. +func About[C constraints.Integer | constraints.Float](t T, gotten, expected, tolerance C, infos ...string) bool { + if tolerance < 0 { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "is about equal", "tolerance >= 0", tolerance, infos...) + return false + } + if gotten < expected-tolerance || gotten > expected+tolerance { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + expectedDescr := fmt.Sprintf("%v' +/- '%v'", expected, tolerance) + verificationFailure(t, "is about equal", expectedDescr, gotten, infos...) + return false + } + return true +} diff --git a/verify/verify_comparisons_test.go b/verify/verify_comparisons_test.go new file mode 100644 index 0000000..705a6c3 --- /dev/null +++ b/verify/verify_comparisons_test.go @@ -0,0 +1,147 @@ +// Tideland Go Asserts - Verify - Comparison Tests +// +// Copyright (C) 2024-2025 Frank Mueller / Tideland / Germany +// +// All rights reserved. Use of this source code is governed +// by the new BSD license. + +package verify_test + +import ( + "testing" + "time" + + "tideland.dev/go/asserts/verify" +) + +// TestZero tests the Zero verification function. +func TestZero(t *testing.T) { + // Positive: zero values + verify.Zero(t, 0) + verify.Zero(t, 0.0) + verify.Zero(t, int64(0)) + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: non-zero values + verify.Zero(ct, 1) + verify.Zero(ct, -1) + verify.Zero(ct, 0.1) + + verify.FailureCount(ct, 3) +} + +// TestNotZero tests the NotZero verification function. +func TestNotZero(t *testing.T) { + // Positive: non-zero values + verify.NotZero(t, 1) + verify.NotZero(t, -1) + verify.NotZero(t, 0.1) + verify.NotZero(t, -0.1) + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: zero value + verify.NotZero(ct, 0) + verify.NotZero(ct, 0.0) + + verify.FailureCount(ct, 2) +} + +// TestPositive tests the Positive verification function. +func TestPositive(t *testing.T) { + // Positive: positive values + verify.Positive(t, 1) + verify.Positive(t, 100) + verify.Positive(t, 0.1) + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: zero and negative values + verify.Positive(ct, 0) + verify.Positive(ct, -1) + verify.Positive(ct, -0.1) + + verify.FailureCount(ct, 3) +} + +// TestNegative tests the Negative verification function. +func TestNegative(t *testing.T) { + // Positive: negative values + verify.Negative(t, -1) + verify.Negative(t, -100) + verify.Negative(t, -0.1) + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: zero and positive values + verify.Negative(ct, 0) + verify.Negative(ct, 1) + verify.Negative(ct, 0.1) + + verify.FailureCount(ct, 3) +} + +// TestEven tests the Even verification function. +func TestEven(t *testing.T) { + // Positive: even values + verify.Even(t, 0) + verify.Even(t, 2) + verify.Even(t, -2) + verify.Even(t, 100) + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: odd values + verify.Even(ct, 1) + verify.Even(ct, 3) + verify.Even(ct, -1) + + verify.FailureCount(ct, 3) +} + +// TestOdd tests the Odd verification function. +func TestOdd(t *testing.T) { + // Positive: odd values + verify.Odd(t, 1) + verify.Odd(t, 3) + verify.Odd(t, -1) + verify.Odd(t, 99) + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: even values + verify.Odd(ct, 0) + verify.Odd(ct, 2) + verify.Odd(ct, -2) + + verify.FailureCount(ct, 3) +} + +// TestNumericPredicatesWithDurations tests numeric predicates work with time.Duration. +func TestNumericPredicatesWithDurations(t *testing.T) { + // Zero + verify.Zero(t, time.Duration(0)) + + // NotZero + verify.NotZero(t, 1*time.Second) + + // Positive + verify.Positive(t, 1*time.Second) + + // Negative + verify.Negative(t, -1*time.Second) + + ct := verify.ContinuedTesting(t) + + // Zero should fail for non-zero duration + verify.Zero(ct, 1*time.Second) + + verify.FailureCount(ct, 1) +} diff --git a/verify/verify_errors.go b/verify/verify_errors.go new file mode 100644 index 0000000..2412728 --- /dev/null +++ b/verify/verify_errors.go @@ -0,0 +1,136 @@ +// Tideland Go Asserts - Verify - Errors +// +// Copyright (C) 2024-2025 Frank Mueller / Tideland / Germany +// +// All rights reserved. Use of this source code is governed +// by the new BSD license. + +package verify + +import ( + "errors" + "regexp" + "strings" + "testing" +) + +// Error checks if the given error is not nil. +func Error(t T, err error) bool { + if err == nil { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "is error", "error", nil) + return false + } + return true +} + +// NoError checks if the given error is nil. +// It's the opposite of Error. +func NoError(t T, gotten error) bool { + if gotten != nil { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "is no error", nil, gotten) + return false + } + return true +} + +// IsError checks if the gotten error is equivalent to the expected error +// using `errors.Is`. This is useful for checking for sentinel errors. +func IsError(t T, gotten, expected error) bool { + if !errors.Is(gotten, expected) { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "is expected error", expected, gotten) + return false + } + return true +} + +// AsError checks if the gotten error can be unwrapped to a specific error +// type using `errors.As`. The `expected` parameter must be a pointer to a +// variable of the target error type (e.g., `var perr *os.PathError`). +func AsError(t T, gotten error, expected any) bool { + if gotten == nil { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "error as type", expected, gotten) + return false + } + if !errors.As(gotten, expected) { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "error as type", expected, gotten) + return false + } + return true +} + +// UnwrapError checks if unwrapping `gotten` error once results in an +// error that is equivalent to `expected` using `errors.Is`. +func UnwrapError(t T, gotten, expected error) bool { + if gotten == nil { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "error unwraps to", expected, gotten) + return false + } + unwrapped := errors.Unwrap(gotten) + if !errors.Is(unwrapped, expected) { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "error unwraps to", expected, unwrapped) + return false + } + return true +} + +// ErrorContains check if the given error is not nil and its message +// contains an expected string. +func ErrorContains(t T, gotten error, expected string) bool { + if gotten == nil { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "error contains", expected, gotten) + return false + } + if !strings.Contains(gotten.Error(), expected) { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "error contains", expected, gotten.Error()) + return false + } + return true +} + +// ErrorMatch checks if the gotten error is not nil and its message +// matches the expected regular expression. +func ErrorMatch(t T, gotten error, expected string) bool { + if gotten == nil { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "error does match", expected, gotten) + return false + } + re := regexp.MustCompile(expected) + if !re.MatchString(gotten.Error()) { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "error does match", expected, gotten.Error()) + return false + } + return true +} diff --git a/verify/verify_maps.go b/verify/verify_maps.go new file mode 100644 index 0000000..2679ede --- /dev/null +++ b/verify/verify_maps.go @@ -0,0 +1,69 @@ +// Tideland Go Asserts - Verify - Maps +// +// Copyright (C) 2024-2025 Frank Mueller / Tideland / Germany +// +// All rights reserved. Use of this source code is governed +// by the new BSD license. + +package verify + +import ( + "fmt" + "maps" + "testing" +) + +// MapEqual checks if two maps are equal using `maps.Equal`, which compares +// keys and values with the `==` operator. For deep equality of complex +// value types, use `DeepEqual`. +func MapEqual[M ~map[K]V, K comparable, V comparable](t T, gotten, expected M, infos ...string) bool { + if !maps.Equal(gotten, expected) { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "maps are equal", expected, gotten, infos...) + return false + } + return true +} + +// MapContainsKey checks if the map contains the specified key. +func MapContainsKey[M ~map[K]V, K comparable, V any](t T, gotten M, key K, infos ...string) bool { + if _, exists := gotten[key]; !exists { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "map contains key", key, "key not found", infos...) + return false + } + return true +} + +// MapContainsKeys checks if the map contains all specified keys. +func MapContainsKeys[M ~map[K]V, K comparable, V any](t T, gotten M, keys []K, infos ...string) bool { + for _, key := range keys { + if _, exists := gotten[key]; !exists { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "map contains all keys", keys, fmt.Sprintf("missing key: %v", key), infos...) + return false + } + } + return true +} + +// MapContainsValue checks if any key in the map is associated with the +// specified value. +func MapContainsValue[M ~map[K]V, K comparable, V comparable](t T, gotten M, value V, infos ...string) bool { + for _, v := range gotten { + if v == value { + return true + } + } + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "map contains value", value, "value not found", infos...) + return false +} diff --git a/verify/verify_maps_test.go b/verify/verify_maps_test.go new file mode 100644 index 0000000..47bdc37 --- /dev/null +++ b/verify/verify_maps_test.go @@ -0,0 +1,89 @@ +// Tideland Go Asserts - Verify - Map Tests +// +// Copyright (C) 2024-2025 Frank Mueller / Tideland / Germany +// +// All rights reserved. Use of this source code is governed +// by the new BSD license. + +package verify_test + +import ( + "testing" + + "tideland.dev/go/asserts/verify" +) + +// TestMapEqual tests the MapEqual verification function. +func TestMapEqual(t *testing.T) { + // Positive: equal maps + verify.MapEqual(t, map[string]int{"a": 1, "b": 2}, map[string]int{"a": 1, "b": 2}) + verify.MapEqual(t, map[int]string{1: "a", 2: "b"}, map[int]string{1: "a", 2: "b"}) + verify.MapEqual(t, map[string]int{}, map[string]int{}) + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: different maps + verify.MapEqual(ct, map[string]int{"a": 1, "b": 2}, map[string]int{"a": 1, "b": 3}) + verify.MapEqual(ct, map[string]int{"a": 1}, map[string]int{"a": 1, "b": 2}) + verify.MapEqual(ct, map[string]int{"a": 1}, map[string]int{"b": 1}) + + verify.FailureCount(ct, 3) +} + +// TestMapContainsKey tests the MapContainsKey verification function. +func TestMapContainsKey(t *testing.T) { + testMap := map[string]int{"a": 1, "b": 2, "c": 3} + + // Positive: key exists + verify.MapContainsKey(t, testMap, "a") + verify.MapContainsKey(t, testMap, "b") + verify.MapContainsKey(t, testMap, "c") + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: key doesn't exist + verify.MapContainsKey(ct, testMap, "d") + verify.MapContainsKey(ct, testMap, "x") + + verify.FailureCount(ct, 2) +} + +// TestMapContainsKeys tests the MapContainsKeys verification function. +func TestMapContainsKeys(t *testing.T) { + testMap := map[string]int{"a": 1, "b": 2, "c": 3} + + // Positive: all keys exist + verify.MapContainsKeys(t, testMap, []string{"a", "b"}) + verify.MapContainsKeys(t, testMap, []string{"a", "b", "c"}) + verify.MapContainsKeys(t, testMap, []string{}) + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: some keys missing + verify.MapContainsKeys(ct, testMap, []string{"a", "d"}) + verify.MapContainsKeys(ct, testMap, []string{"x", "y"}) + + verify.FailureCount(ct, 2) +} + +// TestMapContainsValue tests the MapContainsValue verification function. +func TestMapContainsValue(t *testing.T) { + testMap := map[string]int{"a": 1, "b": 2, "c": 3} + + // Positive: value exists + verify.MapContainsValue(t, testMap, 1) + verify.MapContainsValue(t, testMap, 2) + verify.MapContainsValue(t, testMap, 3) + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: value doesn't exist + verify.MapContainsValue(ct, testMap, 4) + verify.MapContainsValue(ct, testMap, 0) + + verify.FailureCount(ct, 2) +} diff --git a/verify/verify_predicates.go b/verify/verify_predicates.go new file mode 100644 index 0000000..1369b7c --- /dev/null +++ b/verify/verify_predicates.go @@ -0,0 +1,79 @@ +// Tideland Go Asserts - Verify - Predicates +// +// Copyright (C) 2024-2025 Frank Mueller / Tideland / Germany +// +// All rights reserved. Use of this source code is governed +// by the new BSD license. + +package verify + +import ( + "fmt" + "testing" +) + +// All checks if every element in a slice satisfies the provided predicate +// function. It passes for an empty slice. +func All[S ~[]E, E any](t T, gotten S, predicate func(E) bool, infos ...string) bool { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + + if predicate == nil { + verificationFailure(t, "all elements match predicate", "valid predicate", "nil predicate", infos...) + return false + } + + for i, elem := range gotten { + if !predicate(elem) { + msg := fmt.Sprintf("element at index %d failed predicate", i) + verificationFailure(t, "all elements match predicate", "all pass", msg, infos...) + return false + } + } + return true +} + +// Any checks if at least one element in a slice satisfies the provided +// predicate function. It fails for an empty slice. +func Any[S ~[]E, E any](t T, gotten S, predicate func(E) bool, infos ...string) bool { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + + if predicate == nil { + verificationFailure(t, "any element matches predicate", "valid predicate", "nil predicate", infos...) + return false + } + + for _, elem := range gotten { + if predicate(elem) { + return true + } + } + + verificationFailure(t, "any element matches predicate", "at least one pass", "none passed", infos...) + return false +} + +// None checks if no element in a slice satisfies the provided predicate +// function. It passes for an empty slice. +func None[S ~[]E, E any](t T, gotten S, predicate func(E) bool, infos ...string) bool { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + + if predicate == nil { + verificationFailure(t, "no elements match predicate", "valid predicate", "nil predicate", infos...) + return false + } + + for i, elem := range gotten { + if predicate(elem) { + msg := fmt.Sprintf("element at index %d passed predicate", i) + verificationFailure(t, "no elements match predicate", "none pass", msg, infos...) + return false + } + } + return true +} diff --git a/verify/verify_predicates_test.go b/verify/verify_predicates_test.go new file mode 100644 index 0000000..28a2e30 --- /dev/null +++ b/verify/verify_predicates_test.go @@ -0,0 +1,105 @@ +// Tideland Go Asserts - Verify - Predicate Tests +// +// Copyright (C) 2024-2025 Frank Mueller / Tideland / Germany +// +// All rights reserved. Use of this source code is governed +// by the new BSD license. + +package verify_test + +import ( + "testing" + + "tideland.dev/go/asserts/verify" +) + +// TestAll tests the All verification function. +func TestAll(t *testing.T) { + isPositive := func(n int) bool { return n > 0 } + isEven := func(n int) bool { return n%2 == 0 } + + // Positive: all elements pass + verify.All(t, []int{1, 2, 3, 4, 5}, isPositive) + verify.All(t, []int{2, 4, 6, 8}, isEven) + verify.All(t, []int{}, isPositive) // empty slice passes + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: not all elements pass + verify.All(ct, []int{1, 2, -3, 4}, isPositive) + verify.All(ct, []int{2, 4, 5, 8}, isEven) + + // Negative: nil predicate + verify.All(ct, []int{1, 2, 3}, nil) + + verify.FailureCount(ct, 3) +} + +// TestAny tests the Any verification function. +func TestAny(t *testing.T) { + isPositive := func(n int) bool { return n > 0 } + isEven := func(n int) bool { return n%2 == 0 } + + // Positive: at least one element passes + verify.Any(t, []int{-1, -2, 3, -4}, isPositive) + verify.Any(t, []int{1, 3, 5, 6}, isEven) + verify.Any(t, []int{5}, isPositive) + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: no elements pass + verify.Any(ct, []int{-1, -2, -3}, isPositive) + verify.Any(ct, []int{1, 3, 5}, isEven) + verify.Any(ct, []int{}, isPositive) // empty slice fails + + // Negative: nil predicate + verify.Any(ct, []int{1, 2, 3}, nil) + + verify.FailureCount(ct, 4) +} + +// TestNone tests the None verification function. +func TestNone(t *testing.T) { + isNegative := func(n int) bool { return n < 0 } + isOdd := func(n int) bool { return n%2 != 0 } + + // Positive: no elements pass + verify.None(t, []int{1, 2, 3, 4}, isNegative) + verify.None(t, []int{2, 4, 6, 8}, isOdd) + verify.None(t, []int{}, isNegative) // empty slice passes + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: at least one element passes + verify.None(ct, []int{1, 2, -3, 4}, isNegative) + verify.None(ct, []int{2, 4, 5, 8}, isOdd) + + // Negative: nil predicate + verify.None(ct, []int{1, 2, 3}, nil) + + verify.FailureCount(ct, 3) +} + +// TestPredicatesWithStrings tests predicate functions with string slices. +func TestPredicatesWithStrings(t *testing.T) { + isLong := func(s string) bool { return len(s) > 5 } + + // All + verify.All(t, []string{"hello!", "world!", "testing"}, isLong) + + // Any + verify.Any(t, []string{"hi", "hello!", "yo"}, isLong) + + // None + verify.None(t, []string{"hi", "yo", "ok"}, isLong) + + ct := verify.ContinuedTesting(t) + + // All should fail + verify.All(ct, []string{"hi", "hello!", "yo"}, isLong) + + verify.FailureCount(ct, 1) +} diff --git a/verify/verify_ranges.go b/verify/verify_ranges.go new file mode 100644 index 0000000..81c69f5 --- /dev/null +++ b/verify/verify_ranges.go @@ -0,0 +1,53 @@ +// Tideland Go Asserts - Verify - Ranges +// +// Copyright (C) 2024-2025 Frank Mueller / Tideland / Germany +// +// All rights reserved. Use of this source code is governed +// by the new BSD license. + +package verify + +import ( + "fmt" + "testing" + + "golang.org/x/exp/constraints" +) + +// InRange checks if the given value is within lower and upper bounds. +// The boundaries are inclusive (gotten can equal expectedLower or expectedUpper). +// If expectedLower is greater than expectedUpper, they will be automatically swapped. +// Possible values are integers, floats, and time.Duration. +func InRange[C constraints.Integer | constraints.Float](t T, gotten, expectedLower, expectedUpper C, infos ...string) bool { + if expectedLower > expectedUpper { + expectedLower, expectedUpper = expectedUpper, expectedLower + } + if gotten < expectedLower || gotten > expectedUpper { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + expectedDescr := fmt.Sprintf("'%v' to '%v'", expectedLower, expectedUpper) + verificationFailure(t, "is in range", expectedDescr, gotten, infos...) + return false + } + return true +} + +// OutOfRange checks if the given value is outside lower and upper bounds. +// It's the opposite of InRange. The boundaries are exclusive (gotten cannot equal +// expectedLower or expectedUpper to be considered out of range). +// If expectedLower is greater than expectedUpper, they will be automatically swapped. +func OutOfRange[C constraints.Integer | constraints.Float](t T, gotten, expectedLower, expectedUpper C, infos ...string) bool { + if expectedLower > expectedUpper { + expectedLower, expectedUpper = expectedUpper, expectedLower + } + if gotten >= expectedLower && gotten <= expectedUpper { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + expectedDescr := fmt.Sprintf("'%v' to '%v'", expectedLower, expectedUpper) + verificationFailure(t, "is out of range", expectedDescr, gotten, infos...) + return false + } + return true +} diff --git a/verify/verify_slices.go b/verify/verify_slices.go new file mode 100644 index 0000000..7440802 --- /dev/null +++ b/verify/verify_slices.go @@ -0,0 +1,95 @@ +// Tideland Go Asserts - Verify - Slices +// +// Copyright (C) 2024-2025 Frank Mueller / Tideland / Germany +// +// All rights reserved. Use of this source code is governed +// by the new BSD license. + +package verify + +import ( + "fmt" + "slices" + "testing" + + "golang.org/x/exp/constraints" +) + +// Contains checks if the `gotten` element is present in the `expected` slice. +func Contains[S ~[]E, E comparable](t T, gotten E, expected S, infos ...string) bool { + if !slices.Contains(expected, gotten) { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "contains", expected, gotten, infos...) + return false + } + return true +} + +// SliceEqual checks if two slices are deeply equal. +func SliceEqual[S ~[]E, E comparable](t T, gotten, expected S, infos ...string) bool { + if !slices.Equal(gotten, expected) { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "slices are equal", expected, gotten, infos...) + return false + } + return true +} + +// SliceContainsAll checks if the `haystack` slice contains all elements from +// the `needles` slice. The order and number of occurrences do not matter. +func SliceContainsAll[S ~[]E, E comparable](t T, haystack S, needles S, infos ...string) bool { + for _, needle := range needles { + if !slices.Contains(haystack, needle) { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "slice contains all", needles, fmt.Sprintf("missing: %v", needle), infos...) + return false + } + } + return true +} + +// SliceContainsAny checks if the haystack slice contains any element from the needles slice. +func SliceContainsAny[S ~[]E, E comparable](t T, haystack S, needles S, infos ...string) bool { + for _, needle := range needles { + if slices.Contains(haystack, needle) { + return true + } + } + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "slice contains any", needles, "none found", infos...) + return false +} + +// SliceSorted checks if the slice is sorted in ascending order. +func SliceSorted[S ~[]E, E constraints.Ordered](t T, gotten S, infos ...string) bool { + if !slices.IsSorted(gotten) { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "slice is sorted", "sorted slice", gotten, infos...) + return false + } + return true +} + +// SliceSortedFunc checks if the slice is sorted according to a comparison +// function. The comparison function `cmp` should return a negative number +// if `a < b`, zero if `a == b`, and a positive number if `a > b`. +func SliceSortedFunc[S ~[]E, E any](t T, gotten S, cmp func(E, E) int, infos ...string) bool { + if !slices.IsSortedFunc(gotten, cmp) { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "slice is sorted (custom)", "sorted slice", gotten, infos...) + return false + } + return true +} diff --git a/verify/verify_slices_test.go b/verify/verify_slices_test.go new file mode 100644 index 0000000..0034529 --- /dev/null +++ b/verify/verify_slices_test.go @@ -0,0 +1,109 @@ +// Tideland Go Asserts - Verify - Slice Tests +// +// Copyright (C) 2024-2025 Frank Mueller / Tideland / Germany +// +// All rights reserved. Use of this source code is governed +// by the new BSD license. + +package verify_test + +import ( + "testing" + + "tideland.dev/go/asserts/verify" +) + +// TestSliceEqual tests the SliceEqual verification function. +func TestSliceEqual(t *testing.T) { + // Positive: equal slices + verify.SliceEqual(t, []int{1, 2, 3}, []int{1, 2, 3}) + verify.SliceEqual(t, []string{"a", "b", "c"}, []string{"a", "b", "c"}) + verify.SliceEqual(t, []int{}, []int{}) + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: different slices + verify.SliceEqual(ct, []int{1, 2, 3}, []int{1, 2, 4}) + verify.SliceEqual(ct, []int{1, 2, 3}, []int{1, 2}) + verify.SliceEqual(ct, []string{"a", "b"}, []string{"a", "c"}) + + verify.FailureCount(ct, 3) +} + +// TestSliceContainsAll tests the SliceContainsAll verification function. +func TestSliceContainsAll(t *testing.T) { + // Positive: haystack contains all needles + verify.SliceContainsAll(t, []int{1, 2, 3, 4, 5}, []int{2, 4}) + verify.SliceContainsAll(t, []string{"a", "b", "c"}, []string{"a", "c"}) + verify.SliceContainsAll(t, []int{1, 2, 3}, []int{}) + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: missing elements + verify.SliceContainsAll(ct, []int{1, 2, 3}, []int{4, 5}) + verify.SliceContainsAll(ct, []string{"a", "b"}, []string{"c"}) + + verify.FailureCount(ct, 2) +} + +// TestSliceContainsAny tests the SliceContainsAny verification function. +func TestSliceContainsAny(t *testing.T) { + // Positive: haystack contains at least one needle + verify.SliceContainsAny(t, []int{1, 2, 3}, []int{2, 5}) + verify.SliceContainsAny(t, []string{"a", "b", "c"}, []string{"x", "c"}) + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: no elements found + verify.SliceContainsAny(ct, []int{1, 2, 3}, []int{4, 5, 6}) + verify.SliceContainsAny(ct, []string{"a", "b"}, []string{"x", "y"}) + verify.SliceContainsAny(ct, []int{1, 2, 3}, []int{}) + + verify.FailureCount(ct, 3) +} + +// TestSliceSorted tests the SliceSorted verification function. +func TestSliceSorted(t *testing.T) { + // Positive: sorted slices + verify.SliceSorted(t, []int{1, 2, 3, 4, 5}) + verify.SliceSorted(t, []string{"a", "b", "c"}) + verify.SliceSorted(t, []int{}) + verify.SliceSorted(t, []int{1}) + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: unsorted slices + verify.SliceSorted(ct, []int{3, 1, 2}) + verify.SliceSorted(ct, []string{"c", "a", "b"}) + + verify.FailureCount(ct, 2) +} + +// TestSliceSortedFunc tests the SliceSortedFunc verification function. +func TestSliceSortedFunc(t *testing.T) { + // Descending comparison function + descending := func(a, b int) int { + if a > b { + return -1 + } + if a < b { + return 1 + } + return 0 + } + + // Positive: sorted with custom function + verify.SliceSortedFunc(t, []int{5, 4, 3, 2, 1}, descending) + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: not sorted according to custom function + verify.SliceSortedFunc(ct, []int{1, 2, 3, 4, 5}, descending) + + verify.FailureCount(ct, 1) +} diff --git a/verify/verify_strings.go b/verify/verify_strings.go new file mode 100644 index 0000000..4fbe788 --- /dev/null +++ b/verify/verify_strings.go @@ -0,0 +1,182 @@ +// Tideland Go Asserts - Verify - Strings +// +// Copyright (C) 2024-2025 Frank Mueller / Tideland / Germany +// +// All rights reserved. Use of this source code is governed +// by the new BSD license. + +package verify + +import ( + "reflect" + "regexp" + "strings" + "testing" +) + +// Length checks if the given value has the expected length. This only +// works for the according types for len(). All others fail. +func Length(t T, gotten any, expected int, infos ...string) bool { + if expected < 0 { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "has length", expected, "not quantifiable", infos...) + return false + } + gottenLen := flexlen(gotten) + if gottenLen < 0 { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "has length", expected, "gotten not quantifiable", infos...) + return false + } + if gottenLen != expected { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "has length", expected, gottenLen, infos...) + return false + } + return true +} + +// Empty checks if the given value is empty. This only works for the according types for len(). +// All others fail. +func Empty(t T, gotten any, infos ...string) bool { + gottenLen := flexlen(gotten) + if gottenLen < 0 { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "empty", 0, "gotten not quantifiable", infos...) + return false + } + if gottenLen != 0 { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "empty", 0, gottenLen, infos...) + return false + } + return true +} + +// NotEmpty checks if the given value is not empty. This only works for the according types for len(). +// All others fail. +func NotEmpty(t T, gotten any, infos ...string) bool { + gottenLen := flexlen(gotten) + if gottenLen < 0 { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "not empty", 0, "gotten not quantifiable", infos...) + return false + } + if gottenLen == 0 { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "not empty", "> 0", gottenLen, infos...) + return false + } + return true +} + +// Substring checks if `gotten` is a substring of `expected`. +func Substring(t T, gotten, expected string, infos ...string) bool { + if !strings.Contains(expected, gotten) { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "substring", expected, gotten, infos...) + return false + } + return true +} + +// Match checks if the gotten string matches the expected regular expression. +func Match(t T, gotten, expected string, infos ...string) bool { + re, err := regexp.Compile(expected) + if err != nil { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "matches", expected, err.Error(), infos...) + return false + } + if !re.MatchString(gotten) { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "matches", expected, gotten, infos...) + return false + } + return true +} + +// flexlen returns the length of types available to return their length. +func flexlen(in any) int { + // Check for possible existing methods + switch in := in.(type) { + case lenner: + return in.Len() + case lengthier: + return in.Length() + default: + // Use reflection + rv := reflect.ValueOf(in) + switch rv.Kind() { + case reflect.Array, reflect.Chan, reflect.Map, reflect.Slice, reflect.String: + return rv.Len() + default: + // Good old -1 is enough here, verification is above + return -1 + } + } +} + +type lenner interface { + Len() int +} + +type lengthier interface { + Length() int +} + +// HasPrefix checks if the gotten string has the expected prefix. +func HasPrefix(t T, gotten, expected string, infos ...string) bool { + if !strings.HasPrefix(gotten, expected) { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "has prefix", expected, gotten, infos...) + return false + } + return true +} + +// HasSuffix checks if the gotten string has the expected suffix. +func HasSuffix(t T, gotten, expected string, infos ...string) bool { + if !strings.HasSuffix(gotten, expected) { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "has suffix", expected, gotten, infos...) + return false + } + return true +} + +// ContainsIgnoreCase checks if the haystack string contains the needle string (case-insensitive). +func ContainsIgnoreCase(t T, haystack, needle string, infos ...string) bool { + if !strings.Contains(strings.ToLower(haystack), strings.ToLower(needle)) { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "contains (case-insensitive)", needle, haystack, infos...) + return false + } + return true +} diff --git a/verify/verify_strings_test.go b/verify/verify_strings_test.go new file mode 100644 index 0000000..e4e3a9b --- /dev/null +++ b/verify/verify_strings_test.go @@ -0,0 +1,71 @@ +// Tideland Go Asserts - Verify - String Tests +// +// Copyright (C) 2024-2025 Frank Mueller / Tideland / Germany +// +// All rights reserved. Use of this source code is governed +// by the new BSD license. + +package verify_test + +import ( + "testing" + + "tideland.dev/go/asserts/verify" +) + +// TestHasPrefix tests the HasPrefix verification function. +func TestHasPrefix(t *testing.T) { + // Positive: has prefix + verify.HasPrefix(t, "hello world", "hello") + verify.HasPrefix(t, "testing", "test") + verify.HasPrefix(t, "abc", "") + verify.HasPrefix(t, "same", "same") + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: doesn't have prefix + verify.HasPrefix(ct, "hello world", "world") + verify.HasPrefix(ct, "testing", "ing") + verify.HasPrefix(ct, "abc", "xyz") + + verify.FailureCount(ct, 3) +} + +// TestHasSuffix tests the HasSuffix verification function. +func TestHasSuffix(t *testing.T) { + // Positive: has suffix + verify.HasSuffix(t, "hello world", "world") + verify.HasSuffix(t, "testing", "ing") + verify.HasSuffix(t, "abc", "") + verify.HasSuffix(t, "same", "same") + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: doesn't have suffix + verify.HasSuffix(ct, "hello world", "hello") + verify.HasSuffix(ct, "testing", "test") + verify.HasSuffix(ct, "abc", "xyz") + + verify.FailureCount(ct, 3) +} + +// TestContainsIgnoreCase tests the ContainsIgnoreCase verification function. +func TestContainsIgnoreCase(t *testing.T) { + // Positive: contains (case-insensitive) + verify.ContainsIgnoreCase(t, "Hello World", "hello") + verify.ContainsIgnoreCase(t, "Hello World", "WORLD") + verify.ContainsIgnoreCase(t, "Hello World", "o W") + verify.ContainsIgnoreCase(t, "TESTING", "test") + verify.ContainsIgnoreCase(t, "testing", "TEST") + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: doesn't contain + verify.ContainsIgnoreCase(ct, "Hello World", "goodbye") + verify.ContainsIgnoreCase(ct, "testing", "xyz") + + verify.FailureCount(ct, 2) +} diff --git a/verify/verify_test.go b/verify/verify_test.go index 9c0f766..efac069 100644 --- a/verify/verify_test.go +++ b/verify/verify_test.go @@ -8,8 +8,10 @@ package verify_test import ( + "bytes" "errors" "fmt" + "sync" "testing" "time" @@ -32,6 +34,65 @@ func TestVerify(t *testing.T) { verify.FailureCount(ct, 2) } +// TestImplements tests the Implements verification function. +func TestImplements(t *testing.T) { + // Define test interface as nil value of interface type + var stringer fmt.Stringer + + // Positive: type that implements interface + var buf bytes.Buffer + verify.Implements(t, &buf, &stringer) + + // Another type that implements Stringer (time.Time implements Stringer) + now := time.Now() + verify.Implements(t, now, &stringer) + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: type that doesn't implement interface + verify.Implements(ct, 42, &stringer) + verify.Implements(ct, "string", &stringer) + + // Negative: nil expected (not an interface) + verify.Implements(ct, &buf, nil) + + // Negative: nil gotten + verify.Implements(ct, nil, &stringer) + + // Negative: expected is not interface type pointer + var notInterface int + verify.Implements(ct, &buf, ¬Interface) + + verify.FailureCount(ct, 5) +} + +// TestErrorContains tests the ErrorContains verification function. +func TestErrorContains(t *testing.T) { + testErr := errors.New("database connection failed: timeout after 30s") + + // Positive: error contains substring + verify.ErrorContains(t, testErr, "database") + verify.ErrorContains(t, testErr, "connection") + verify.ErrorContains(t, testErr, "timeout") + verify.ErrorContains(t, testErr, "30s") + verify.ErrorContains(t, testErr, "database connection failed: timeout after 30s") // full match + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: nil error + verify.ErrorContains(ct, nil, "anything") + verify.ErrorContains(ct, nil, "database") + + // Negative: substring not in error + verify.ErrorContains(ct, testErr, "network") + verify.ErrorContains(ct, testErr, "SUCCESS") + verify.ErrorContains(ct, testErr, "redis") + + verify.FailureCount(ct, 5) +} + // TestBoolean tests the True and False verification functions. func TestBoolean(t *testing.T) { // Standard testing @@ -271,9 +332,7 @@ func (e customError) Error() string { return e.msg } -type anotherError struct { - code int -} +type anotherError struct{} func (e anotherError) Error() string { return "another error" @@ -413,3 +472,154 @@ func TestIsContinue(t *testing.T) { verify.FailureCount(ct, 0) } +// TestPanics tests the Panics verification function. +func TestPanics(t *testing.T) { + // Positive: function that panics + verify.Panics(t, func() { panic("test panic") }) + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: function that doesn't panic + verify.Panics(ct, func() { + // do nothing - no panic + }) + + // Negative: nil function + verify.Panics(ct, nil) + + verify.FailureCount(ct, 2) +} + +// TestNotPanics tests the NotPanics verification function. +func TestNotPanics(t *testing.T) { + // Positive: function that doesn't panic + verify.NotPanics(t, func() { + // safe code + _ = 1 + 1 + }) + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: function that panics + verify.NotPanics(ct, func() { + panic("boom") + }) + + // Negative: nil function + verify.NotPanics(ct, nil) + + verify.FailureCount(ct, 2) +} + +// TestConcurrentContinuedTesting tests thread-safety of continuedTesting. +func TestConcurrentContinuedTesting(t *testing.T) { + ct := verify.ContinuedTesting(t) + + // Run multiple goroutines using same continued testing instance + var wg sync.WaitGroup + numGoroutines := 10 + + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + // Intentional failure - each goroutine adds one failure + verify.Equal(ct, n, n+1) + }(i) + } + + wg.Wait() + + // All 10 failures should be counted correctly (tests thread safety) + verify.FailureCount(ct, numGoroutines) +} + +// TestBetweenBoundaries tests the Between function with boundary conditions. +func TestBetweenBoundaries(t *testing.T) { + now := time.Now() + earlier := now.Add(-1 * time.Hour) + later := now.Add(1 * time.Hour) + + // Positive: exact boundary matches should pass (inclusive) + verify.Between(t, earlier, earlier, later) + verify.Between(t, later, earlier, later) + verify.Between(t, now, earlier, later) + + // Positive: boundaries auto-swap if reversed + verify.Between(t, now, later, earlier) + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: outside the range + verify.Between(ct, earlier.Add(-1*time.Second), earlier, later) + verify.Between(ct, later.Add(1*time.Second), earlier, later) + + verify.FailureCount(ct, 2) +} + +// TestInRangeBoundaries tests InRange and OutOfRange with boundary conditions. +func TestInRangeBoundaries(t *testing.T) { + // Positive: exact boundary matches should pass (inclusive) + verify.InRange(t, 30, 30, 50) + verify.InRange(t, 50, 30, 50) + verify.InRange(t, 40, 30, 50) + + verify.InRange(t, 3.0, 3.0, 5.0) + verify.InRange(t, 5.0, 3.0, 5.0) + + verify.InRange(t, 3*time.Second, 3*time.Second, 5*time.Second) + verify.InRange(t, 5*time.Second, 3*time.Second, 5*time.Second) + + // Positive: boundaries auto-swap if reversed + verify.InRange(t, 40, 50, 30) + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: outside range + verify.InRange(ct, 29, 30, 50) + verify.InRange(ct, 51, 30, 50) + + verify.FailureCount(ct, 2) +} + +// TestOutOfRangeBoundaries tests OutOfRange with boundary conditions. +func TestOutOfRangeBoundaries(t *testing.T) { + // Positive: outside the range + verify.OutOfRange(t, 29, 30, 50) + verify.OutOfRange(t, 51, 30, 50) + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: exact boundary matches should fail (boundaries are exclusive for OutOfRange) + verify.OutOfRange(ct, 30, 30, 50) + verify.OutOfRange(ct, 50, 30, 50) + verify.OutOfRange(ct, 40, 30, 50) + + verify.FailureCount(ct, 3) +} + +// TestAboutTolerance tests About function with tolerance validation. +func TestAboutTolerance(t *testing.T) { + // Positive: valid tolerance + verify.About(t, 45, 43, 5) + verify.About(t, 4.5, 4.3, 0.3) + verify.About(t, 5*time.Second, 4*time.Second, 2*time.Second) + + // Positive: zero tolerance + verify.About(t, 10, 10, 0) + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: negative tolerance should fail + verify.About(ct, 45, 43, -5) + verify.About(ct, 4.5, 4.3, -0.3) + verify.About(ct, 5*time.Second, 4*time.Second, -2*time.Second) + + verify.FailureCount(ct, 3) +} diff --git a/verify/verify_time.go b/verify/verify_time.go new file mode 100644 index 0000000..77a22b4 --- /dev/null +++ b/verify/verify_time.go @@ -0,0 +1,101 @@ +// Tideland Go Asserts - Verify - Time +// +// Copyright (C) 2024-2025 Frank Mueller / Tideland / Germany +// +// All rights reserved. Use of this source code is governed +// by the new BSD license. + +package verify + +import ( + "fmt" + "testing" + "time" +) + +// Simultaneous checks if the gotten time is simultaneous with the expected time. +func Simultaneous(t T, gotten, expected time.Time, infos ...string) bool { + if !gotten.Equal(expected) { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "is time simultaneous", ftim(expected), ftim(gotten), infos...) + return false + } + return true +} + +// Before checks if the gotten time is before the expected time. +func Before(t T, gotten, expected time.Time, infos ...string) bool { + if !gotten.Before(expected) { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "is time before", ftim(expected), ftim(gotten), infos...) + return false + } + return true +} + +// After checks if the gotten time is after the expected time. +func After(t T, gotten, expected time.Time, infos ...string) bool { + if !gotten.After(expected) { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "is time after", ftim(expected), ftim(gotten), infos...) + return false + } + return true +} + +// Between checks if the gotten time is between the expected start and end times. +// The boundaries are inclusive (gotten can equal expectedBegin or expectedEnd). +// If expectedBegin is after expectedEnd, they will be automatically swapped. +func Between(t T, gotten, expectedBegin, expectedEnd time.Time, infos ...string) bool { + expstr := "" + if expectedBegin.After(expectedEnd) { + expectedBegin, expectedEnd = expectedEnd, expectedBegin + } + if gotten.Before(expectedBegin) || gotten.After(expectedEnd) { + expstr = fmt.Sprintf("'%s' and '%s'", ftim(expectedBegin), ftim(expectedEnd)) + } + if expstr != "" { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "is between", expstr, ftim(gotten), infos...) + return false + } + return true +} + +// Shorter checks if the gotten duration is shorter than the expected duration. +func Shorter(t T, gotten, expected time.Duration, infos ...string) bool { + if gotten >= expected { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "duration is shorter", expected, gotten, infos...) + return false + } + return true +} + +// Longer checks if the gotten duration is longer than the expected duration. +func Longer(t T, gotten, expected time.Duration, infos ...string) bool { + if gotten <= expected { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "duration is longer", expected, gotten, infos...) + return false + } + return true +} + +// ftim formats a time.Time value into RFC3339 format for consistent +// test output. +func ftim(t time.Time) string { + return t.Format(time.RFC3339) +} diff --git a/verify/verify_types.go b/verify/verify_types.go new file mode 100644 index 0000000..5fcd0bb --- /dev/null +++ b/verify/verify_types.go @@ -0,0 +1,236 @@ +// Tideland Go Asserts - Verify - Types +// +// Copyright (C) 2024-2025 Frank Mueller / Tideland / Germany +// +// All rights reserved. Use of this source code is governed +// by the new BSD license. + +package verify + +import ( + "reflect" + "testing" +) + +// Implements checks if the gotten instance implements the expected interface. +// The expected parameter has to be an interface type as nil pointer. Here e.g. +// var stringer fmt.Stringer and then verify.Implements(t, myVar, &stringer). +func Implements(t T, gotten, expected any) bool { + if expected == nil { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "does implement", "expected instance", nil) + return false + } + + if gotten == nil { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "does implement", "actual instance", nil) + return false + } + + expectedType := reflect.TypeOf(expected).Elem() + if expectedType.Kind() != reflect.Interface { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "does implement", "expected interface", nil) + return false + } + + actualType := reflect.TypeOf(gotten) + if !actualType.Implements(expectedType) { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "does implement", expectedType, actualType) + return false + } + return true +} + +// Assignability checks if a value of `gotten`'s type is assignable to a +// variable of `expected`'s type. +func Assignability(t T, gotten, expected any) bool { + if expected == nil { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "is assignable to", "expected type", nil) + return false + } + + if gotten == nil { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "is assignable to", "actual type", nil) + return false + } + + expectedType := reflect.TypeOf(expected) + actualType := reflect.TypeOf(gotten) + + if !actualType.AssignableTo(expectedType) { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "is assignable to", expectedType, actualType) + return false + } + return true +} + +// Panics checks if calling the function `gotten` causes a panic. +func Panics(t T, gotten func()) bool { + if gotten == nil { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "panics", "expected function", nil) + return false + } + + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + + panicked := false + defer func() { + if r := recover(); r != nil { + panicked = true + } + }() + + gotten() + + if !panicked { + verificationFailure(t, "panics", "function to panic", "function did not panic") + return false + } + return true +} + +// NotPanics checks that calling the function `gotten` does not cause a panic. +func NotPanics(t T, gotten func()) bool { + if gotten == nil { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + verificationFailure(t, "not panics", "expected function", nil) + return false + } + + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + + panicked := false + defer func() { + if r := recover(); r != nil { + panicked = true + verificationFailure(t, "not panics", "function not to panic", "function panicked") + } + }() + + gotten() + + return !panicked +} + +// DeepEqual checks if the gotten and expected values are deeply equal using reflection. +// This is useful for comparing complex structures, slices, maps, etc. +func DeepEqual(t T, gotten, expected any, infos ...string) bool { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + + if !reflect.DeepEqual(gotten, expected) { + verificationFailure(t, "is deeply equal", expected, gotten, infos...) + return false + } + return true +} + +// SameType checks if the gotten and expected values have the same type. +func SameType(t T, gotten, expected any, infos ...string) bool { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + + gottenType := reflect.TypeOf(gotten) + expectedType := reflect.TypeOf(expected) + + if gottenType != expectedType { + verificationFailure(t, "is same type", expectedType, gottenType, infos...) + return false + } + return true +} + +// SamePointer checks if the gotten and expected values point to the same memory address. +// Both values must be pointers, slices, maps, channels, or functions. +func SamePointer(t T, gotten, expected any, infos ...string) bool { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + + gottenVal := reflect.ValueOf(gotten) + expectedVal := reflect.ValueOf(expected) + + // Check if both are valid pointer-like types + if !isPointerLike(gottenVal) { + verificationFailure(t, "same pointer", "pointer-like type", "non-pointer type", infos...) + return false + } + if !isPointerLike(expectedVal) { + verificationFailure(t, "same pointer", "pointer-like type", "non-pointer type", infos...) + return false + } + + if gottenVal.Pointer() != expectedVal.Pointer() { + verificationFailure(t, "same pointer", expectedVal.Pointer(), gottenVal.Pointer(), infos...) + return false + } + return true +} + +// NotSamePointer checks if the gotten and expected values point to different memory addresses. +// Both values must be pointers, slices, maps, channels, or functions. +func NotSamePointer(t T, gotten, expected any, infos ...string) bool { + if ht, ok := t.(testing.TB); ok { + ht.Helper() + } + + gottenVal := reflect.ValueOf(gotten) + expectedVal := reflect.ValueOf(expected) + + // Check if both are valid pointer-like types + if !isPointerLike(gottenVal) { + verificationFailure(t, "different pointer", "pointer-like type", "non-pointer type", infos...) + return false + } + if !isPointerLike(expectedVal) { + verificationFailure(t, "different pointer", "pointer-like type", "non-pointer type", infos...) + return false + } + + if gottenVal.Pointer() == expectedVal.Pointer() { + verificationFailure(t, "different pointer", "different addresses", "same address", infos...) + return false + } + return true +} + +// isPointerLike checks if a value is a pointer-like type (pointer, slice, map, channel, or function). +func isPointerLike(v reflect.Value) bool { + if !v.IsValid() { + return false + } + kind := v.Kind() + return kind == reflect.Ptr || kind == reflect.Slice || kind == reflect.Map || + kind == reflect.Chan || kind == reflect.Func +} diff --git a/verify/verify_types_test.go b/verify/verify_types_test.go new file mode 100644 index 0000000..de210cb --- /dev/null +++ b/verify/verify_types_test.go @@ -0,0 +1,105 @@ +// Tideland Go Asserts - Verify - Type Tests +// +// Copyright (C) 2024-2025 Frank Mueller / Tideland / Germany +// +// All rights reserved. Use of this source code is governed +// by the new BSD license. + +package verify_test + +import ( + "testing" + + "tideland.dev/go/asserts/verify" +) + +// TestDeepEqual tests the DeepEqual verification function. +func TestDeepEqual(t *testing.T) { + type nested struct { + Value int + Name string + } + + // Positive: deeply equal values + verify.DeepEqual(t, []int{1, 2, 3}, []int{1, 2, 3}) + verify.DeepEqual(t, map[string]int{"a": 1, "b": 2}, map[string]int{"a": 1, "b": 2}) + verify.DeepEqual(t, nested{Value: 42, Name: "test"}, nested{Value: 42, Name: "test"}) + verify.DeepEqual(t, []nested{{Value: 1, Name: "a"}}, []nested{{Value: 1, Name: "a"}}) + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: not deeply equal + verify.DeepEqual(ct, []int{1, 2, 3}, []int{1, 2, 4}) + verify.DeepEqual(ct, map[string]int{"a": 1}, map[string]int{"a": 2}) + verify.DeepEqual(ct, nested{Value: 42, Name: "test"}, nested{Value: 42, Name: "other"}) + + verify.FailureCount(ct, 3) +} + +// TestSameType tests the SameType verification function. +func TestSameType(t *testing.T) { + // Positive: same types + verify.SameType(t, 42, 100) + verify.SameType(t, "hello", "world") + verify.SameType(t, []int{1}, []int{2, 3}) + verify.SameType(t, map[string]int{}, map[string]int{"a": 1}) + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: different types + verify.SameType(ct, 42, "42") + verify.SameType(ct, 42, int64(42)) + verify.SameType(ct, []int{1}, []string{"1"}) + + verify.FailureCount(ct, 3) +} + +// TestSamePointer tests the SamePointer verification function. +func TestSamePointer(t *testing.T) { + slice := []int{1, 2, 3} + sameSlice := slice + differentSlice := []int{1, 2, 3} + + ptr := new(int) + samePtr := ptr + differentPtr := new(int) + + // Positive: same pointer + verify.SamePointer(t, slice, sameSlice) + verify.SamePointer(t, ptr, samePtr) + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: different pointers + verify.SamePointer(ct, slice, differentSlice) + verify.SamePointer(ct, ptr, differentPtr) + + verify.FailureCount(ct, 2) +} + +// TestNotSamePointer tests the NotSamePointer verification function. +func TestNotSamePointer(t *testing.T) { + slice := []int{1, 2, 3} + sameSlice := slice + differentSlice := []int{1, 2, 3} + + ptr := new(int) + samePtr := ptr + differentPtr := new(int) + + // Positive: different pointers + verify.NotSamePointer(t, slice, differentSlice) + verify.NotSamePointer(t, ptr, differentPtr) + + // Create continuation testing for negative cases + ct := verify.ContinuedTesting(t) + + // Negative: same pointer + verify.NotSamePointer(ct, slice, sameSlice) + verify.NotSamePointer(ct, ptr, samePtr) + + verify.FailureCount(ct, 2) +}