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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ updates:
interval: "weekly"
commit-message:
prefix: "chore(things/testurl)"
- package-ecosystem: "gomod"
directory: "/helpers/counter"
schedule:
interval: "weekly"
commit-message:
prefix: "chore(helpers/counter)"

- package-ecosystem: "github-actions"
directory: "/"
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,15 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.21'
go-version: '1.24'
- name: Test module
working-directory: ${{ matrix.module }}
run: make tests
- name: Upload coverage to Codecov
if: ${{ hashFiles(format('{0}/coverage.out', matrix.module)) != '' }}
uses: codecov/codecov-action@v5
with:
files: ${{ matrix.module }}/coverage.out
flags: ${ matrix.module }-unittests
flags: ${{ matrix.module }}-unittests
name: ${{ matrix.module }}
token: ${{ secrets.CODECOV_TOKEN }}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
coverage.*
*.coverprofile
profile.cov
*.swp
*.swo
*.swx

# Dependency directories (remove the comment below to include it)
# vendor/
Expand Down
13 changes: 11 additions & 2 deletions .release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"package-name": "",
"bump-minor-pre-major": true,
"include-v-in-tag": true,
"extra-files": ["go.mod", "monorepo.go"],
"extra-files": ["go.mod"],
"changelog-path": "CHANGELOG.md"
},
"things/testurl": {
Expand All @@ -18,7 +18,16 @@
"bump-minor-pre-major": true,
"include-component-in-tag": true,
"include-v-in-tag": true,
"extra-files": ["things/testurl/go.mod", "things/testurl/things/testurl.go"],
"extra-files": ["things/testurl/go.mod", "things/testurl/url.go"],
"changelog-path": "CHANGELOG.md"
},
"helpers/counter": {
"release-type": "go",
"package-name": "helpers/counter",
"bump-minor-pre-major": true,
"include-component-in-tag": true,
"include-v-in-tag": true,
"extra-files": ["helpers/counter/go.mod"],
"changelog-path": "CHANGELOG.md"
}
},
Expand Down
7 changes: 3 additions & 4 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
{
".": "0.3.0",
"modulea": "0.1.0",
"moduleb": "0.1.0",
"things/testurl": "1.1.0"
}
"things/testurl": "1.1.0",
"helpers/counter": "0.1.0"
}
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

all: build tests lint

COMPONENTS = things/testurl
COMPONENTS = things/testurl helpers/counter

# Run tests for all modules
tests:
Expand All @@ -29,6 +29,11 @@ build:
format:
@echo "Formatting code..."
@gofmt -s -w .
@if command -v golines >/dev/null 2>&1; then \
golines -w .; \
else \
echo "golines not installed, skipping line wrapping"; \
fi
@for dir in $(COMPONENTS); do \
$(MAKE) -C $$dir format || exit 1; \
done
Expand Down
35 changes: 17 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,27 +72,25 @@ req := &http.Request{
}
```

### Contexts without the hassle | `github.com/madflojo/testlazy/fakes/fakectx`
### Counters for async tests | `github.com/madflojo/testlazy/helpers/counter`

Instead of this:

```go
// Create a context that is already canceled
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel the context immediately

