Skip to content

Commit 45cc9c1

Browse files
authored
feat(helpers/counter): add new thread-safe counter package
* ci: update Go version and Codecov action conditions ☕️ Upgraded to Go 1.24 because it's always sunny in Golang-ville. Added a check before uploading coverage—let's not send empty files to the party! * feat(helpers/counter): add new thread-safe counter package 🧮 Introduces a simple, atomic counter suited for testing scenarios with concurrency in mind. * docs(readme): streamline counter example and remove redundant doc 📉 Updated the README to feature a more concise counter example and deleted the redundant counter README. Less is more, even for Go!
1 parent cb3c123 commit 45cc9c1

File tree

15 files changed

+482
-32
lines changed

15 files changed

+482
-32
lines changed

.github/dependabot.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ updates:
1313
interval: "weekly"
1414
commit-message:
1515
prefix: "chore(things/testurl)"
16+
- package-ecosystem: "gomod"
17+
directory: "/helpers/counter"
18+
schedule:
19+
interval: "weekly"
20+
commit-message:
21+
prefix: "chore(helpers/counter)"
1622

1723
- package-ecosystem: "github-actions"
1824
directory: "/"

.github/workflows/tests.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,15 @@ jobs:
3535
- name: Setup Go
3636
uses: actions/setup-go@v5
3737
with:
38-
go-version: '1.21'
38+
go-version: '1.24'
3939
- name: Test module
4040
working-directory: ${{ matrix.module }}
4141
run: make tests
4242
- name: Upload coverage to Codecov
43+
if: ${{ hashFiles(format('{0}/coverage.out', matrix.module)) != '' }}
4344
uses: codecov/codecov-action@v5
4445
with:
4546
files: ${{ matrix.module }}/coverage.out
46-
flags: ${ matrix.module }-unittests
47+
flags: ${{ matrix.module }}-unittests
4748
name: ${{ matrix.module }}
4849
token: ${{ secrets.CODECOV_TOKEN }}

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
coverage.*
1717
*.coverprofile
1818
profile.cov
19+
*.swp
20+
*.swo
21+
*.swx
1922

2023
# Dependency directories (remove the comment below to include it)
2124
# vendor/

.release-please-config.json

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"package-name": "",
1010
"bump-minor-pre-major": true,
1111
"include-v-in-tag": true,
12-
"extra-files": ["go.mod", "monorepo.go"],
12+
"extra-files": ["go.mod"],
1313
"changelog-path": "CHANGELOG.md"
1414
},
1515
"things/testurl": {
@@ -18,7 +18,16 @@
1818
"bump-minor-pre-major": true,
1919
"include-component-in-tag": true,
2020
"include-v-in-tag": true,
21-
"extra-files": ["things/testurl/go.mod", "things/testurl/things/testurl.go"],
21+
"extra-files": ["things/testurl/go.mod", "things/testurl/url.go"],
22+
"changelog-path": "CHANGELOG.md"
23+
},
24+
"helpers/counter": {
25+
"release-type": "go",
26+
"package-name": "helpers/counter",
27+
"bump-minor-pre-major": true,
28+
"include-component-in-tag": true,
29+
"include-v-in-tag": true,
30+
"extra-files": ["helpers/counter/go.mod"],
2231
"changelog-path": "CHANGELOG.md"
2332
}
2433
},

.release-please-manifest.json

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
{
22
".": "0.3.0",
3-
"modulea": "0.1.0",
4-
"moduleb": "0.1.0",
5-
"things/testurl": "1.1.0"
6-
}
3+
"things/testurl": "1.1.0",
4+
"helpers/counter": "0.1.0"
5+
}

Makefile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
all: build tests lint
44

5-
COMPONENTS = things/testurl
5+
COMPONENTS = things/testurl helpers/counter
66

77
# Run tests for all modules
88
tests:
@@ -29,6 +29,11 @@ build:
2929
format:
3030
@echo "Formatting code..."
3131
@gofmt -s -w .
32+
@if command -v golines >/dev/null 2>&1; then \
33+
golines -w .; \
34+
else \
35+
echo "golines not installed, skipping line wrapping"; \
36+
fi
3237
@for dir in $(COMPONENTS); do \
3338
$(MAKE) -C $$dir format || exit 1; \
3439
done

README.md

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -72,27 +72,25 @@ req := &http.Request{
7272
}
7373
```
7474

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

77-
Instead of this:
78-
79-
```go
80-
// Create a context that is already canceled
81-
ctx, cancel := context.WithCancel(context.Background())
82-
cancel() // Cancel the context immediately
83-
84-
err := doSomething(ctx)
85-
if err == nil {
86-
t.Fatal("Expected error for canceled context, got nil")
87-
}
88-
```
89-
90-
You can now just do this:
77+
Coordinating background goroutines without brittle sleeps? Use a tiny, thread-safe counter that can signal when a condition is met.
9178

9279
```go
93-
err := doSomething(fakectx.Cancelled())
94-
if err == nil {
95-
t.Fatal("Expected error for canceled context, got nil")
80+
// Create a counter.
81+
c := counter.New()
82+
83+
// Kick off work in the background.
84+
go func() {
85+
for i := 0; i < 5; i++ {
86+
c.Increment()
87+
time.Sleep(5 * time.Millisecond)
88+
}
89+
}()
90+
91+
// Wait until value >= 5 or time out.
92+
if err := <-c.WaitAbove(5, time.Second); err != nil {
93+
t.Fatalf("timed out waiting for counter: %v", err)
9694
}
9795
```
9896

@@ -106,6 +104,7 @@ It allows you to take on only the dependencies you need.
106104
| Package | Description | Go Package Reference |
107105
|---------|-------------|----------------------|
108106
| `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) |
107+
| `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) |
109108

110109
---
111110

helpers/counter/Makefile

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
.PHONY: all clean tests lint build format coverage benchmarks
2+
3+
all: build tests lint
4+
5+
# Run tests with coverage
6+
tests:
7+
@echo "Running tests with coverage..."
8+
go test -v -race -covermode=atomic -coverprofile=coverage.out ./...
9+
@go tool cover -func=coverage.out || true
10+
@if command -v go tool cover >/dev/null 2>&1; then \
11+
go tool cover -html=coverage.out -o coverage.html; \
12+
fi
13+
14+
# Run benchmarks
15+
benchmarks:
16+
@echo "Running benchmarks..."
17+
go test -run=^$$ -bench=. -benchmem ./...
18+
19+
# Build the package
20+
build:
21+
@echo "Building package..."
22+
go build ./...
23+
24+
# Format code: fmt, imports (if available), and gofmt -s
25+
format:
26+
@echo "Formatting code..."
27+
@gofmt -s -w .
28+
@if command -v goimports >/dev/null 2>&1; then \
29+
goimports -w .; \
30+
else \
31+
echo "goimports not installed, skipping import reordering"; \
32+
fi
33+
@if command -v golines >/dev/null 2>&1; then \
34+
golines -w .; \
35+
else \
36+
echo "golines not installed, skipping line wrapping"; \
37+
fi
38+
39+
# Lint code if golangci-lint is available
40+
lint:
41+
@echo "Linting code..."
42+
@if command -v golangci-lint >/dev/null 2>&1; then \
43+
golangci-lint run ./...; \
44+
else \
45+
echo "golangci-lint not installed, skipping lint"; \
46+
fi
47+
48+
# Generate coverage report (HTML)
49+
coverage: tests
50+
@go tool cover -html=coverage.out
51+
52+
# Clean build artifacts
53+
clean:
54+
@echo "Cleaning build artifacts..."
55+
@find . -type f -name "*.test" -delete
56+
@rm -f coverage.out coverage.html
57+
@rm -rf tmp

helpers/counter/counter.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/*
2+
Package counter provides a tiny, thread-safe counter focused on
3+
testing scenarios. It supports atomic increments/decrements, direct
4+
reads/sets, and simple wait helpers that block until the value crosses a
5+
threshold.
6+
7+
github.com/madflojo/testlazy/helpers/counter
8+
9+
# Why use it
10+
11+
- Minimal API designed for tests.
12+
- Safe for concurrent use by multiple goroutines.
13+
- Non-blocking "wait" helpers that return a channel for easy select/timeout.
14+
15+
Quick example
16+
17+
c := counter.New()
18+
c.Increment()
19+
c.Add(9)
20+
<-c.WaitAbove(10, time.Second) // wait until value >= 10
21+
*/
22+
package counter
23+
24+
import (
25+
"errors"
26+
"sync/atomic"
27+
"time"
28+
)
29+
30+
// defaultPollInterval controls how often waiters check the counter value.
31+
const defaultPollInterval = 10 * time.Millisecond
32+
33+
// ErrTimeout indicates a WaitAbove or WaitBelow call timed out before the
34+
// condition was met.
35+
var ErrTimeout = errors.New("timeout waiting for counter condition")
36+
37+
// Counter is a thread-safe counter.
38+
// All methods are safe to call from multiple goroutines.
39+
type Counter struct {
40+
value int64
41+
}
42+
43+
// New creates a new Counter with initial value 0.
44+
func New() *Counter {
45+
return &Counter{}
46+
}
47+
48+
// Increment increases the counter by 1.
49+
func (c *Counter) Increment() {
50+
atomic.AddInt64(&c.value, 1)
51+
}
52+
53+
// Decrement decreases the counter by 1.
54+
func (c *Counter) Decrement() {
55+
atomic.AddInt64(&c.value, -1)
56+
}
57+
58+
// Value returns the current value of the counter.
59+
func (c *Counter) Value() int64 {
60+
return atomic.LoadInt64(&c.value)
61+
}
62+
63+
// Reset sets the counter back to 0.
64+
func (c *Counter) Reset() {
65+
atomic.StoreInt64(&c.value, 0)
66+
}
67+
68+
// Add increases the counter by the given delta.
69+
func (c *Counter) Add(delta int64) {
70+
atomic.AddInt64(&c.value, delta)
71+
}
72+
73+
// Subtract decreases the counter by the given delta.
74+
func (c *Counter) Subtract(delta int64) {
75+
atomic.AddInt64(&c.value, -delta)
76+
}
77+
78+
// Set sets the counter to the given value.
79+
func (c *Counter) Set(value int64) {
80+
atomic.StoreInt64(&c.value, value)
81+
}
82+
83+
// WaitAbove returns a channel that will receive a single error when the counter
84+
// value is >= target (inclusive) or when the timeout elapses.
85+
//
86+
// On success the error is nil. On timeout the error is ErrTimeout.
87+
func (c *Counter) WaitAbove(target int64, timeout time.Duration) <-chan error {
88+
result := make(chan error, 1)
89+
go func() {
90+
ticker := time.NewTicker(defaultPollInterval)
91+
defer ticker.Stop()
92+
timeoutCh := time.After(timeout)
93+
for {
94+
select {
95+
case <-ticker.C:
96+
if c.Value() >= target {
97+
result <- nil
98+
return
99+
}
100+
case <-timeoutCh:
101+
result <- ErrTimeout
102+
return
103+
}
104+
}
105+
}()
106+
return result
107+
}
108+
109+
// WaitBelow returns a channel that will receive a single error when the counter
110+
// value is <= target (inclusive) or when the timeout elapses.
111+
//
112+
// On success the error is nil. On timeout the error is ErrTimeout.
113+
func (c *Counter) WaitBelow(target int64, timeout time.Duration) <-chan error {
114+
result := make(chan error, 1)
115+
go func() {
116+
ticker := time.NewTicker(defaultPollInterval)
117+
defer ticker.Stop()
118+
timeoutCh := time.After(timeout)
119+
for {
120+
select {
121+
case <-ticker.C:
122+
if c.Value() <= target {
123+
result <- nil
124+
return
125+
}
126+
case <-timeoutCh:
127+
result <- ErrTimeout
128+
return
129+
}
130+
}
131+
}()
132+
return result
133+
}

0 commit comments

Comments
 (0)