err := doSomething(ctx)
if err == nil {
t.Fatal("Expected error for canceled context, got nil")
}
```

You can now just do this:
Coordinating background goroutines without brittle sleeps? Use a tiny, thread-safe counter that can signal when a condition is met.

```go
err := doSomething(fakectx.Cancelled())
if err == nil {
t.Fatal("Expected error for canceled context, got nil")
// Create a counter.
c := counter.New()

// Kick off work in the background.
go func() {
for i := 0; i < 5; i++ {
c.Increment()
time.Sleep(5 * time.Millisecond)
}
}()

// Wait until value >= 5 or time out.
if err := <-c.WaitAbove(5, time.Second); err != nil {
t.Fatalf("timed out waiting for counter: %v", err)
}
```

Expand All @@ -106,6 +104,7 @@ It allows you to take on only the dependencies you need.
| Package | Description | Go Package Reference |
|---------|-------------|----------------------|
| `github.com/madflojo/testlazy/things/testurl` | Pre-built URLs for common use cases | [![Go Reference](https://pkg.go.dev/badge/github.com/madflojo/testlazy/things/testurl.svg)](https://pkg.go.dev/github.com/madflojo/testlazy/things/testurl) |
| `github.com/madflojo/testlazy/helpers/counter` | Test-focused, thread-safe counter | [![Go Reference](https://pkg.go.dev/badge/github.com/madflojo/testlazy/helpers/counter.svg)](https://pkg.go.dev/github.com/madflojo/testlazy/helpers/counter) |

---

Expand Down
57 changes: 57 additions & 0 deletions helpers/counter/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
.PHONY: all clean tests lint build format coverage benchmarks

all: build tests lint

# Run tests with coverage
tests:
@echo "Running tests with coverage..."
go test -v -race -covermode=atomic -coverprofile=coverage.out ./...
@go tool cover -func=coverage.out || true
@if command -v go tool cover >/dev/null 2>&1; then \
go tool cover -html=coverage.out -o coverage.html; \
fi

# Run benchmarks
benchmarks:
@echo "Running benchmarks..."
go test -run=^$$ -bench=. -benchmem ./...

# Build the package
build:
@echo "Building package..."
go build ./...

# Format code: fmt, imports (if available), and gofmt -s
format:
@echo "Formatting code..."
@gofmt -s -w .
@if command -v goimports >/dev/null 2>&1; then \
goimports -w .; \
else \
echo "goimports not installed, skipping import reordering"; \
fi
@if command -v golines >/dev/null 2>&1; then \
golines -w .; \
else \
echo "golines not installed, skipping line wrapping"; \
fi

# Lint code if golangci-lint is available
lint:
@echo "Linting code..."
@if command -v golangci-lint >/dev/null 2>&1; then \
golangci-lint run ./...; \
else \
echo "golangci-lint not installed, skipping lint"; \
fi

# Generate coverage report (HTML)
coverage: tests
@go tool cover -html=coverage.out

# Clean build artifacts
clean:
@echo "Cleaning build artifacts..."
@find . -type f -name "*.test" -delete
@rm -f coverage.out coverage.html
@rm -rf tmp
133 changes: 133 additions & 0 deletions helpers/counter/counter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/*
Package counter provides a tiny, thread-safe counter focused on
testing scenarios. It supports atomic increments/decrements, direct
reads/sets, and simple wait helpers that block until the value crosses a
threshold.

github.com/madflojo/testlazy/helpers/counter

# Why use it

- Minimal API designed for tests.
- Safe for concurrent use by multiple goroutines.
- Non-blocking "wait" helpers that return a channel for easy select/timeout.

Quick example

c := counter.New()
c.Increment()
c.Add(9)
<-c.WaitAbove(10, time.Second) // wait until value >= 10
*/
package counter

import (
"errors"
"sync/atomic"
"time"
)

// defaultPollInterval controls how often waiters check the counter value.
const defaultPollInterval = 10 * time.Millisecond

// ErrTimeout indicates a WaitAbove or WaitBelow call timed out before the
// condition was met.
var ErrTimeout = errors.New("timeout waiting for counter condition")

// Counter is a thread-safe counter.
// All methods are safe to call from multiple goroutines.
type Counter struct {
value int64
}

// New creates a new Counter with initial value 0.
func New() *Counter {
return &Counter{}
}

// Increment increases the counter by 1.
func (c *Counter) Increment() {
atomic.AddInt64(&c.value, 1)
}

// Decrement decreases the counter by 1.
func (c *Counter) Decrement() {
atomic.AddInt64(&c.value, -1)
}

// Value returns the current value of the counter.
func (c *Counter) Value() int64 {
return atomic.LoadInt64(&c.value)
}

// Reset sets the counter back to 0.
func (c *Counter) Reset() {
atomic.StoreInt64(&c.value, 0)
}

// Add increases the counter by the given delta.
func (c *Counter) Add(delta int64) {
atomic.AddInt64(&c.value, delta)
}

// Subtract decreases the counter by the given delta.
func (c *Counter) Subtract(delta int64) {
atomic.AddInt64(&c.value, -delta)
}

// Set sets the counter to the given value.
func (c *Counter) Set(value int64) {
atomic.StoreInt64(&c.value, value)
}

// WaitAbove returns a channel that will receive a single error when the counter
// value is >= target (inclusive) or when the timeout elapses.
//
// On success the error is nil. On timeout the error is ErrTimeout.
func (c *Counter) WaitAbove(target int64, timeout time.Duration) <-chan error {
result := make(chan error, 1)
go func() {
ticker := time.NewTicker(defaultPollInterval)
defer ticker.Stop()
timeoutCh := time.After(timeout)
for {
select {
case <-ticker.C:
if c.Value() >= target {
result <- nil
return
}
case <-timeoutCh:
result <- ErrTimeout
return
}
}
}()
return result
}

// WaitBelow returns a channel that will receive a single error when the counter
// value is <= target (inclusive) or when the timeout elapses.
//
// On success the error is nil. On timeout the error is ErrTimeout.
func (c *Counter) WaitBelow(target int64, timeout time.Duration) <-chan error {
result := make(chan error, 1)
go func() {
ticker := time.NewTicker(defaultPollInterval)
defer ticker.Stop()
timeoutCh := time.After(timeout)
for {
select {
case <-ticker.C:
if c.Value() <= target {
result <- nil
return
}
case <-timeoutCh:
result <- ErrTimeout
return
}
}
}()
return result
}
Loading
Loading